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 - main
pull_request: pull_request:
branches: branches:
- '*' - "*"
jobs: jobs:
build: build:
@ -15,20 +15,23 @@ jobs:
if: "!contains(github.event.head_commit.message, '[ci skip]')" if: "!contains(github.event.head_commit.message, '[ci skip]')"
steps: 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 # Clean should essentially be a no-op, but make sure that it works.
uses: actions/checkout@v3 - name: Clean
- uses: actions/setup-go@v5 run: make clean
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: Build APKs - name: Build APKs
run: make tailscale-debug.apk run: make tailscale-debug.apk
- name: Run tests - name: Run tests
run: make test run: make test

2
.gitignore vendored

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

@ -8,27 +8,16 @@
# The docker image to use for the build environment. Changing this # 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 # will force a rebuild of the docker image. If there is an existing image
# with this name, it will be used. # 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 DEBUG_APK=tailscale-debug.apk
RELEASE_AAB=tailscale-release.aab RELEASE_AAB=tailscale-release.aab
RELEASE_TV_AAB=tailscale-tv-release.aab RELEASE_TV_AAB=tailscale-tv-release.aab
LIBTAILSCALE=android/libs/libtailscale.aar 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. # 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) ifeq ($(shell uname),Linux)
ANDROID_TOOLS_URL="https://dl.google.com/android/repository/commandlinetools-linux-9477386_latest.zip" ANDROID_TOOLS_URL="https://dl.google.com/android/repository/commandlinetools-linux-9477386_latest.zip"
ANDROID_TOOLS_SUM="bd1aa17c7ef10066949c88dc6c9c8d536be27f992a1f3b5a584f9bd2ba5646a0 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) export PATH := $(JAVA_HOME)/bin:$(PATH)
endif 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 is set by fdoid CI and used by tool/* scripts.
TOOLCHAINDIR ?= TOOLCHAINDIR ?=
export 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) # Builds the release AAB and signs it (phone/tablet/chromeOS variant)
.PHONY: release .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 @jarsigner -sigalg SHA256withRSA -digestalg SHA-256 -keystore $(JKS_PATH) -storepass $(JKS_PASSWORD) $(RELEASE_AAB) tailscale
# Builds the release AAB and signs it (androidTV variant) # Builds the release AAB and signs it (androidTV variant)
.PHONY: release-tv .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 @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. # gradle-dependencies groups together the android sources and libtailscale needed to assemble tests/debug/release builds.
.PHONY: gradle-dependencies .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) (cd android && ./gradlew test assembleDebug)
install -C android/build/outputs/apk/debug/android-debug.apk $@ install -C android/build/outputs/apk/debug/android-debug.apk $@
$(RELEASE_AAB): gradle-dependencies $(RELEASE_AAB): version gradle-dependencies
@echo "Building release AAB" @echo "Building release AAB"
(cd android && ./gradlew test bundleRelease) (cd android && ./gradlew test bundleRelease)
install -C ./android/build/outputs/bundle/release/android-release.aab $@ 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" @echo "Building TV release AAB"
(cd android && ./gradlew test bundleRelease_tv) (cd android && ./gradlew test bundleRelease_tv)
install -C ./android/build/outputs/bundle/release_tv/android-release_tv.aab $@ 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) (cd android && ./gradlew assembleApplicationTestAndroidTest)
install -C ./android/build/outputs/apk/androidTest/applicationTest/android-applicationTest-androidTest.apk $@ 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: # Go Builds:
# #
@ -129,14 +142,15 @@ android/libs:
mkdir -p android/libs mkdir -p android/libs
$(GOBIN)/gomobile: $(GOBIN)/gobind go.mod go.sum $(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 $(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 $(LIBTAILSCALE): Makefile android/libs $(shell find libtailscale -name *.go) go.mod go.sum $(GOBIN)/gomobile tailscale.version
gomobile bind -target android -androidapi 26 \ $(GOBIN)/gomobile bind -target android -androidapi 26 \
-ldflags "$(FULL_LDFLAGS)" \ -tags "$$(./build-tags.sh)" \
-ldflags "-w $$(./version-ldflags.sh)" \
-o $@ ./libtailscale -o $@ ./libtailscale
.PHONY: libtailscale .PHONY: libtailscale
@ -157,6 +171,7 @@ env:
@echo ANDROID_STUDIO_ROOT=$(ANDROID_STUDIO_ROOT) @echo ANDROID_STUDIO_ROOT=$(ANDROID_STUDIO_ROOT)
@echo JAVA_HOME=$(JAVA_HOME) @echo JAVA_HOME=$(JAVA_HOME)
@echo TOOLCHAINDIR=$(TOOLCHAINDIR) @echo TOOLCHAINDIR=$(TOOLCHAINDIR)
@echo AVD_IMAGE="$(AVD_IMAGE)"
# Ensure that JKS_PATH and JKS_PASSWORD are set before we attempt a build # Ensure that JKS_PATH and JKS_PASSWORD are set before we attempt a build
# that requires signing. # that requires signing.
@ -180,28 +195,25 @@ androidpath:
@echo 'export PATH=$(ANDROID_HOME)/cmdline-tools/latest/bin:$(ANDROID_HOME)/platform-tools:$$PATH' @echo 'export PATH=$(ANDROID_HOME)/cmdline-tools/latest/bin:$(ANDROID_HOME)/platform-tools:$$PATH'
.PHONY: tag_release .PHONY: tag_release
tag_release: ## Tag the current commit with the current version tag_release: tailscale.version ## Tag the current commit with the current version
git tag -a "$(VERSION_LONG)" -m "OSS and Version updated to ${VERSION_LONG}" 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. .PHONY: bumposs ## Bump to the latest oss and update the versions.
bumposs: update-oss update-version bumposs: update-oss tailscale.version
git commit -sm "android: bumping OSS" -m "OSS and Version updated to ${VERSION_LONG}" android/build.gradle go.mod go.sum 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
git tag -a "$(VERSION_LONG)" -m "OSS and Version updated to ${VERSION_LONG}" source tailscale.version && git tag -a "$${VERSION_LONG}" -m "OSS and Version updated to $${VERSION_LONG}"
.PHONY: bump_version_code .PHONY: bump_version_code
bump_version_code: ## Bump the version code in build.gradle 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 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-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
.PHONY: update-oss .PHONY: update-oss
update-oss: ## Update the tailscale.com go module and update the version in build.gradle update-oss: ## Update the tailscale.com go module
GOPROXY=direct go get tailscale.com@main GOPROXY=direct ./tool/go get tailscale.com@main
go run tailscale.com/cmd/printdep --go > go.toolchain.rev ./tool/go mod tidy -compat=1.23
go mod tidy -compat=1.22 ./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. # Get the commandline tools package, this provides (among other things) the sdkmanager binary.
$(ANDROID_HOME)/cmdline-tools/latest/bin/sdkmanager: $(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 test: gradle-dependencies ## Run the Android tests
(cd android && ./gradlew test) (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 .PHONY: install
install: $(DEBUG_APK) ## Install the debug APK on a connected device install: $(DEBUG_APK) ## Install the debug APK on a connected device
adb install -r $< adb install -r $<
@ -265,7 +292,7 @@ docker-all: docker-build-image docker-run-build $(DOCKER_IMAGE)
.PHONY: docker-shell .PHONY: docker-shell
docker-shell: ## Builds a docker image with the android build env and opens a 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 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 .PHONY: docker-remove-shell-image
docker-remove-shell-image: ## Removes all docker 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 .PHONY: clean
clean: ## Remove build artifacts. Does not purge docker build envs. Use dockerRemoveEnv for that. 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 -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 -pkill -f gradle
-rm tailscale.version
.PHONY: help .PHONY: help
help: ## Show this 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: 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 ```sh
make dockershell make docker-shell
``` ```
Several other makefile recipes are available for setting up the proper build environment and running builds. 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. 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. 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. The docker build image name is parameterized in the makefile and changing it provides a simple means to do this.
### Nix ### Nix

@ -11,7 +11,7 @@ buildscript {
} }
} }
dependencies { 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-gradle-plugin:$kotlin_version"
classpath "org.jetbrains.kotlin:kotlin-serialization:$kotlin_version" classpath "org.jetbrains.kotlin:kotlin-serialization:$kotlin_version"
classpath("com.ncorti.ktfmt.gradle:plugin:0.17.0") classpath("com.ncorti.ktfmt.gradle:plugin:0.17.0")
@ -37,8 +37,17 @@ android {
defaultConfig { defaultConfig {
minSdkVersion 26 minSdkVersion 26
targetSdkVersion 34 targetSdkVersion 34
versionCode 241 versionCode 242
versionName "1.73.13-taf3d3c433-g536e1adcc42" 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 { compileOptions {
@ -46,6 +55,10 @@ android {
targetCompatibility JavaVersion.VERSION_17 targetCompatibility JavaVersion.VERSION_17
} }
lintOptions {
warningsAsErrors true
}
kotlinOptions { kotlinOptions {
jvmTarget = "17" jvmTarget = "17"
} }
@ -54,6 +67,7 @@ android {
jvmTarget = "17" jvmTarget = "17"
} }
buildFeatures { buildFeatures {
buildConfig true
compose true compose true
} }
composeOptions { composeOptions {
@ -66,9 +80,9 @@ android {
applicationTest { applicationTest {
initWith debug initWith debug
manifestPlaceholders.leanbackRequired = false manifestPlaceholders.leanbackRequired = false
buildConfigField "String", "GITHUB_USERNAME", "\"" + getLocalProperty("githubUsername")+"\"" buildConfigField "String", "GITHUB_USERNAME", "\"" + getLocalProperty("githubUsername", "")+"\""
buildConfigField "String", "GITHUB_PASSWORD", "\"" + getLocalProperty("githubPassword")+"\"" buildConfigField "String", "GITHUB_PASSWORD", "\"" + getLocalProperty("githubPassword", "")+"\""
buildConfigField "String", "GITHUB_2FA_SECRET", "\"" + getLocalProperty("github2FASecret")+"\"" buildConfigField "String", "GITHUB_2FA_SECRET", "\"" + getLocalProperty("github2FASecret", "")+"\""
} }
debug { debug {
manifestPlaceholders.leanbackRequired = false manifestPlaceholders.leanbackRequired = false
@ -99,7 +113,7 @@ dependencies {
implementation 'androidx.core:core-ktx:1.13.1' implementation 'androidx.core:core-ktx:1.13.1'
implementation "androidx.browser:browser:1.8.0" implementation "androidx.browser:browser:1.8.0"
implementation "androidx.security:security-crypto:1.1.0-alpha06" 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. // Kotlin dependencies.
implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.3" implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.3"
@ -110,20 +124,21 @@ dependencies {
implementation "org.jetbrains.kotlin:kotlin-reflect:$kotlin_version" implementation "org.jetbrains.kotlin:kotlin-reflect:$kotlin_version"
// Compose dependencies. // 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 composeBom
implementation 'androidx.compose.material3:material3:1.2.1' implementation 'androidx.compose.material3:material3:1.3.0'
implementation 'androidx.compose.material:material-icons-core:1.6.8' implementation 'androidx.compose.material:material-icons-core:1.7.3'
implementation "androidx.compose.ui:ui:1.6.8" implementation "androidx.compose.ui:ui:1.7.3"
implementation "androidx.compose.ui:ui-tooling:1.6.8" implementation "androidx.compose.ui:ui-tooling:1.7.3"
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.8.3' implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.8.6'
implementation 'androidx.activity:activity-compose:1.9.0' implementation 'androidx.activity:activity-compose:1.9.2'
implementation "com.google.accompanist:accompanist-permissions:$accompanist_version" implementation "com.google.accompanist:accompanist-permissions:$accompanist_version"
implementation "com.google.accompanist:accompanist-systemuicontroller:$accompanist_version" implementation "com.google.accompanist:accompanist-systemuicontroller:$accompanist_version"
implementation "androidx.core:core-splashscreen:1.1.0-rc01" implementation "androidx.core:core-splashscreen:1.1.0-rc01"
implementation "androidx.compose.animation:animation:1.7.4"
// Navigation dependencies. // 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-compose:$nav_version"
implementation "androidx.navigation:navigation-ui-ktx:$nav_version" implementation "androidx.navigation:navigation-ui-ktx:$nav_version"
@ -138,7 +153,7 @@ dependencies {
// Integration Tests // Integration Tests
androidTestImplementation composeBom 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-ktx:1.2.1'
androidTestImplementation 'androidx.test.ext:junit:1.2.1' androidTestImplementation 'androidx.test.ext:junit:1.2.1'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.6.1' androidTestImplementation 'androidx.test.espresso:espresso-core:3.6.1'
@ -151,17 +166,30 @@ dependencies {
// Unit Tests // Unit Tests
testImplementation 'junit:junit:4.13.2' 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") debugImplementation("androidx.compose.ui:ui-tooling")
implementation("androidx.compose.ui:ui-tooling-preview") implementation("androidx.compose.ui:ui-tooling-preview")
} }
def getLocalProperty(key) { def getLocalProperty(key, defaultValue) {
try { try {
Properties properties = new Properties() Properties properties = new Properties()
properties.load(project.file('local.properties').newDataInputStream()) properties.load(project.file('local.properties').newDataInputStream())
return properties.getProperty(key) return properties.getProperty(key) ?: defaultValue
} catch(Throwable ignored) { } 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>; 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 specific classes from Tink library
-keep class com.google.crypto.tink.** { *; } -keep class com.google.crypto.tink.** { *; }
@ -18,4 +25,4 @@
# Keep Joda-Time classes # Keep Joda-Time classes
-keep class org.joda.time.** { *; } -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.HmacAlgorithm
import dev.turingcomplete.kotlinonetimepassword.TimeBasedOneTimePasswordConfig import dev.turingcomplete.kotlinonetimepassword.TimeBasedOneTimePasswordConfig
import dev.turingcomplete.kotlinonetimepassword.TimeBasedOneTimePasswordGenerator 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.apache.commons.codec.binary.Base32
import org.junit.After import org.junit.After
import org.junit.Assert import org.junit.Assert
@ -24,11 +29,6 @@ import org.junit.Before
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith 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) @RunWith(AndroidJUnit4::class)
@LargeTest @LargeTest
@ -59,18 +59,18 @@ class MainActivityTest {
timeStep = 30, timeStep = 30,
timeStepUnit = TimeUnit.SECONDS) timeStepUnit = TimeUnit.SECONDS)
val githubTOTP = TimeBasedOneTimePasswordGenerator(github2FASecret, config) val githubTOTP = TimeBasedOneTimePasswordGenerator(github2FASecret, config)
val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) 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") Log.d(TAG, "Click through Get Started screen")
device.find(By.text("Get Started")) device.find(By.text("Get Started"))
device.find(By.text("Get Started")).click() 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( asNecessary(
timeout = 2.minutes, 2.minutes,
{ {
Log.d(TAG, "Log in") Log.d(TAG, "Log in")
device.find(By.text("Log in")).click() device.find(By.text("Log in")).click()
@ -93,7 +93,6 @@ class MainActivityTest {
}, },
{ {
Log.d(TAG, "Make sure GitHub page has loaded") 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("Username or email address"))
device.find(By.text("Sign in")) device.find(By.text("Sign in"))
}, },
@ -115,10 +114,15 @@ class MainActivityTest {
.setText(githubTOTP.generate()) .setText(githubTOTP.generate())
device.find(UiSelector().instance(0).className(Button::class.java)).click() 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") Log.d(TAG, "Accept Tailscale app")
device.find(By.text("Learn more about OAuth")) device.find(By.text("Learn more about OAuth"))
// Sleep a little to give button time to activate // Sleep a little to give button time to activate
Thread.sleep(5.seconds.inWholeMilliseconds) Thread.sleep(5.seconds.inWholeMilliseconds)
device.find(UiSelector().instance(1).className(Button::class.java)).click() device.find(UiSelector().instance(1).className(Button::class.java)).click()
}, },
@ -126,8 +130,7 @@ class MainActivityTest {
Log.d(TAG, "Connect device") Log.d(TAG, "Connect device")
device.find(By.text("Connect device")) device.find(By.text("Connect device"))
device.find(UiSelector().instance(0).className(Button::class.java)).click() device.find(UiSelector().instance(0).className(Button::class.java)).click()
}, })
)
try { try {
Log.d(TAG, "Accept Permission (Either Storage or Notifications)") Log.d(TAG, "Accept Permission (Either Storage or Notifications)")

@ -7,15 +7,13 @@ import android.app.Application
import android.app.Notification import android.app.Notification
import android.app.NotificationChannel import android.app.NotificationChannel
import android.app.PendingIntent import android.app.PendingIntent
import android.content.BroadcastReceiver
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.IntentFilter
import android.content.SharedPreferences import android.content.SharedPreferences
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.net.ConnectivityManager 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.Build
import android.os.Environment import android.os.Environment
import android.util.Log import android.util.Log
@ -28,6 +26,7 @@ import androidx.lifecycle.ViewModelStoreOwner
import androidx.security.crypto.EncryptedSharedPreferences import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey import androidx.security.crypto.MasterKey
import com.tailscale.ipn.mdm.MDMSettings 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.Client
import com.tailscale.ipn.ui.localapi.Request import com.tailscale.ipn.ui.localapi.Request
import com.tailscale.ipn.ui.model.Ipn 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.notifier.Notifier
import com.tailscale.ipn.ui.viewModel.VpnViewModel import com.tailscale.ipn.ui.viewModel.VpnViewModel
import com.tailscale.ipn.ui.viewModel.VpnViewModelFactory import com.tailscale.ipn.ui.viewModel.VpnViewModelFactory
import com.tailscale.ipn.util.TSLog
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.serialization.encodeToString import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import libtailscale.Libtailscale import libtailscale.Libtailscale
import java.io.File import java.io.File
import java.io.IOException import java.io.IOException
import java.net.InetAddress
import java.net.NetworkInterface import java.net.NetworkInterface
import java.security.GeneralSecurityException import java.security.GeneralSecurityException
import java.util.Locale import java.util.Locale
@ -56,11 +57,6 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner {
companion object { companion object {
private const val FILE_CHANNEL_ID = "tailscale-files" private const val FILE_CHANNEL_ID = "tailscale-files"
private const val TAG = "App" 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 private lateinit var appInstance: App
/** /**
@ -76,28 +72,42 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner {
val dns = DnsConfig() val dns = DnsConfig()
private lateinit var connectivityManager: ConnectivityManager private lateinit var connectivityManager: ConnectivityManager
private lateinit var mdmChangeReceiver: MDMSettingsChangedReceiver
private lateinit var app: libtailscale.Application private lateinit var app: libtailscale.Application
override val viewModelStore: ViewModelStore override val viewModelStore: ViewModelStore
get() = appViewModelStore get() = appViewModelStore
lateinit var vpnViewModel: VpnViewModel
private set
private val appViewModelStore: ViewModelStore by lazy { ViewModelStore() } private val appViewModelStore: ViewModelStore by lazy { ViewModelStore() }
var healthNotifier: HealthNotifier? = null var healthNotifier: HealthNotifier? = null
override fun getPlatformDNSConfig(): String = dns.dnsConfigAsString override fun getPlatformDNSConfig(): String = dns.dnsConfigAsString
override fun 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) { override fun log(s: String, s1: String) {
Log.d(s, s1) Log.d(s, s1)
} }
fun getLibtailscaleApp(): libtailscale.Application {
if (!isInitialized) {
initOnce() // Calls the synchronized initialization logic
}
return app
}
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
appInstance = this
setUnprotectedInstance(this)
mdmChangeReceiver = MDMSettingsChangedReceiver()
val filter = IntentFilter(Intent.ACTION_APPLICATION_RESTRICTIONS_CHANGED)
registerReceiver(mdmChangeReceiver, filter)
createNotificationChannel( createNotificationChannel(
STATUS_CHANNEL_ID, STATUS_CHANNEL_ID,
getString(R.string.vpn_status), 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_name),
getString(R.string.health_channel_description), getString(R.string.health_channel_description),
NotificationManagerCompat.IMPORTANCE_HIGH) NotificationManagerCompat.IMPORTANCE_HIGH)
appInstance = this
setUnprotectedInstance(this)
} }
override fun onTerminate() { override fun onTerminate() {
@ -123,17 +131,22 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner {
notificationManager.cancelAll() notificationManager.cancelAll()
applicationScope.cancel() applicationScope.cancel()
viewModelStore.clear() viewModelStore.clear()
unregisterReceiver(mdmChangeReceiver)
} }
private var isInitialized = false @Volatile private var isInitialized = false
@Synchronized @Synchronized
private fun initOnce() { private fun initOnce() {
if (isInitialized) { if (isInitialized) {
return return
} }
initializeApp()
isInitialized = true isInitialized = true
}
private fun initializeApp() {
val dataDir = this.filesDir.absolutePath val dataDir = this.filesDir.absolutePath
// Set this to enable direct mode for taildrop whereby downloads will be saved directly // Set this to enable direct mode for taildrop whereby downloads will be saved directly
@ -145,76 +158,56 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner {
Request.setApp(app) Request.setApp(app)
Notifier.setApp(app) Notifier.setApp(app)
Notifier.start(applicationScope) Notifier.start(applicationScope)
healthNotifier = HealthNotifier(Notifier.health, applicationScope) healthNotifier = HealthNotifier(Notifier.health, Notifier.state, applicationScope)
connectivityManager = this.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager connectivityManager = this.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
setAndRegisterNetworkCallbacks() NetworkChangeCallback.monitorDnsChanges(connectivityManager, dns)
initViewModels()
applicationScope.launch { applicationScope.launch {
Notifier.state.collect { state -> Notifier.state.collect { _ ->
val ableToStartVPN = state > Ipn.State.NeedsMachineAuth combine(Notifier.state, MDMSettings.forceEnabled.flow) { state, forceEnabled ->
// If VPN is stopped, show a disconnected notification. If it is running as a foregrround Pair(state, forceEnabled)
// service, IPNService will show a connected notification. }
if (state == Ipn.State.Stopped) { .collect { (state, hideDisconnectAction) ->
notifyStatus(false) val ableToStartVPN = state > Ipn.State.NeedsMachineAuth
} // If VPN is stopped, show a disconnected notification. If it is running as a
val vpnRunning = state == Ipn.State.Starting || state == Ipn.State.Running // foreground
updateConnStatus(ableToStartVPN) // service, IPNService will show a connected notification.
QuickToggleService.setVPNRunning(vpnRunning) 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() { private fun initViewModels() {
vpnViewModel = ViewModelProvider(this, VpnViewModelFactory(this)).get(VpnViewModel::class.java) 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 -> val callback: (Result<Ipn.Prefs>) -> Unit = { result ->
result.fold( result.fold(
onSuccess = {}, onSuccess = { onSuccess?.invoke() },
onFailure = { error -> 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) Client(applicationScope)
.editPrefs(Ipn.MaskedPrefs().apply { WantRunning = wantRunning }, callback) .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 // encryptToPref a byte array of data using the Jetpack Security
// library and writes it to a global encrypted preference store. // library and writes it to a global encrypted preference store.
@Throws(IOException::class, GeneralSecurityException::class) @Throws(IOException::class, GeneralSecurityException::class)
@ -249,7 +242,7 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner {
private fun updateConnStatus(ableToStartVPN: Boolean) { private fun updateConnStatus(ableToStartVPN: Boolean) {
setAbleToStartVPN(ableToStartVPN) setAbleToStartVPN(ableToStartVPN)
QuickToggleService.updateTile() QuickToggleService.updateTile()
Log.d("App", "Set Tile Ready: $ableToStartVPN") TSLog.d("App", "Set Tile Ready: $ableToStartVPN")
} }
override fun getModelName(): String { override fun getModelName(): String {
@ -312,14 +305,14 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner {
downloads.mkdirs() downloads.mkdirs()
} }
} catch (e: Exception) { } 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") downloads = File(this.filesDir, "Taildrop")
try { try {
if (!downloads.exists()) { if (!downloads.exists()) {
downloads.mkdirs() downloads.mkdirs()
} }
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "Failed to create Taildrop folder: $e") TSLog.e(TAG, "Failed to create Taildrop folder: $e")
downloads = File("") downloads = File("")
} }
} }
@ -354,7 +347,7 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner {
val list = setting.value as? List<*> val list = setting.value as? List<*>
return Json.encodeToString(list) return Json.encodeToString(list)
} catch (e: Exception) { } 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() throw MDMSettings.NoSuchKeyException()
} }
} }
@ -389,6 +382,8 @@ open class UninitializedApp : Application() {
private lateinit var appInstance: UninitializedApp private lateinit var appInstance: UninitializedApp
lateinit var notificationManager: NotificationManagerCompat lateinit var notificationManager: NotificationManagerCompat
lateinit var vpnViewModel: VpnViewModel
@JvmStatic @JvmStatic
fun get(): UninitializedApp { fun get(): UninitializedApp {
return appInstance return appInstance
@ -414,16 +409,27 @@ open class UninitializedApp : Application() {
fun startVPN() { fun startVPN() {
val intent = Intent(this, IPNService::class.java).apply { action = IPNService.ACTION_START_VPN } val intent = Intent(this, IPNService::class.java).apply { action = IPNService.ACTION_START_VPN }
// FLAG_UPDATE_CURRENT ensures that if the intent is already pending, the existing intent will
// 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 { try {
startForegroundService(intent) pendingIntent.send()
} catch (foregroundServiceStartException: IllegalStateException) { } catch (foregroundServiceStartException: IllegalStateException) {
Log.e( TSLog.e(
TAG, TAG,
"startVPN hit ForegroundServiceStartNotAllowedException in startForegroundService(): $foregroundServiceStartException") "startVPN hit ForegroundServiceStartNotAllowedException: $foregroundServiceStartException")
} catch (securityException: SecurityException) { } catch (securityException: SecurityException) {
Log.e(TAG, "startVPN hit SecurityException in startForegroundService(): $securityException") TSLog.e(TAG, "startVPN hit SecurityException: $securityException")
} catch (e: Exception) { } 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 { try {
startService(intent) startService(intent)
} catch (illegalStateException: IllegalStateException) { } catch (illegalStateException: IllegalStateException) {
Log.e(TAG, "stopVPN hit IllegalStateException in startService(): $illegalStateException") TSLog.e(TAG, "stopVPN hit IllegalStateException in startService(): $illegalStateException")
} catch (e: Exception) { } 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() { 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() stopVPN()
startVPN()
} }
fun createNotificationChannel(id: String, name: String, description: String, importance: Int) { fun createNotificationChannel(id: String, name: String, description: String, importance: Int) {
@ -451,8 +473,8 @@ open class UninitializedApp : Application() {
notificationManager.createNotificationChannel(channel) notificationManager.createNotificationChannel(channel)
} }
fun notifyStatus(vpnRunning: Boolean) { fun notifyStatus(vpnRunning: Boolean, hideDisconnectAction: Boolean) {
notifyStatus(buildStatusNotification(vpnRunning)) notifyStatus(buildStatusNotification(vpnRunning, hideDisconnectAction))
} }
fun notifyStatus(notification: Notification) { fun notifyStatus(notification: Notification) {
@ -470,7 +492,7 @@ open class UninitializedApp : Application() {
notificationManager.notify(STATUS_NOTIFICATION_ID, notification) 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 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 icon = if (vpnRunning) R.drawable.ic_notification else R.drawable.ic_notification_disabled
val action = val action =
@ -492,24 +514,27 @@ open class UninitializedApp : Application() {
PendingIntent.getActivity( PendingIntent.getActivity(
this, 1, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE) this, 1, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
return NotificationCompat.Builder(this, STATUS_CHANNEL_ID) val builder =
.setSmallIcon(icon) NotificationCompat.Builder(this, STATUS_CHANNEL_ID)
.setContentTitle("Tailscale") .setSmallIcon(icon)
.setContentText(message) .setContentTitle(getString(R.string.app_name))
.setAutoCancel(!vpnRunning) .setContentText(message)
.setOnlyAlertOnce(!vpnRunning) .setAutoCancel(!vpnRunning)
.setOngoing(vpnRunning) .setOnlyAlertOnce(!vpnRunning)
.setSilent(true) .setOngoing(vpnRunning)
.setOngoing(false) .setSilent(true)
.setPriority(NotificationCompat.PRIORITY_DEFAULT) .setPriority(NotificationCompat.PRIORITY_DEFAULT)
.addAction(NotificationCompat.Action.Builder(0, actionLabel, pendingButtonIntent).build()) .setContentIntent(pendingIntent)
.setContentIntent(pendingIntent) if (!vpnRunning || !hideDisconnectAction) {
.build() builder.addAction(
NotificationCompat.Action.Builder(0, actionLabel, pendingButtonIntent).build())
}
return builder.build()
} }
fun addUserDisallowedPackageName(packageName: String) { fun addUserDisallowedPackageName(packageName: String) {
if (packageName.isEmpty()) { if (packageName.isEmpty()) {
Log.e(TAG, "addUserDisallowedPackageName called with empty packageName") TSLog.e(TAG, "addUserDisallowedPackageName called with empty packageName")
return return
} }
@ -524,7 +549,7 @@ open class UninitializedApp : Application() {
fun removeUserDisallowedPackageName(packageName: String) { fun removeUserDisallowedPackageName(packageName: String) {
if (packageName.isEmpty()) { if (packageName.isEmpty()) {
Log.e(TAG, "removeUserDisallowedPackageName called with empty packageName") TSLog.e(TAG, "removeUserDisallowedPackageName called with empty packageName")
return return
} }
@ -542,7 +567,7 @@ open class UninitializedApp : Application() {
val mdmDisallowed = val mdmDisallowed =
MDMSettings.excludedPackages.flow.value.value?.split(",")?.map { it.trim() } ?: emptyList() MDMSettings.excludedPackages.flow.value.value?.split(",")?.map { it.trim() } ?: emptyList()
if (mdmDisallowed.isNotEmpty()) { 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 return builtInDisallowedPackageNames + mdmDisallowed
} }
val userDisallowed = val userDisallowed =
@ -550,6 +575,10 @@ open class UninitializedApp : Application() {
return builtInDisallowedPackageNames + userDisallowed return builtInDisallowedPackageNames + userDisallowed
} }
fun getAppScopedViewModel(): VpnViewModel {
return vpnViewModel
}
val builtInDisallowedPackageNames: List<String> = val builtInDisallowedPackageNames: List<String> =
listOf( listOf(
// RCS/Jibe https://github.com/tailscale/tailscale/issues/2322 // RCS/Jibe https://github.com/tailscale/tailscale/issues/2322
@ -572,5 +601,7 @@ open class UninitializedApp : Application() {
"com.vna.service.vvm", "com.vna.service.vvm",
"com.dish.vvm", "com.dish.vvm",
"com.comcast.modesto.vvm.client", "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.net.VpnService
import android.os.Build import android.os.Build
import android.system.OsConstants import android.system.OsConstants
import android.util.Log
import com.tailscale.ipn.mdm.MDMSettings 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 libtailscale.Libtailscale
import java.util.UUID import java.util.UUID
open class IPNService : VpnService(), libtailscale.IPNService { open class IPNService : VpnService(), libtailscale.IPNService {
private val TAG = "IPNService" private val TAG = "IPNService"
private val randomID: String = UUID.randomUUID().toString() private val randomID: String = UUID.randomUUID().toString()
private lateinit var app: App
val scope = CoroutineScope(Dispatchers.IO)
override fun id(): String { override fun id(): String {
return randomID return randomID
} }
override fun updateVpnStatus(status: Boolean) {
app.getAppScopedViewModel().setVpnActive(status)
}
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
// grab app to make sure it initializes // grab app to make sure it initializes
App.get() app = App.get()
} }
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int = override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int =
when (intent?.action) { when (intent?.action) {
ACTION_STOP_VPN -> { ACTION_STOP_VPN -> {
App.get().setWantRunning(false) app.setWantRunning(false)
close() close()
START_NOT_STICKY START_NOT_STICKY
} }
ACTION_START_VPN -> { ACTION_START_VPN -> {
showForegroundNotification() scope.launch {
App.get().setWantRunning(true) // Collect the first value of hideDisconnectAction asynchronously.
val hideDisconnectAction = MDMSettings.forceEnabled.flow.first()
showForegroundNotification(hideDisconnectAction.value)
}
app.setWantRunning(true)
Libtailscale.requestVPN(this) Libtailscale.requestVPN(this)
START_STICKY START_STICKY
} }
@ -44,8 +60,12 @@ open class IPNService : VpnService(), libtailscale.IPNService {
// This means we were started by Android due to Always On VPN. // This means we were started by Android due to Always On VPN.
// We show a non-foreground notification because we weren't // We show a non-foreground notification because we weren't
// started as a foreground service. // started as a foreground service.
App.get().notifyStatus(true) scope.launch {
App.get().setWantRunning(true) // Collect the first value of hideDisconnectAction asynchronously.
val hideDisconnectAction = MDMSettings.forceEnabled.flow.first()
app.notifyStatus(true, hideDisconnectAction.value)
}
app.setWantRunning(true)
Libtailscale.requestVPN(this) Libtailscale.requestVPN(this)
START_STICKY START_STICKY
} }
@ -53,7 +73,11 @@ open class IPNService : VpnService(), libtailscale.IPNService {
// This means that we were restarted after the service was killed // This means that we were restarted after the service was killed
// (potentially due to OOM). // (potentially due to OOM).
if (UninitializedApp.get().isAbleToStartVPN()) { 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() App.get()
Libtailscale.requestVPN(this) Libtailscale.requestVPN(this)
START_STICKY START_STICKY
@ -64,27 +88,39 @@ open class IPNService : VpnService(), libtailscale.IPNService {
} }
override fun close() { override fun close() {
stopForeground(STOP_FOREGROUND_REMOVE) app.setWantRunning(false) {}
Notifier.setState(Ipn.State.Stopping)
disconnectVPN()
Libtailscale.serviceDisconnect(this) Libtailscale.serviceDisconnect(this)
} }
override fun disconnectVPN() {
stopSelf()
}
override fun onDestroy() { override fun onDestroy() {
close() close()
updateVpnStatus(false)
super.onDestroy() super.onDestroy()
} }
override fun onRevoke() { override fun onRevoke() {
close() close()
updateVpnStatus(false)
super.onRevoke() super.onRevoke()
} }
private fun showForegroundNotification() { private fun setVpnPrepared(isPrepared: Boolean) {
app.getAppScopedViewModel().setVpnPrepared(isPrepared)
}
private fun showForegroundNotification(hideDisconnectAction: Boolean) {
try { try {
startForeground( startForeground(
UninitializedApp.STATUS_NOTIFICATION_ID, UninitializedApp.STATUS_NOTIFICATION_ID,
UninitializedApp.get().buildStatusNotification(true)) UninitializedApp.get().buildStatusNotification(true, hideDisconnectAction))
} catch (e: Exception) { } 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 { try {
b.addDisallowedApplication(name) b.addDisallowedApplication(name)
} catch (e: PackageManager.NameNotFoundException) { } 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, // Tailscale,
// then only allow those apps. // then only allow those apps.
for (packageName in includedPackages) { for (packageName in includedPackages) {
Log.d(TAG, "Including app: $packageName") TSLog.d(TAG, "Including app: $packageName")
b.addAllowedApplication(packageName) b.addAllowedApplication(packageName)
} }
} else { } else {
@ -130,7 +166,7 @@ open class IPNService : VpnService(), libtailscale.IPNService {
// - any app that the user manually disallowed in the GUI // - any app that the user manually disallowed in the GUI
// - any app that we disallowed via hard-coding // - any app that we disallowed via hard-coding
for (disallowedPackageName in UninitializedApp.get().disallowedPackageNames()) { for (disallowedPackageName in UninitializedApp.get().disallowedPackageNames()) {
Log.d(TAG, "Disallowing app: $disallowedPackageName") TSLog.d(TAG, "Disallowing app: $disallowedPackageName")
disallowApp(b, disallowedPackageName) disallowApp(b, disallowedPackageName)
} }
} }

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

@ -8,20 +8,24 @@ import android.net.Uri
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.provider.OpenableColumns import android.provider.OpenableColumns
import android.util.Log
import android.webkit.MimeTypeMap import android.webkit.MimeTypeMap
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.lifecycle.lifecycleScope
import com.tailscale.ipn.ui.model.Ipn import com.tailscale.ipn.ui.model.Ipn
import com.tailscale.ipn.ui.theme.AppTheme import com.tailscale.ipn.ui.theme.AppTheme
import com.tailscale.ipn.ui.util.set import com.tailscale.ipn.ui.util.set
import com.tailscale.ipn.ui.util.universalFit import com.tailscale.ipn.ui.util.universalFit
import com.tailscale.ipn.ui.view.TaildropView 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.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlin.random.Random import kotlin.random.Random
// ShareActivity is the entry point for Taildrop share intents // ShareActivity is the entry point for Taildrop share intents
@ -47,7 +51,7 @@ class ShareActivity : ComponentActivity() {
super.onStart() super.onStart()
// Ensure our app instance is initialized // Ensure our app instance is initialized
App.get() App.get()
loadFiles() lifecycleScope.launch { withContext(Dispatchers.IO) { loadFiles() } }
} }
override fun onNewIntent(intent: Intent) { override fun onNewIntent(intent: Intent) {
@ -59,7 +63,7 @@ class ShareActivity : ComponentActivity() {
// Loads the files from the intent. // Loads the files from the intent.
fun loadFiles() { fun loadFiles() {
if (intent == null) { if (intent == null) {
Log.e(TAG, "Share failure - No intent found") TSLog.e(TAG, "Share failure - No intent found")
return return
} }
@ -83,43 +87,42 @@ class ShareActivity : ComponentActivity() {
} }
} }
else -> { else -> {
Log.e(TAG, "No extras found in intent - nothing to share") TSLog.e(TAG, "No extras found in intent - nothing to share")
null null
} }
} }
val pendingFiles: List<Ipn.OutgoingFile> = val pendingFiles: List<Ipn.OutgoingFile> =
uris?.filterNotNull()?.mapNotNull { uris?.filterNotNull()?.mapNotNull { uri ->
contentResolver?.query(it, null, null, null, null)?.let { c -> contentResolver?.query(uri, null, null, null, null)?.use { cursor ->
val nameCol = c.getColumnIndex(OpenableColumns.DISPLAY_NAME) val nameCol = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
val sizeCol = c.getColumnIndex(OpenableColumns.SIZE) val sizeCol = cursor.getColumnIndex(OpenableColumns.SIZE)
c.moveToFirst()
val name: String = if (cursor.moveToFirst()) {
c.getString(nameCol) val name: String = cursor.getString(nameCol)
?: run { ?: generateFallbackName(uri)
// For some reason, some content resolvers don't return a name. val size: Long = cursor.getLong(sizeCol)
// Try to build a name from a random integer plus file extension Ipn.OutgoingFile(Name = name, DeclaredSize = size).apply {
// (if type can be determined), else just a random integer. this.uri = uri
val rand = Random.nextLong()
contentResolver.getType(it)?.let { mimeType ->
MimeTypeMap.getSingleton().getExtensionFromMimeType(mimeType)?.let {
extension ->
"$rand.$extension"
} ?: "$rand"
} ?: "$rand"
} }
val size = c.getLong(sizeCol) } else {
c.close() TSLog.e(TAG, "Cursor is empty for URI: $uri")
val file = Ipn.OutgoingFile(Name = name, DeclaredSize = size) null
file.uri = it }
file }
} } ?: emptyList()
} ?: emptyList()
if (pendingFiles.isEmpty()) { 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) 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.Worker;
import androidx.work.WorkerParameters; import androidx.work.WorkerParameters;
import com.tailscale.ipn.util.TSLog;
/** /**
* A worker that exists to support IPNReceiver. * 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. // 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 // Send notification
NotificationManager notificationManager = (NotificationManager) app.getSystemService(Context.NOTIFICATION_SERVICE); 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) : class StringArrayListMDMSetting(key: String, localizedTitle: String) :
MDMSetting<List<String>?>(null, key, localizedTitle) { MDMSetting<List<String>?>(null, key, localizedTitle) {
override fun getFromBundle(bundle: Bundle) = bundle.getStringArrayList(key) override fun getFromBundle(bundle: Bundle): List<String>? {
override fun getFromPrefs(prefs: SharedPreferences) = // Try to retrieve the value as a String[] first
prefs.getStringSet(key, HashSet<String>())?.toList() 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) : class AlwaysNeverUserDecidesMDMSetting(key: String, localizedTitle: String) :

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

@ -18,7 +18,11 @@ class Ipn {
NeedsMachineAuth(3), NeedsMachineAuth(3),
Stopped(4), Stopped(4),
Starting(5), 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 { companion object {
fun fromInt(value: Int): State { fun fromInt(value: Int): State {
@ -158,7 +162,7 @@ class Ipn {
ForceDaemonSet = true ForceDaemonSet = true
} }
var Hostname: Boolean? = null var Hostname: String? = null
set(value) { set(value) {
field = value field = value
HostnameSet = true HostnameSet = true

@ -4,6 +4,7 @@
package com.tailscale.ipn.ui.model package com.tailscale.ipn.ui.model
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import java.net.URL
class IpnState { class IpnState {
@Serializable @Serializable
@ -123,9 +124,29 @@ class IpnLocal {
val UserProfile: Tailcfg.UserProfile, val UserProfile: Tailcfg.UserProfile,
val NetworkProfile: Tailcfg.NetworkProfile? = null, val NetworkProfile: Tailcfg.NetworkProfile? = null,
val LocalUserID: String, val LocalUserID: String,
var ControlURL: String? = null,
) { ) {
fun isEmpty(): Boolean { fun isEmpty(): Boolean {
return ID.isEmpty() 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 @Composable
get() { get() {
val permissionStates = rememberMultiplePermissionsState(permissions = all.map { it.name }) 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 !state.status.isGranted && !state.status.shouldShowRationale
} }
} }

@ -5,7 +5,6 @@ package com.tailscale.ipn.ui.notifier
import android.Manifest import android.Manifest
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.util.Log
import androidx.core.app.ActivityCompat import androidx.core.app.ActivityCompat
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import com.tailscale.ipn.App 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.UninitializedApp.Companion.notificationManager
import com.tailscale.ipn.ui.model.Health import com.tailscale.ipn.ui.model.Health
import com.tailscale.ipn.ui.model.Health.UnhealthyState 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.ui.util.set
import com.tailscale.ipn.util.TSLog
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -25,6 +27,7 @@ import kotlinx.coroutines.launch
@OptIn(FlowPreview::class) @OptIn(FlowPreview::class)
class HealthNotifier( class HealthNotifier(
healthStateFlow: StateFlow<Health.State?>, healthStateFlow: StateFlow<Health.State?>,
ipnStateFlow: StateFlow<Ipn.State>,
scope: CoroutineScope, scope: CoroutineScope,
) { ) {
companion object { companion object {
@ -45,11 +48,22 @@ class HealthNotifier(
scope.launch { scope.launch {
healthStateFlow healthStateFlow
.distinctUntilChanged { old, new -> old?.Warnings?.count() == new?.Warnings?.count() } .distinctUntilChanged { old, new -> old?.Warnings?.count() == new?.Warnings?.count() }
.combine(ipnStateFlow, ::Pair)
.debounce(5000) .debounce(5000)
.collect { health -> .collect { pair ->
Log.d(TAG, "Health updated: ${health?.Warnings?.keys?.sorted()}") val health = pair.first
health?.Warnings?.let { val ipnState = pair.second
notifyHealthUpdated(it.values.mapNotNull { it }.toTypedArray()) // 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 warningsBeforeAdd = currentWarnings.value
val currentWarnableCodes = warnings.map { it.WarnableCode }.toSet() val currentWarnableCodes = warnings.map { it.WarnableCode }.toSet()
val addedWarnings: MutableSet<UnhealthyState> = mutableSetOf() val addedWarnings: MutableSet<UnhealthyState> = mutableSetOf()
val removedByNewDependency: MutableSet<UnhealthyState> = mutableSetOf()
val isWarmingUp = warnings.any { it.WarnableCode == "warming-up" } 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) { for (warning in warnings) {
if (ignoredWarnableCodes.contains(warning.WarnableCode)) { if (ignoredWarnableCodes.contains(warning.WarnableCode)) {
continue continue
@ -76,28 +106,37 @@ class HealthNotifier(
continue continue
} else if (warning.hiddenByDependencies(currentWarnableCodes)) { } else if (warning.hiddenByDependencies(currentWarnableCodes)) {
// Ignore this warning because a dependency is also unhealthy // 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 continue
} else if (!isWarmingUp) { } 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) this.currentWarnings.set(this.currentWarnings.value + warning)
dropDependenciesForAddedWarning(warning)
if (warning.Severity == Health.Severity.high) { if (warning.Severity == Health.Severity.high) {
this.sendNotification(warning.Title, warning.Text, warning.WarnableCode) this.sendNotification(warning.Title, warning.Text, warning.WarnableCode)
} }
} else { } 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()) { if (warningsToDrop.isNotEmpty()) {
Log.d(TAG, "Dropping health warnings with codes $warningsToDrop") TSLog.d(TAG, "Dropping health warnings with codes $warningsToDrop")
this.removeNotifications(warningsToDrop) this.removeNotifications(warningsToDrop)
} }
currentWarnings.set(this.currentWarnings.value.subtract(warningsToDrop)) currentWarnings.set(this.currentWarnings.value.subtract(warningsToDrop))
this.updateIcon() 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() { private fun updateIcon() {
if (currentWarnings.value.isEmpty()) { if (currentWarnings.value.isEmpty()) {
this.currentIcon.set(null) this.currentIcon.set(null)
@ -113,7 +152,7 @@ class HealthNotifier(
} }
private fun sendNotification(title: String, text: String, code: String) { 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 = val notification =
NotificationCompat.Builder(App.get().applicationContext, HEALTH_CHANNEL_ID) NotificationCompat.Builder(App.get().applicationContext, HEALTH_CHANNEL_ID)
.setSmallIcon(R.drawable.ic_notification) .setSmallIcon(R.drawable.ic_notification)
@ -125,14 +164,24 @@ class HealthNotifier(
if (ActivityCompat.checkSelfPermission( if (ActivityCompat.checkSelfPermission(
App.get().applicationContext, Manifest.permission.POST_NOTIFICATIONS) != App.get().applicationContext, Manifest.permission.POST_NOTIFICATIONS) !=
PackageManager.PERMISSION_GRANTED) { PackageManager.PERMISSION_GRANTED) {
Log.d(TAG, "Notification permission not granted") TSLog.d(TAG, "Notification permission not granted")
return return
} }
notificationManager.notify(code.hashCode(), notification) 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>) { private fun removeNotifications(warnings: Set<UnhealthyState>) {
Log.d(TAG, "Removing notifications for $warnings") TSLog.d(TAG, "Removing notifications for $warnings")
for (warning in warnings) { for (warning in warnings) {
notificationManager.cancel(warning.WarnableCode.hashCode()) notificationManager.cancel(warning.WarnableCode.hashCode())
} }

@ -3,7 +3,6 @@
package com.tailscale.ipn.ui.notifier package com.tailscale.ipn.ui.notifier
import android.util.Log
import com.tailscale.ipn.App import com.tailscale.ipn.App
import com.tailscale.ipn.ui.model.Empty import com.tailscale.ipn.ui.model.Empty
import com.tailscale.ipn.ui.model.Health 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.Ipn.Notify
import com.tailscale.ipn.ui.model.Netmap import com.tailscale.ipn.ui.model.Netmap
import com.tailscale.ipn.ui.util.set import com.tailscale.ipn.ui.util.set
import com.tailscale.ipn.util.TSLog
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@ -33,7 +33,8 @@ object Notifier {
private val decoder = Json { ignoreUnknownKeys = true } private val decoder = Json { ignoreUnknownKeys = true }
// General IPN Bus State // 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 netmap: StateFlow<Netmap.NetworkMap?> = MutableStateFlow(null)
val prefs: StateFlow<Ipn.Prefs?> = MutableStateFlow(null) val prefs: StateFlow<Ipn.Prefs?> = MutableStateFlow(null)
val engineStatus: StateFlow<Ipn.EngineStatus?> = MutableStateFlow(null) val engineStatus: StateFlow<Ipn.EngineStatus?> = MutableStateFlow(null)
@ -51,14 +52,16 @@ object Notifier {
private lateinit var app: libtailscale.Application private lateinit var app: libtailscale.Application
private var manager: libtailscale.NotificationManager? = null private var manager: libtailscale.NotificationManager? = null
@Synchronized
@JvmStatic @JvmStatic
fun setApp(newApp: libtailscale.Application) { fun setApp(newApp: libtailscale.Application) {
app = newApp app = newApp
} }
@Synchronized
@OptIn(ExperimentalSerializationApi::class) @OptIn(ExperimentalSerializationApi::class)
fun start(scope: CoroutineScope) { fun start(scope: CoroutineScope) {
Log.d(TAG, "Starting") TSLog.d(TAG, "Starting Notifier")
if (!::app.isInitialized) { if (!::app.isInitialized) {
App.get() App.get()
} }
@ -67,7 +70,8 @@ object Notifier {
NotifyWatchOpt.Netmap.value or NotifyWatchOpt.Netmap.value or
NotifyWatchOpt.Prefs.value or NotifyWatchOpt.Prefs.value or
NotifyWatchOpt.InitialState.value or NotifyWatchOpt.InitialState.value or
NotifyWatchOpt.InitialHealthState.value NotifyWatchOpt.InitialHealthState.value or
NotifyWatchOpt.RateLimitNetmaps.value
manager = manager =
app.watchNotifications(mask.toLong()) { notification -> app.watchNotifications(mask.toLong()) { notification ->
val notify = decoder.decodeFromStream<Notify>(notification.inputStream()) val notify = decoder.decodeFromStream<Notify>(notification.inputStream())
@ -88,7 +92,7 @@ object Notifier {
} }
fun stop() { fun stop() {
Log.d(TAG, "Stopping") TSLog.d(TAG, "Stopping Notifier")
manager?.let { manager?.let {
it.stop() it.stop()
manager = null manager = null
@ -106,5 +110,10 @@ object Notifier {
InitialTailFSShares(32), InitialTailFSShares(32),
InitialOutgoingFiles(64), InitialOutgoingFiles(64),
InitialHealthState(128), 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. // margins in list items.
bodyMedium = MaterialTheme.typography.bodyMedium.copy(fontSize = 16.sp)) bodyMedium = MaterialTheme.typography.bodyMedium.copy(fontSize = 16.sp))
val systemUiController = rememberSystemUiController() // TODO: Migrate to Activity.enableEdgeToEdge
@Suppress("deprecation") val systemUiController = rememberSystemUiController()
DisposableEffect(systemUiController, useDarkTheme) { DisposableEffect(systemUiController, useDarkTheme) {
systemUiController.setStatusBarColor(color = colors.surfaceContainer) systemUiController.setStatusBarColor(color = colors.surfaceContainer)
@ -446,7 +447,6 @@ val ColorScheme.disabled: Color
val ColorScheme.searchBarColors: TextFieldColors val ColorScheme.searchBarColors: TextFieldColors
@Composable @Composable
get() { get() {
val defaults = OutlinedTextFieldDefaults.colors()
return OutlinedTextFieldDefaults.colors( return OutlinedTextFieldDefaults.colors(
focusedLeadingIconColor = MaterialTheme.colorScheme.onSurface, focusedLeadingIconColor = MaterialTheme.colorScheme.onSurface,
unfocusedLeadingIconColor = MaterialTheme.colorScheme.onSurface, unfocusedLeadingIconColor = MaterialTheme.colorScheme.onSurface,

@ -15,7 +15,7 @@ import com.tailscale.ipn.ui.util.AndroidTVUtil.isAndroidTV
object AndroidTVUtil { object AndroidTVUtil {
fun isAndroidTV(): Boolean { fun isAndroidTV(): Boolean {
val pm = UninitializedApp.get().packageManager val pm = UninitializedApp.get().packageManager
return (pm.hasSystemFeature(PackageManager.FEATURE_TELEVISION) || return (pm.hasSystemFeature(@Suppress("deprecation") PackageManager.FEATURE_TELEVISION) ||
pm.hasSystemFeature(PackageManager.FEATURE_LEANBACK)) 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 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.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.padding
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.size
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.ListItem import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier 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.platform.LocalClipboardManager
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.tailscale.ipn.R import com.tailscale.ipn.R
import com.tailscale.ipn.ui.theme.titledListItem
import com.tailscale.ipn.ui.util.AndroidTVUtil.isAndroidTV
@Composable @Composable
fun ClipboardValueView(value: String, title: String? = null, subtitle: String? = null) { fun ClipboardValueView(value: String, title: String? = null, subtitle: String? = null) {
val localClipboardManager = LocalClipboardManager.current val isFocused = remember { mutableStateOf(false) }
val modifier = val localClipboardManager = LocalClipboardManager.current
if (isAndroidTV()) { val interactionSource = remember { MutableInteractionSource() }
Modifier
} else {
Modifier.clickable { localClipboardManager.setText(AnnotatedString(value)) }
}
ListItem( ListItem(
colors = MaterialTheme.colorScheme.titledListItem, modifier = Modifier
modifier = modifier, .focusable(interactionSource = interactionSource)
overlineContent = title?.let { { Text(it, style = MaterialTheme.typography.titleMedium) } }, .onFocusChanged { focusState -> isFocused.value = focusState.isFocused }
headlineContent = { Text(text = value, style = MaterialTheme.typography.bodyMedium) }, .clickable(
supportingContent = interactionSource = interactionSource,
subtitle?.let { indication = LocalIndication.current
{ ) { localClipboardManager.setText(AnnotatedString(value)) }
Text( .background(
it, if (isFocused.value) MaterialTheme.colorScheme.primary.copy(alpha = 0.12f)
modifier = Modifier.padding(top = 8.dp), else Color.Transparent
style = MaterialTheme.typography.bodyMedium) ),
} overlineContent = title?.let { { Text(it, style = MaterialTheme.typography.titleMedium) } },
}, headlineContent = { Text(text = value, style = MaterialTheme.typography.bodyMedium) },
trailingContent = { supportingContent = subtitle?.let {
Icon( { Text(it, modifier = Modifier.padding(top = 8.dp), style = MaterialTheme.typography.bodyMedium) }
painterResource(R.drawable.clipboard), },
stringResource(R.string.copy_to_clipboard), trailingContent = {
modifier = Modifier.width(24.dp).height(24.dp)) 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 package com.tailscale.ipn.ui.util
import android.util.Log
import com.tailscale.ipn.R import com.tailscale.ipn.R
import com.tailscale.ipn.util.TSLog
import java.time.Duration import java.time.Duration
import java.time.Instant import java.time.Instant
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
@ -108,12 +108,12 @@ object TimeUtil {
'm' -> durationFragment * 60.0 'm' -> durationFragment * 60.0
's' -> durationFragment 's' -> durationFragment
else -> { else -> {
Log.e(TAG, "Invalid duration string: $goDuration") TSLog.e(TAG, "Invalid duration string: $goDuration")
return null return null
} }
} }
} catch (e: NumberFormatException) { } catch (e: NumberFormatException) {
Log.e(TAG, "Invalid duration string: $goDuration") TSLog.e(TAG, "Invalid duration string: $goDuration")
return null return null
} }
valStr = "" valStr = ""

@ -33,6 +33,7 @@ import com.tailscale.ipn.BuildConfig
import com.tailscale.ipn.R import com.tailscale.ipn.R
import com.tailscale.ipn.ui.Links import com.tailscale.ipn.ui.Links
import com.tailscale.ipn.ui.theme.logoBackground import com.tailscale.ipn.ui.theme.logoBackground
import com.tailscale.ipn.ui.util.AppVersion
@Composable @Composable
fun AboutView(backToSettings: BackNavigation) { fun AboutView(backToSettings: BackNavigation) {
@ -69,9 +70,14 @@ fun AboutView(backToSettings: BackNavigation) {
Text( Text(
modifier = modifier =
Modifier.clickable { 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)) 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, fontWeight = MaterialTheme.typography.bodyMedium.fontWeight,
fontSize = MaterialTheme.typography.bodyMedium.fontSize) fontSize = MaterialTheme.typography.bodyMedium.fontSize)
} }

@ -3,46 +3,91 @@
package com.tailscale.ipn.ui.view package com.tailscale.ipn.ui.view
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.focusable
import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Person import androidx.compose.material.icons.filled.Person
import androidx.compose.material.ripple.rememberRipple
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ripple
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import coil.annotation.ExperimentalCoilApi import coil.annotation.ExperimentalCoilApi
import coil.compose.AsyncImage import coil.compose.AsyncImage
import com.tailscale.ipn.R import com.tailscale.ipn.R
import com.tailscale.ipn.ui.model.IpnLocal import com.tailscale.ipn.ui.model.IpnLocal
import com.tailscale.ipn.ui.util.AndroidTVUtil
import com.tailscale.ipn.ui.util.conditional
@OptIn(ExperimentalCoilApi::class) @OptIn(ExperimentalCoilApi::class)
@Composable @Composable
fun Avatar(profile: IpnLocal.LoginProfile?, size: Int = 50, action: (() -> Unit)? = null) { fun Avatar(
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(size.dp).clip(CircleShape)) { profile: IpnLocal.LoginProfile?,
var modifier = Modifier.size((size * .8f).dp) size: Int = 50,
action?.let { 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 =
modifier.clickable( Modifier.conditional(AndroidTVUtil.isAndroidTV(), { padding(4.dp) })
interactionSource = remember { MutableInteractionSource() }, .conditional(
indication = rememberRipple(bounded = false), AndroidTVUtil.isAndroidTV(),
onClick = action) {
} size((size * 1.5f).dp) // Focusable area is larger than the avatar
Icon( })
imageVector = Icons.Default.Person, .clip(CircleShape) // Ensure both the focus and click area are circular
contentDescription = stringResource(R.string.settings_title), .background(
modifier = modifier) 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 -> // Overlay the profile picture if available
AsyncImage(model = url, modifier = Modifier.size((size * 1.2f).dp), contentDescription = null) 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.PaddingValues
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.ListItem import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@ -25,6 +26,7 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.tailscale.ipn.R import com.tailscale.ipn.R
import com.tailscale.ipn.ui.theme.listItem import com.tailscale.ipn.ui.theme.listItem
@ -137,10 +139,12 @@ fun LoginView(
onValueChange = { textVal = it }, onValueChange = { textVal = it },
placeholder = { placeholder = {
Text(strings.placeholder, style = MaterialTheme.typography.bodySmall) Text(strings.placeholder, style = MaterialTheme.typography.bodySmall)
}) },
keyboardOptions = KeyboardOptions(capitalization = KeyboardCapitalization.None)
)
}) })
ListItem( ListItem(
colors = MaterialTheme.colorScheme.listItem, colors = MaterialTheme.colorScheme.listItem,
headlineContent = { headlineContent = {
Box(modifier = Modifier.fillMaxWidth()) { Box(modifier = Modifier.fillMaxWidth()) {

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

@ -25,6 +25,8 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.stringResource 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.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog 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()) { Surface(color = MaterialTheme.colorScheme.scrim, modifier = Modifier.fillMaxSize()) {
Dialog(onDismissRequest = onDismiss) { Dialog(onDismissRequest = onDismiss) {
val image by model.qrCode.collectAsState() val image by model.qrCode.collectAsState()
val numCode by model.numCode.collectAsState()
Column( Column(
modifier = modifier =
Modifier.clip(RoundedCornerShape(10.dp)) Modifier.clip(RoundedCornerShape(10.dp))
@ -51,12 +53,13 @@ fun LoginQRView(onDismiss: () -> Unit = {}, model: LoginQRViewModel = viewModel(
Text( Text(
text = stringResource(R.string.scan_to_connect_to_your_tailnet), text = stringResource(R.string.scan_to_connect_to_your_tailnet),
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface) color = MaterialTheme.colorScheme.onSurface,
textAlign = TextAlign.Center)
Box( Box(
modifier = modifier =
Modifier.size(200.dp) Modifier.size(200.dp)
.background(MaterialTheme.colorScheme.onSurface) .background(MaterialTheme.colorScheme.onSurface),
.fillMaxWidth(),
contentAlignment = Alignment.Center) { contentAlignment = Alignment.Center) {
image?.let { image?.let {
Image( Image(
@ -65,7 +68,28 @@ fun LoginQRView(onDismiss: () -> Unit = {}, model: LoginQRViewModel = viewModel(
modifier = Modifier.fillMaxSize()) 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() { fun LoginQRViewPreview() {
val vm = LoginQRViewModel() val vm = LoginQRViewModel()
vm.qrCode.set(vm.generateQRCode("https://tailscale.com", 200, 0)) vm.qrCode.set(vm.generateQRCode("https://tailscale.com", 200, 0))
vm.numCode.set("123456789")
AppTheme { LoginQRView({}, vm) } AppTheme { LoginQRView({}, vm) }
} }

@ -25,7 +25,10 @@ import com.tailscale.ipn.ui.viewModel.IpnViewModel
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @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) }) { Scaffold(topBar = { Header(R.string.current_mdm_settings, onBack = backToSettings) }) {
innerPadding -> innerPadding ->
LazyColumn(modifier = Modifier.padding(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.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBars import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons 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.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.Lock
import androidx.compose.material.icons.outlined.Search
import androidx.compose.material.icons.outlined.Settings import androidx.compose.material.icons.outlined.Settings
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenu
@ -41,7 +39,6 @@ import androidx.compose.material3.ListItem
import androidx.compose.material3.ListItemDefaults import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable 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.focus.onFocusChanged
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.SpanStyle 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.listItem
import com.tailscale.ipn.ui.theme.minTextSize import com.tailscale.ipn.ui.theme.minTextSize
import com.tailscale.ipn.ui.theme.primaryListItem 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.secondaryButton
import com.tailscale.ipn.ui.theme.short import com.tailscale.ipn.ui.theme.short
import com.tailscale.ipn.ui.theme.surfaceContainerListItem import com.tailscale.ipn.ui.theme.surfaceContainerListItem
import com.tailscale.ipn.ui.theme.warningButton import com.tailscale.ipn.ui.theme.warningButton
import com.tailscale.ipn.ui.theme.warningListItem 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.AutoResizingText
import com.tailscale.ipn.ui.util.Lists import com.tailscale.ipn.ui.util.Lists
import com.tailscale.ipn.ui.util.LoadingIndicator import com.tailscale.ipn.ui.util.LoadingIndicator
@ -109,7 +103,8 @@ data class MainViewNavigation(
val onNavigateToSettings: () -> Unit, val onNavigateToSettings: () -> Unit,
val onNavigateToPeerDetails: (Tailcfg.Node) -> Unit, val onNavigateToPeerDetails: (Tailcfg.Node) -> Unit,
val onNavigateToExitNodes: () -> Unit, val onNavigateToExitNodes: () -> Unit,
val onNavigateToHealth: () -> Unit val onNavigateToHealth: () -> Unit,
val onNavigateToSearch: () -> Unit,
) )
@OptIn(ExperimentalPermissionsApi::class, ExperimentalMaterial3Api::class) @OptIn(ExperimentalPermissionsApi::class, ExperimentalMaterial3Api::class)
@ -142,7 +137,7 @@ fun MainView(
val showKeyExpiry by viewModel.showExpiry.collectAsState(initial = false) val showKeyExpiry by viewModel.showExpiry.collectAsState(initial = false)
// Hide the header only on Android TV when the user needs to login // 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( ListItem(
colors = MaterialTheme.colorScheme.surfaceContainerListItem, colors = MaterialTheme.colorScheme.surfaceContainerListItem,
@ -187,20 +182,16 @@ fun MainView(
} }
}, },
trailingContent = { trailingContent = {
Box(modifier = Modifier.weight(1f), contentAlignment = Alignment.CenterEnd) { Box(modifier = Modifier.padding(8.dp), contentAlignment = Alignment.CenterEnd) {
when (user) { when (user) {
null -> SettingsButton { navigation.onNavigateToSettings() } null -> SettingsButton { navigation.onNavigateToSettings() }
else -> else -> {
Box( Avatar(
contentAlignment = Alignment.Center, profile = user,
modifier = size = 36,
Modifier.size(42.dp).clip(CircleShape).clickable { { navigation.onNavigateToSettings() },
navigation.onNavigateToSettings() isFocusable = true)
}) { }
Avatar(profile = user, size = 36) {
navigation.onNavigateToSettings()
}
}
} }
} }
}) })
@ -224,7 +215,7 @@ fun MainView(
PeerList( PeerList(
viewModel = viewModel, viewModel = viewModel,
onNavigateToPeerDetails = navigation.onNavigateToPeerDetails, onNavigateToPeerDetails = navigation.onNavigateToPeerDetails,
onSearch = { viewModel.searchPeers(it) }) onSearchBarClick = navigation.onNavigateToSearch)
} }
Ipn.State.NoState, Ipn.State.NoState,
Ipn.State.Starting -> StartingView() Ipn.State.Starting -> StartingView()
@ -232,6 +223,9 @@ fun MainView(
ConnectView( ConnectView(
state, state,
isPrepared, 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, user,
{ viewModel.toggleVpn() }, { viewModel.toggleVpn() },
{ viewModel.login() }, { viewModel.login() },
@ -242,7 +236,7 @@ fun MainView(
} }
} }
currentPingDevice?.let { peer -> currentPingDevice?.let { _ ->
ModalBottomSheet(onDismissRequest = { viewModel.onPingDismissal() }) { ModalBottomSheet(onDismissRequest = { viewModel.onPingDismissal() }) {
PingView(model = viewModel.pingViewModel) PingView(model = viewModel.pingViewModel)
} }
@ -407,6 +401,7 @@ fun StartingView() {
fun ConnectView( fun ConnectView(
state: Ipn.State, state: Ipn.State,
isPrepared: Boolean, isPrepared: Boolean,
shouldStartAutomatically: Boolean,
user: IpnLocal.LoginProfile?, user: IpnLocal.LoginProfile?,
connectAction: () -> Unit, connectAction: () -> Unit,
loginAction: () -> Unit, loginAction: () -> Unit,
@ -415,7 +410,7 @@ fun ConnectView(
showVPNPermissionLauncherIfUnauthorized: () -> Unit showVPNPermissionLauncherIfUnauthorized: () -> Unit
) { ) {
LaunchedEffect(isPrepared) { LaunchedEffect(isPrepared) {
if (!isPrepared) { if (!isPrepared && shouldStartAutomatically) {
showVPNPermissionLauncherIfUnauthorized() showVPNPermissionLauncherIfUnauthorized()
} }
} }
@ -523,7 +518,7 @@ fun ConnectView(
fun PeerList( fun PeerList(
viewModel: MainViewModel, viewModel: MainViewModel,
onNavigateToPeerDetails: (Tailcfg.Node) -> Unit, onNavigateToPeerDetails: (Tailcfg.Node) -> Unit,
onSearch: (String) -> Unit onSearchBarClick: () -> Unit
) { ) {
val peerList by viewModel.peers.collectAsState(initial = emptyList<PeerSet>()) val peerList by viewModel.peers.collectAsState(initial = emptyList<PeerSet>())
val searchTermStr by viewModel.searchTerm.collectAsState(initial = "") val searchTermStr by viewModel.searchTerm.collectAsState(initial = "")
@ -531,154 +526,117 @@ fun PeerList(
remember { derivedStateOf { searchTermStr.isNotEmpty() && peerList.isEmpty() } }.value remember { derivedStateOf { searchTermStr.isNotEmpty() && peerList.isEmpty() } }.value
val netmap = viewModel.netmap.collectAsState() val netmap = viewModel.netmap.collectAsState()
val focusManager = LocalFocusManager.current
var isFocussed by remember { mutableStateOf(false) }
var isListFocussed by remember { mutableStateOf(false) } var isListFocussed by remember { mutableStateOf(false) }
val expandedPeer = viewModel.expandedMenuPeer.collectAsState() val expandedPeer = viewModel.expandedMenuPeer.collectAsState()
val localClipboardManager = LocalClipboardManager.current val localClipboardManager = LocalClipboardManager.current
val enableSearch = true // !isAndroidTV()
val enableSearch = !isAndroidTV() Column(modifier = Modifier.fillMaxSize()) {
if (enableSearch) {
if (enableSearch) { SearchWithDynamicSuggestions(viewModel, onSearchBarClick)
Box(modifier = Modifier.fillMaxWidth().background(color = MaterialTheme.colorScheme.surface)) {
OutlinedTextField( Spacer(modifier = Modifier.height(if (showNoResults) 0.dp else 8.dp))
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) })
} }
}
LazyColumn( // Peers display
modifier = LazyColumn(
Modifier.fillMaxSize() modifier =
.onFocusChanged { isListFocussed = it.isFocused } Modifier.fillMaxWidth()
.background(color = MaterialTheme.colorScheme.surface)) { .weight(1f) // LazyColumn gets the remaining vertical space
if (showNoResults) { .onFocusChanged { isListFocussed = it.isFocused }
item { .background(color = MaterialTheme.colorScheme.surface)) {
Spacer(
Modifier.height(16.dp) // Handle case when no results are found
.fillMaxSize() if (showNoResults) {
.focusable(false) item {
.background(color = MaterialTheme.colorScheme.surface)) Spacer(
Modifier.height(16.dp)
Lists.LargeTitle( .fillMaxSize()
stringResource(id = R.string.no_results), .focusable(false)
bottomPadding = 8.dp, .background(color = MaterialTheme.colorScheme.surface))
style = MaterialTheme.typography.bodyMedium, Lists.LargeTitle(
fontWeight = FontWeight.Light) stringResource(id = R.string.no_results),
bottomPadding = 8.dp,
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Light)
}
} }
}
var first = true // Iterate over peer sets to display them
peerList.forEach { peerSet -> var first = true
if (!first) { peerList.forEach { peerSet ->
item(key = "user_divider_${peerSet.user?.ID ?: 0L}") { Lists.ItemDivider() } if (!first) {
} item(key = "user_divider_${peerSet.user?.ID ?: 0L}") { Lists.ItemDivider() }
first = false }
first = false
// Sticky headers are a bit broken on Android TV - they hide their content // if (isAndroidTV()) {
if (isAndroidTV()) {
item { NodesSectionHeader(peerSet = peerSet) } item { NodesSectionHeader(peerSet = peerSet) }
} else { /* } else {
stickyHeader { NodesSectionHeader(peerSet = peerSet) } stickyHeader { NodesSectionHeader(peerSet = peerSet) }
} }*/
itemsWithDividers(peerSet.peers, key = { it.StableID }) { peer -> itemsWithDividers(peerSet.peers, key = { it.StableID }) { peer ->
ListItem( ListItem(
modifier = modifier =
Modifier.combinedClickable( Modifier.combinedClickable(
onClick = { onNavigateToPeerDetails(peer) }, onClick = { onNavigateToPeerDetails(peer) },
onLongClick = { viewModel.expandedMenuPeer.set(peer) }), onLongClick = { viewModel.expandedMenuPeer.set(peer) }),
colors = MaterialTheme.colorScheme.listItem, colors = MaterialTheme.colorScheme.listItem,
headlineContent = { headlineContent = {
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
Box( Box(
modifier = modifier =
Modifier.padding(top = 2.dp) Modifier.padding(top = 2.dp)
.size(10.dp) .size(10.dp)
.background( .background(
color = peer.connectedColor(netmap.value), color = peer.connectedColor(netmap.value),
shape = RoundedCornerShape(percent = 50))) {} shape = RoundedCornerShape(percent = 50))) {}
Spacer(modifier = Modifier.size(8.dp)) Spacer(modifier = Modifier.size(8.dp))
Text(text = peer.displayName, style = MaterialTheme.typography.titleMedium) Text(text = peer.displayName, style = MaterialTheme.typography.titleMedium)
DropdownMenu( DropdownMenu(
expanded = expandedPeer.value?.StableID == peer.StableID, expanded = expandedPeer.value?.StableID == peer.StableID,
onDismissRequest = { viewModel.hidePeerDropdownMenu() }) { onDismissRequest = { viewModel.hidePeerDropdownMenu() }) {
DropdownMenuItem( DropdownMenuItem(
leadingIcon = { leadingIcon = {
Icon( Icon(
painter = painterResource(R.drawable.clipboard), painter = painterResource(R.drawable.clipboard),
contentDescription = null) contentDescription = null)
}, },
text = { Text(text = stringResource(R.string.copy_ip_address)) }, text = { Text(text = stringResource(R.string.copy_ip_address)) },
onClick = { onClick = {
viewModel.copyIpAddress(peer, localClipboardManager) viewModel.copyIpAddress(peer, localClipboardManager)
viewModel.hidePeerDropdownMenu() viewModel.hidePeerDropdownMenu()
}) })
netmap.value?.let { netMap ->
netmap.value?.let { netMap -> if (!peer.isSelfNode(netMap)) {
if (!peer.isSelfNode(netMap)) { DropdownMenuItem(
// Don't show the ping item for the self-node leadingIcon = {
DropdownMenuItem( Icon(
leadingIcon = { painter = painterResource(R.drawable.timer),
Icon( contentDescription = null)
painter = painterResource(R.drawable.timer), },
contentDescription = null) text = { Text(text = stringResource(R.string.ping)) },
}, onClick = {
text = { Text(text = stringResource(R.string.ping)) }, viewModel.hidePeerDropdownMenu()
onClick = { viewModel.startPing(peer)
viewModel.hidePeerDropdownMenu() })
viewModel.startPing(peer) }
})
} }
} }
} }
} },
}, supportingContent = {
supportingContent = { Text(
Text( text = peer.Addresses?.first()?.split("/")?.first() ?: "",
text = peer.Addresses?.first()?.split("/")?.first() ?: "", style =
style = MaterialTheme.typography.bodyMedium.copy(
MaterialTheme.typography.bodyMedium.copy( lineHeight = MaterialTheme.typography.titleMedium.lineHeight))
lineHeight = MaterialTheme.typography.titleMedium.lineHeight)) })
}) }
} }
} }
} }
} }
@Composable @Composable
@ -688,7 +646,7 @@ fun NodesSectionHeader(peerSet: PeerSet) {
Lists.LargeTitle( Lists.LargeTitle(
peerSet.user?.DisplayName ?: stringResource(id = R.string.unknown_user), peerSet.user?.DisplayName ?: stringResource(id = R.string.unknown_user),
bottomPadding = 8.dp, bottomPadding = 8.dp,
focusable = isAndroidTV(), focusable = true, // isAndroidTV(),
style = MaterialTheme.typography.titleLarge, style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.SemiBold) 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 @Preview
@Composable @Composable
fun MainViewPreview() { fun MainViewPreview() {
@ -746,6 +745,7 @@ fun MainViewPreview() {
onNavigateToSettings = {}, onNavigateToSettings = {},
onNavigateToPeerDetails = {}, onNavigateToPeerDetails = {},
onNavigateToExitNodes = {}, onNavigateToExitNodes = {},
onNavigateToHealth = {}), onNavigateToHealth = {},
onNavigateToSearch = {}),
vm) vm)
} }

@ -23,14 +23,16 @@ import com.tailscale.ipn.R
import com.tailscale.ipn.mdm.MDMSettings import com.tailscale.ipn.mdm.MDMSettings
import com.tailscale.ipn.ui.viewModel.IpnViewModel import com.tailscale.ipn.ui.viewModel.IpnViewModel
@Suppress("UNUSED_PARAMETER")
@Composable @Composable
fun ManagedByView(backToSettings: BackNavigation, model: IpnViewModel = viewModel()) { 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( Column(
verticalArrangement = verticalArrangement =
Arrangement.spacedBy(space = 20.dp, alignment = Alignment.CenterVertically), Arrangement.spacedBy(space = 20.dp, alignment = Alignment.CenterVertically),
horizontalAlignment = Alignment.Start, horizontalAlignment = Alignment.Start,
modifier = Modifier.fillMaxWidth().safeContentPadding().verticalScroll(rememberScrollState())) { modifier =
Modifier.fillMaxWidth().safeContentPadding().verticalScroll(rememberScrollState())) {
val managedByOrganization = val managedByOrganization =
MDMSettings.managedByOrganizationName.flow.collectAsState().value.value MDMSettings.managedByOrganizationName.flow.collectAsState().value.value
val managedByCaption = MDMSettings.managedByCaption.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) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun PeerDetails( fun PeerDetails(
backToHome: BackNavigation, onNavigateBack: () -> Unit,
nodeId: String, nodeId: String,
pingViewModel: PingViewModel, pingViewModel: PingViewModel,
model: PeerDetailsViewModel = model: PeerDetailsViewModel =
@ -90,7 +90,7 @@ fun PeerDetails(
contentDescription = "Ping device") contentDescription = "Ping device")
} }
}, },
onBack = backToHome) onBack = onNavigateBack)
}, },
) { innerPadding -> ) { innerPadding ->
LazyColumn( 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.SettingsViewModel
import com.tailscale.ipn.ui.viewModel.VpnViewModel import com.tailscale.ipn.ui.viewModel.VpnViewModel
import com.tailscale.ipn.ui.notifier.Notifier import com.tailscale.ipn.ui.notifier.Notifier
import com.tailscale.ipn.ui.util.AndroidTVUtil
import com.tailscale.ipn.ui.util.AppVersion
@Composable @Composable
fun SettingsView(settingsNav: SettingsNav, viewModel: SettingsViewModel = viewModel(), vpnViewModel: VpnViewModel = viewModel()) { fun SettingsView(settingsNav: SettingsNav, viewModel: SettingsViewModel = viewModel(), vpnViewModel: VpnViewModel = viewModel()) {
@ -95,9 +97,10 @@ fun SettingsView(settingsNav: SettingsNav, viewModel: SettingsViewModel = viewMo
}, },
onClick = settingsNav.onNavigateToTailnetLock) onClick = settingsNav.onNavigateToTailnetLock)
} }
if (!AndroidTVUtil.isAndroidTV()){
Lists.ItemDivider() Lists.ItemDivider()
Setting.Text(R.string.permissions, onClick = settingsNav.onNavigateToPermissions) Setting.Text(R.string.permissions, onClick = settingsNav.onNavigateToPermissions)
}
managedByOrganization.value?.let { managedByOrganization.value?.let {
Lists.ItemDivider() Lists.ItemDivider()
@ -112,7 +115,7 @@ fun SettingsView(settingsNav: SettingsNav, viewModel: SettingsViewModel = viewMo
Lists.ItemDivider() Lists.ItemDivider()
Setting.Text( Setting.Text(
R.string.about_tailscale, R.string.about_tailscale,
subtitle = "${stringResource(id = R.string.version)} ${BuildConfig.VERSION_NAME}", subtitle = "${stringResource(id = R.string.version)} ${AppVersion.Short()}",
onClick = settingsNav.onNavigateToAbout) onClick = settingsNav.onNavigateToAbout)
// TODO: put a heading for the debug section // 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.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.CheckCircle import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.ripple.rememberRipple
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
@ -21,6 +20,7 @@ import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.material3.ripple
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
@ -45,40 +45,44 @@ fun Header(
actions: @Composable RowScope.() -> Unit = {}, actions: @Composable RowScope.() -> Unit = {},
onBack: (() -> Unit)? = null onBack: (() -> Unit)? = null
) { ) {
val f = FocusRequester() val focusRequester = remember { FocusRequester() }
if (isAndroidTV()) { if (isAndroidTV()) {
LaunchedEffect(Unit) { f.requestFocus() } LaunchedEffect(Unit) {
} focusRequester.requestFocus()
}
}
TopAppBar( TopAppBar(
title = { title = {
title?.let { title() } title?.let { title() }
?: Text( ?: Text(
stringResource(titleRes), stringResource(titleRes),
style = MaterialTheme.typography.titleLarge, style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurface) color = MaterialTheme.colorScheme.onSurface
}, )
colors = MaterialTheme.colorScheme.topAppBar, },
actions = actions, colors = MaterialTheme.colorScheme.topAppBar,
navigationIcon = { onBack?.let { BackArrow(action = it, focusRequester = f) } }, actions = actions,
) navigationIcon = { onBack?.let { BackArrow(action = it, focusRequester = focusRequester) } },
)
} }
@Composable @Composable
fun BackArrow(action: () -> Unit, focusRequester: FocusRequester) { fun BackArrow(action: () -> Unit, focusRequester: FocusRequester) {
Box(modifier = Modifier.padding(start = 8.dp, end = 8.dp)) {
Box(modifier = Modifier.padding(start = 8.dp, end = 8.dp)) { Icon(
Icon( Icons.AutoMirrored.Filled.ArrowBack,
Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Go back to the previous screen",
contentDescription = "Go back to the previous screen", modifier = Modifier
modifier = .focusRequester(focusRequester)
Modifier.focusRequester(focusRequester)
.clickable( .clickable(
interactionSource = remember { MutableInteractionSource() }, interactionSource = remember { MutableInteractionSource() },
indication = rememberRipple(bounded = false), indication = ripple(bounded = false),
onClick = { action() })) onClick = { action() }
} )
)
}
} }
@Composable @Composable

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

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

@ -36,7 +36,9 @@ class LoginWithCustomControlURLViewModel : CustomLoginViewModel() {
// localAPIClient will use the default server if we give it a broken URL, // 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 // but we can make sure we can construct a URL from the input string and
// ensure it has an http/https scheme // 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 -> { false -> {
errorDialog.set(ErrorDialogType.INVALID_CUSTOM_URL) errorDialog.set(ErrorDialogType.INVALID_CUSTOM_URL)
return return

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

@ -3,7 +3,6 @@
package com.tailscale.ipn.ui.viewModel package com.tailscale.ipn.ui.viewModel
import android.util.Log
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.tailscale.ipn.UninitializedApp 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.AdvertisedRoutesHelper
import com.tailscale.ipn.ui.util.LoadingIndicator import com.tailscale.ipn.ui.util.LoadingIndicator
import com.tailscale.ipn.ui.util.set import com.tailscale.ipn.ui.util.set
import com.tailscale.ipn.util.TSLog
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
@ -130,15 +130,10 @@ open class IpnViewModel : ViewModel() {
} }
.collect { nodeState -> _nodeState.value = nodeState } .collect { nodeState -> _nodeState.value = nodeState }
} }
Log.d(TAG, "Created") TSLog.d(TAG, "Created")
} }
// VPN Control // VPN Control
fun setVpnPrepared(prepared: Boolean) {
_vpnPrepared.value = prepared
}
fun startVPN() { fun startVPN() {
UninitializedApp.get().startVPN() UninitializedApp.get().startVPN()
} }
@ -158,8 +153,8 @@ open class IpnViewModel : ViewModel() {
val loginAction = { val loginAction = {
Client(viewModelScope).startLoginInteractive { result -> Client(viewModelScope).startLoginInteractive { result ->
result result
.onSuccess { Log.d(TAG, "Login started: $it") } .onSuccess { TSLog.d(TAG, "Login started: $it") }
.onFailure { Log.e(TAG, "Error starting login: ${it.message}") } .onFailure { TSLog.e(TAG, "Error starting login: ${it.message}") }
completionHandler(result) completionHandler(result)
} }
} }
@ -170,7 +165,7 @@ open class IpnViewModel : ViewModel() {
Client(viewModelScope).editPrefs(Ipn.MaskedPrefs().apply { WantRunning = false }) { result -> Client(viewModelScope).editPrefs(Ipn.MaskedPrefs().apply { WantRunning = false }) { result ->
result result
.onSuccess { loginAction() } .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) { if (mdmControlURL != null) {
prefs = prefs ?: Ipn.MaskedPrefs() prefs = prefs ?: Ipn.MaskedPrefs()
prefs.ControlURL = mdmControlURL 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 { prefs?.let {
@ -215,8 +210,8 @@ open class IpnViewModel : ViewModel() {
fun logout(completionHandler: (Result<String>) -> Unit = {}) { fun logout(completionHandler: (Result<String>) -> Unit = {}) {
Client(viewModelScope).logout { result -> Client(viewModelScope).logout { result ->
result result
.onSuccess { Log.d(TAG, "Logout started: $it") } .onSuccess { TSLog.d(TAG, "Logout started: $it") }
.onFailure { Log.e(TAG, "Error starting logout: ${it.message}") } .onFailure { TSLog.e(TAG, "Error starting logout: ${it.message}") }
completionHandler(result) completionHandler(result)
} }
} }
@ -226,14 +221,14 @@ open class IpnViewModel : ViewModel() {
private fun loadUserProfiles() { private fun loadUserProfiles() {
Client(viewModelScope).profiles { result -> Client(viewModelScope).profiles { result ->
result.onSuccess(loginProfiles::set).onFailure { 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 -> Client(viewModelScope).currentProfile { result ->
result result
.onSuccess { loggedInUser.set(if (it.isEmpty()) null else it) } .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 -> Client(viewModelScope).editPrefs(Ipn.MaskedPrefs().apply { WantRunning = false }) { result ->
result result
.onSuccess { switchProfile() } .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() } Client(viewModelScope).setUseExitNode(true) { LoadingIndicator.stop() }
} else { } else {
// This should not be possible. In this state the button is hidden // 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 -> Client(viewModelScope).editPrefs(newPrefs) { result ->
LoadingIndicator.stop() 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() { class LoginQRViewModel : IpnViewModel() {
val numCode: StateFlow<String?> = MutableStateFlow(null)
val qrCode: StateFlow<ImageBitmap?> = MutableStateFlow(null) val qrCode: StateFlow<ImageBitmap?> = MutableStateFlow(null)
// Remove this once changes to admin console allowing input code to be entered are made.
init { init {
viewModelScope.launch { viewModelScope.launch {
Notifier.browseToURL.collect { url -> 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.PeerSet
import com.tailscale.ipn.ui.util.TimeUtil import com.tailscale.ipn.ui.util.TimeUtil
import com.tailscale.ipn.ui.util.set import com.tailscale.ipn.ui.util.set
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.time.Duration import java.time.Duration
class MainViewModelFactory(private val vpnViewModel: VpnViewModel) : ViewModelProvider.Factory { class MainViewModelFactory(private val vpnViewModel: VpnViewModel) : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T { override fun <T : ViewModel> create(modelClass: Class<T>): T {
if (modelClass.isAssignableFrom(MainViewModel::class.java)) { if (modelClass.isAssignableFrom(MainViewModel::class.java)) {
return MainViewModel(vpnViewModel) as T 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() { class MainViewModel(private val vpnViewModel: VpnViewModel) : IpnViewModel() {
// The user readable state of the system // The user readable state of the system
@ -51,13 +57,15 @@ class MainViewModel(private val vpnViewModel: VpnViewModel) : IpnViewModel() {
private var vpnPermissionLauncher: ActivityResultLauncher<Intent>? = null private var vpnPermissionLauncher: ActivityResultLauncher<Intent>? = null
// The list of peers // 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 // The current state of the IPN for determining view visibility
val ipnState = Notifier.state val ipnState = Notifier.state
// The active search term for filtering peers // 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 // True if we should render the key expiry bannder
val showExpiry: StateFlow<Boolean> = MutableStateFlow(false) val showExpiry: StateFlow<Boolean> = MutableStateFlow(false)
@ -69,9 +77,17 @@ class MainViewModel(private val vpnViewModel: VpnViewModel) : IpnViewModel() {
val isVpnPrepared: StateFlow<Boolean> = vpnViewModel.vpnPrepared 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 // Icon displayed in the button to present the health view
val healthIcon: StateFlow<Int?> = MutableStateFlow(null) val healthIcon: StateFlow<Int?> = MutableStateFlow(null)
fun updateSearchTerm(term: String) {
_searchTerm.value = term
}
fun hidePeerDropdownMenu() { fun hidePeerDropdownMenu() {
expandedMenuPeer.set(null) expandedMenuPeer.set(null)
} }
@ -94,19 +110,24 @@ class MainViewModel(private val vpnViewModel: VpnViewModel) : IpnViewModel() {
viewModelScope.launch { viewModelScope.launch {
var previousState: State? = null var previousState: State? = null
combine(Notifier.state, isVpnPrepared) { state, prepared -> state to prepared } combine(Notifier.state, isVpnActive) { state, active -> state to active }
.collect { (currentState, prepared) -> .collect { (currentState, active) ->
stateRes.set(userStringRes(currentState, previousState, prepared)) // Determine the correct state resource string
stateRes.set(userStringRes(currentState, previousState, active))
// Determine if the VPN toggle should be on
val isOn = val isOn =
when { when {
prepared && currentState == State.Running || currentState == State.Starting -> active && (currentState == State.Running || currentState == State.Starting) ->
true true
previousState == State.NoState && currentState == State.Starting -> true previousState == State.NoState && currentState == State.Starting -> true
else -> false else -> false
} }
// Update the VPN toggle state
_vpnToggleState.value = isOn _vpnToggleState.value = isOn
// Update the previous state
previousState = currentState previousState = currentState
} }
} }
@ -114,8 +135,12 @@ class MainViewModel(private val vpnViewModel: VpnViewModel) : IpnViewModel() {
viewModelScope.launch { viewModelScope.launch {
Notifier.netmap.collect { it -> Notifier.netmap.collect { it ->
it?.let { netmap -> it?.let { netmap ->
peerCategorizer.regenerateGroupedPeers(netmap) searchJob?.cancel()
peers.set(peerCategorizer.groupedAndFilteredPeers(searchTerm.value)) launch(Dispatchers.Default) {
peerCategorizer.regenerateGroupedPeers(netmap)
val filteredPeers = peerCategorizer.groupedAndFilteredPeers(searchTerm.value)
_peers.value = filteredPeers
}
if (netmap.SelfNode.keyDoesNotExpire) { if (netmap.SelfNode.keyDoesNotExpire) {
showExpiry.set(false) showExpiry.set(false)
@ -133,8 +158,11 @@ class MainViewModel(private val vpnViewModel: VpnViewModel) : IpnViewModel() {
} }
viewModelScope.launch { viewModelScope.launch {
searchTerm.collect { term -> peers.set(peerCategorizer.groupedAndFilteredPeers(term)) } searchTerm.collect { term ->
} val filteredPeers = peerCategorizer.groupedAndFilteredPeers(term)
_peers.value = filteredPeers
}
}
viewModelScope.launch { viewModelScope.launch {
App.get().healthNotifier?.currentIcon?.collect { icon -> healthIcon.set(icon) } App.get().healthNotifier?.currentIcon?.collect { icon -> healthIcon.set(icon) }
@ -164,8 +192,12 @@ class MainViewModel(private val vpnViewModel: VpnViewModel) : IpnViewModel() {
} }
fun searchPeers(searchTerm: String) { 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>) { fun setVpnPermissionLauncher(launcher: ActivityResultLauncher<Intent>) {
// No intent means we're already authorized // 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 { return when {
previousState == State.NoState && currentState == State.Starting -> R.string.starting previousState == State.NoState && currentState == State.Starting -> R.string.starting
currentState == State.NoState -> R.string.placeholder currentState == State.NoState -> R.string.placeholder
currentState == State.InUseOtherUser -> R.string.placeholder currentState == State.InUseOtherUser -> R.string.placeholder
currentState == State.NeedsLogin -> 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.NeedsMachineAuth -> R.string.needs_machine_auth
currentState == State.Stopped -> R.string.stopped currentState == State.Stopped -> R.string.stopped
currentState == State.Starting -> R.string.starting 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 else -> R.string.placeholder
} }
} }

@ -5,7 +5,6 @@ package com.tailscale.ipn.ui.viewModel
import android.content.Context import android.content.Context
import android.os.CountDownTimer import android.os.CountDownTimer
import android.util.Log
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope 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.ConnectionMode
import com.tailscale.ipn.ui.util.set import com.tailscale.ipn.ui.util.set
import com.tailscale.ipn.ui.view.roundedString import com.tailscale.ipn.ui.view.roundedString
import com.tailscale.ipn.util.TSLog
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@ -39,7 +39,7 @@ class PingViewModel : ViewModel() {
} }
override fun onFinish() { 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 -> response.onFailure { error ->
val context: Context = App.get().applicationContext val context: Context = App.get().applicationContext
val stringError = error.toString() val stringError = error.toString()
Log.d(TAG, "Ping request failed: $stringError") TSLog.d(TAG, "Ping request failed: $stringError")
if (stringError.contains("timeout")) { if (stringError.contains("timeout")) {
this.errorMessage.set( this.errorMessage.set(
context.getString( 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 package com.tailscale.ipn.ui.viewModel
import android.content.Context import android.content.Context
import android.util.Log
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.material3.MaterialTheme 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.ActivityIndicator
import com.tailscale.ipn.ui.view.CheckedIndicator import com.tailscale.ipn.ui.view.CheckedIndicator
import com.tailscale.ipn.ui.view.ErrorDialogType import com.tailscale.ipn.ui.view.ErrorDialogType
import com.tailscale.ipn.util.TSLog
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@ -144,7 +144,7 @@ class TaildropViewModel(
allSharablePeers.filter { !(it.Online ?: false) }.sortedBy { it.Name } allSharablePeers.filter { !(it.Online ?: false) }.sortedBy { it.Name }
myPeers.set(onlinePeers + offlinePeers) 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 // application scoped because Tailscale might be toggled on and off outside of the activity
// lifecycle. // lifecycle.
class VpnViewModel(application: Application) : AndroidViewModel(application) { 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 = MutableStateFlow(false)
val vpnPrepared: StateFlow<Boolean> = _vpnPrepared 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" val TAG = "VpnViewModel"
init { init {
@ -49,7 +55,11 @@ class VpnViewModel(application: Application) : AndroidViewModel(application) {
} }
} }
fun setVpnPrepared(prepared: Boolean) { fun setVpnActive(isActive: Boolean) {
_vpnPrepared.value = prepared _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="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="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="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 --> <!-- Permissions Management -->
<string name="permissions">Permissions</string> <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="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="welcome2">All connections are device-to-device, so we never see your data. We collect and use your email address and name, as well as your device name, OS version, and IP address in order to help you to connect your devices and manage your settings. We log when you are connected to your network.</string>
<string name="scan_to_connect_to_your_tailnet">Scan this QR code to log in to your tailnet</string> <string name="scan_to_connect_to_your_tailnet">Scan this QR code to log in to your tailnet</string>
<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 --> <!-- Strings for intent handling -->
<string name="vpn_is_not_ready_to_start">VPN is not ready to start</string> <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="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="go_to_settings">Go to Settings</string>
<string name="cancel">Cancel</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> </resources>

@ -1,60 +1,107 @@
// Copyright (c) Tailscale Inc & AUTHORS // Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause // SPDX-License-Identifier: BSD-3-Clause
package com.tailcale.ipn.ui.util package com.tailcale.ipn.ui.util
import com.tailscale.ipn.ui.util.TimeUtil 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.assertEquals
import org.junit.Assert.assertNull import org.junit.Assert.assertNull
import org.junit.Before
import org.junit.Test import org.junit.Test
import org.mockito.ArgumentMatchers.anyString
import org.mockito.Mockito.doNothing
import org.mockito.Mockito.mock
import java.time.Duration import java.time.Duration
class TimeUtilTest { class TimeUtilTest {
@Test
fun durationInvalidMsUnits() { private lateinit var libtailscaleWrapperMock: LibtailscaleWrapper
val input = "5s10ms" private lateinit var originalWrapper: LibtailscaleWrapper
val actual = TimeUtil.duration(input)
assertNull("Should return null", actual)
} @Before
fun setUp() {
@Test libtailscaleWrapperMock = mock(LibtailscaleWrapper::class.java)
fun durationInvalidUsUnits() { doNothing().`when`(libtailscaleWrapperMock).sendLog(anyString(), anyString())
val input = "5s10us"
val actual = TimeUtil.duration(input)
assertNull("Should return null", actual) // Store the original wrapper so we can reset it later
} originalWrapper = TSLog.libtailscaleWrapper
// Inject mock into TSLog
@Test TSLog.libtailscaleWrapper = libtailscaleWrapperMock
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) @After
val expected = expectedSeconds.map { Duration.ofSeconds(it.toLong()) } fun tearDown() {
val actual = input.map { TimeUtil.duration(it) } // Reset TSLog after each test to avoid side effects
assertEquals("Incorrect conversion", expected, actual) TSLog.libtailscaleWrapper = originalWrapper
} }
@Test
fun testBadDurationString() { @Test
val input = "1..0y1.0w1.0d1.0h1.0m1.0s" fun durationInvalidMsUnits() {
val actual = TimeUtil.duration(input) val input = "5s10ms"
assertNull("Should return null", actual) val actual = TimeUtil.duration(input)
} assertNull("Should return null", actual)
}
@Test
fun testBadDInputString() {
val input = "1.0yy1.0w1.0d1.0h1.0m1.0s" @Test
val actual = TimeUtil.duration(input) fun durationInvalidUsUnits() {
assertNull("Should return null", actual) val input = "5s10us"
} val actual = TimeUtil.duration(input)
assertNull("Should return null", actual)
@Test }
fun testIgnoreFractionalSeconds() {
val input = "10.9s"
val expectedSeconds = 10 @Test
val expected = Duration.ofSeconds(expectedSeconds.toLong()) fun durationTestHappyPath() {
val actual = TimeUtil.duration(input) val input = arrayOf("1.0y1.0w1.0d1.0h1.0m1.0s", "1s", "1m", "1h", "1d", "1w", "1y")
assertEquals("Should return $expectedSeconds seconds", expected, actual) 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 # 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 # 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: # 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 ln -s /usr/local/go/bin/go /usr/bin
RUN mkdir -p $HOME/tailscale-android RUN mkdir -p $HOME/tailscale-android
@ -42,5 +42,5 @@ COPY android/gradle android/gradle
RUN ./android/gradlew RUN ./android/gradlew
# Build the android app, bump the playstore version code, and make the tv release # 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 # 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 # 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: # 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 ln -s /usr/local/go/bin/go /usr/bin
RUN mkdir -p $HOME/tailscale-android RUN mkdir -p $HOME/tailscale-android

@ -1,13 +1,12 @@
module github.com/tailscale/tailscale-android module github.com/tailscale/tailscale-android
go 1.22.0 go 1.23.1
require ( require (
github.com/tailscale/wireguard-go v0.0.0-20240731203015-71393c576b98 github.com/tailscale/wireguard-go v0.0.0-20241113014420-4e883d38c8d3
golang.org/x/mobile v0.0.0-20240319015410-c58ccf4b0c87 golang.org/x/mobile v0.0.0-20240806205939-81131f6468ab
golang.org/x/sys v0.22.0
inet.af/netaddr v0.0.0-20220617031823-097006376321 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 ( require (
@ -47,13 +46,13 @@ require (
github.com/gorilla/csrf v1.7.2 // indirect github.com/gorilla/csrf v1.7.2 // indirect
github.com/gorilla/securecookie v1.1.2 // indirect github.com/gorilla/securecookie v1.1.2 // indirect
github.com/hdevalence/ed25519consensus v0.2.0 // 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/insomniacslk/dhcp v0.0.0-20231206064809-8c70d406f6d2 // indirect
github.com/jellydator/ttlcache/v3 v3.1.0 // indirect github.com/jellydator/ttlcache/v3 v3.1.0 // indirect
github.com/jmespath/go-jmespath v0.4.0 // indirect github.com/jmespath/go-jmespath v0.4.0 // indirect
github.com/josharian/native v1.1.1-0.20230202152459-5c7d0dd6ab86 // indirect github.com/josharian/native v1.1.1-0.20230202152459-5c7d0dd6ab86 // indirect
github.com/jsimonetti/rtnetlink v1.4.0 // 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/kortschak/wol v0.0.0-20200729010619-da482cc4850a // indirect
github.com/mdlayher/genetlink v1.3.2 // indirect github.com/mdlayher/genetlink v1.3.2 // indirect
github.com/mdlayher/netlink v1.7.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/golang-x-crypto v0.0.0-20240604161659-3fde5e568aa4 // indirect
github.com/tailscale/goupnp v1.0.1-0.20210804011211-c64d0f06ea05 // 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/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/peercred v0.0.0-20240214030740-b535050b2aa4 // indirect
github.com/tailscale/web-client-prebuilt v0.0.0-20240226180453-5db17b287bf1 // 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/tailscale/xnet v0.0.0-20240729143630-8497ac4dab2e // indirect
github.com/tcnksm/go-httpstat v0.2.0 // indirect github.com/tcnksm/go-httpstat v0.2.0 // indirect
github.com/u-root/uio v0.0.0-20240118234441-a3c409a6018e // 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/vishvananda/netns v0.0.4 // indirect
github.com/x448/float16 v0.8.4 // indirect github.com/x448/float16 v0.8.4 // indirect
go4.org/intern v0.0.0-20211027215823-ae77deb06f29 // indirect go4.org/intern v0.0.0-20211027215823-ae77deb06f29 // indirect
go4.org/mem v0.0.0-20220726221520-4f986261bf13 // indirect go4.org/mem v0.0.0-20220726221520-4f986261bf13 // indirect
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba // indirect go4.org/netipx v0.0.0-20231129151722-fdeea329fbba // indirect
go4.org/unsafe/assume-no-moving-gc v0.0.0-20230525183740-e7c30c78aeb2 // 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/exp v0.0.0-20240119083558-1b970713d09a // indirect
golang.org/x/mod v0.19.0 // indirect golang.org/x/mod v0.20.0 // indirect
golang.org/x/net v0.27.0 // indirect golang.org/x/net v0.28.0 // indirect
golang.org/x/sync v0.7.0 // indirect golang.org/x/sync v0.9.0 // indirect
golang.org/x/term v0.22.0 // indirect golang.org/x/sys v0.27.0 // indirect
golang.org/x/text v0.16.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/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/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect
golang.zx2c4.com/wireguard/windows v0.5.3 // indirect golang.zx2c4.com/wireguard/windows v0.5.3 // indirect
gvisor.dev/gvisor v0.0.0-20240722211153-64c016c92987 // 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/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 h1:37ICyZqdyj0lAZ8P4D1d1id3HqbbG1N3iBb1Tb4rdcU=
github.com/hdevalence/ed25519consensus v0.2.0/go.mod h1:w3BHWjwJbFU29IRHL1Iqkw3sus+7FctEyM4RqDxYNzo= 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/v2 v2.0.3 h1:B6+SKPo/0Sw8cRJh1aLzNEeNVFfzE3c6N+o+vyxM+9A=
github.com/illarion/gonotify v1.0.1/go.mod h1:zt5pmDofZpU1f8aqlK0+95eQhoEAn/d4G4B/FjVW4jE= 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 h1:9K06NfxkBh25x56yVhWWlKFE8YpicaSfHwoV8SFbueA=
github.com/insomniacslk/dhcp v0.0.0-20231206064809-8c70d406f6d2/go.mod h1:3A9PQ1cunSDF/1rbTq99Ts4pVnycWg+vlPkfeD2NLFI= 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= 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/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 h1:Z1BF0fRgcETPEa0Kt0MRk3yV5+kF1FWTni6KUFKrq2I=
github.com/jsimonetti/rtnetlink v1.4.0/go.mod h1:5W1jDvWdnthFJ7fxYX1GMK07BUpI4oskfOqvPteYS6E= 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.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc=
github.com/klauspost/compress v1.17.4/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM= 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 h1:+RR6SqnTkDLWyICxS1xpjCi/3dhyV+TgZwA6Ww3KncQ=
github.com/kortschak/wol v0.0.0-20200729010619-da482cc4850a/go.mod h1:YTtCCM3ryyfiu4F7t8HQ1mxvp1UBdWM2r6Xa+nGWvDk= github.com/kortschak/wol v0.0.0-20200729010619-da482cc4850a/go.mod h1:YTtCCM3ryyfiu4F7t8HQ1mxvp1UBdWM2r6Xa+nGWvDk=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 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/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 h1:YMbv+i08gQz97OZZBwLyvmmQEEzyfyrrjEaAchdy3R4=
github.com/prometheus-community/pro-bing v0.4.0/go.mod h1:b7wRYZtCcPmt4Sz319BykUU241rWLe1VFXyiyWK/dH4= 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.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA= 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 h1:gimQJpsI6sc1yIqP/y8GYgiXn/NjgvpM0RNoWLVVmP0=
github.com/safchain/ethtool v0.3.0/go.mod h1:SA9BwrgyAqNo7M+uaL6IYbxpm5wk3L7Mm6ocLW+CJUs= 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= 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/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 h1:SJy1Pu0eH1C29XwJucQo73FrleVK6t4kYz4NVhp34Yw=
github.com/tailscale/hujson v0.0.0-20221223112325-20486734a56a/go.mod h1:DFSS3NAGHthKo1gTlmEcSBiZrRJXi28rLNd/1udP1c8= 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.20240822203006-4d49adab4de7 h1:uFsXVBE9Qr4ZoF094vE6iYTLDl0qCiKzYXlL6UeWObU=
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/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 h1:Gz0rz40FvFVLTBk/K8UNAenb36EbDSnh+q7Z9ldcC8w=
github.com/tailscale/peercred v0.0.0-20240214030740-b535050b2aa4/go.mod h1:phI29ccmHQBc+wvroosENp1IF9195449VDnFDhJ4rJU= 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 h1:tdUdyPqJ0C97SJfjB9tW6EylTtreyee9C44de+UBG0g=
github.com/tailscale/web-client-prebuilt v0.0.0-20240226180453-5db17b287bf1/go.mod h1:agQPE6y6ldqCOui2gkIh7ZMztTkIQKH049tv8siLuNQ= 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-20241113014420-4e883d38c8d3 h1:dmoPb3dG27tZgMtrvqfD/LW4w7gA6BSWl8prCPNmkCQ=
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/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 h1:zOGKqN5D5hHhiYUp091JqK7DPCqSARyUfduhGUY8Bek=
github.com/tailscale/xnet v0.0.0-20240729143630-8497ac4dab2e/go.mod h1:orPd6JZXXRyuDusYilywte7k094d7dycXXU5YnWsrwg= github.com/tailscale/xnet v0.0.0-20240729143630-8497ac4dab2e/go.mod h1:orPd6JZXXRyuDusYilywte7k094d7dycXXU5YnWsrwg=
github.com/tc-hib/winres v0.2.1 h1:YDE0FiP0VmtRaDn7+aaChp1KiF4owBiJa5l964l5ujA= 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/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 h1:BA9O3BmlTmpjbvajAwzWx4Wo2TRVdpPXZEeemGQcajw=
github.com/u-root/uio v0.0.0-20240118234441-a3c409a6018e/go.mod h1:eLL9Nub3yfAho7qB0MzZizFhTU2QkLeoVsWdHtDW264= 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.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 h1:Oeaw1EM2JMxD51g9uhtC0D7erkIjgmj8+JZc26m1YX8=
github.com/vishvananda/netns v0.0.4/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM= 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-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-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= 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.26.0 h1:RrRspgV4mU+YwB4FYnuBoKsUapNIL5cohGAmSH3azsw=
golang.org/x/crypto v0.25.0/go.mod h1:T+wALwcMOSE0kXgUAnPAHqTLW+XHgcELELW8VaDgm/M= 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 h1:Q8/wZp0KX97QFTc2ywcOE0YRjZPVIx+MXInMzdvQqcA=
golang.org/x/exp v0.0.0-20240119083558-1b970713d09a/go.mod h1:idGWGoKP1toJGkd5/ig9ZLuPcZBC3ewk7SzmH0uou08= 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.19.0 h1:D9FX4QWkLfkeqaC62SonffIIuYdOk/UE2XKUBgRIBIQ=
golang.org/x/image v0.18.0/go.mod h1:4yyo5vMFQjVjUcVk4jEQcU9MGy/rulF5WvUILseCM2E= golang.org/x/image v0.19.0/go.mod h1:y0zrRqlQRWQ5PXaYCOMLTW2fpsxZ8Qh9I/ohnInJEys=
golang.org/x/mobile v0.0.0-20240319015410-c58ccf4b0c87 h1:dm00oNtDy265HReLTARPfIDXTRb2IG0jqQVpn7p5MKE= golang.org/x/mobile v0.0.0-20240806205939-81131f6468ab h1:KONOFF8Uy3b60HEzOsGnNghORNhY4ImyOx0PGm73K9k=
golang.org/x/mobile v0.0.0-20240319015410-c58ccf4b0c87/go.mod h1:DN+F2TpepQEh5goqWnM3gopfFakSWM8OmHiz0rPRjT4= 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.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.19.0 h1:fEdghXQSo20giMthA7cd28ZC+jts4amQ3YMXiP5oMQ8= golang.org/x/mod v0.20.0 h1:utOm6MM3R3dnawAiJgn0y+xvuYRsm1RKM/4giyfDgV0=
golang.org/x/mod v0.19.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= 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-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-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.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.28.0 h1:a9JDOJc5GMUJ0+UDqmLT86WiEy7iWyIhz8gz8E4e5hE=
golang.org/x/net v0.27.0/go.mod h1:dDi0PyhWNoiUOrAS8uXv/vnScO4wnHQO4mj9fn/RytE= 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-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-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.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M= golang.org/x/sync v0.9.0 h1:fEo0HyrW1GIgZdpbhCRO0PkJajUS5H9IFUztCgEo2jQ=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= 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-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-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200217220822-9197077df867/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.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.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.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI= golang.org/x/sys v0.27.0 h1:wBqf8DvsY9Y/2P8gAfPDEYNuS30J4lPHJxXSb/nJZ+s=
golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.27.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.22.0 h1:BbsgPEJULsl2fV/AT3v15Mjva5yXKQDyKf+TbDz7QJk= golang.org/x/term v0.23.0 h1:F6D4vR+EHoL9/sWAWgAR1H2DcHr4PareCbAaCo1RpuU=
golang.org/x/term v0.22.0/go.mod h1:F3qCibpT5AMpCRfhfT53vVJwhLtIVHhB9XDjfFvnMI4= 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.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 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.17.0 h1:XtiM5bkSOt+ewxlOE/aE/AKEHibwj/6gvWMl9Rsh0Qc=
golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= 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 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk=
golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= 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-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.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.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0=
golang.org/x/tools v0.23.0 h1:SGsXPZ+2l4JsgaCKkx+FQ9YZ5XEtA1GZYuoDjenLjvg= golang.org/x/tools v0.24.0 h1:J1shsA93PJUEVaUSaay7UXAyE8aimq3GW0pjlolpa24=
golang.org/x/tools v0.23.0/go.mod h1:pnu6ufv6vQkll6szChhK3C3L/ruaIv5eBeztNG8wtsI= 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-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-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/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= 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 h1:H2g08FrTvSFKUj+D309j1DPfk5APnIdAQAB8aEykJ5k=
software.sslmate.com/src/go-pkcs12 v0.4.0/go.mod h1:Qiz0EyvDRJjjxGyUQa2cCNZn/wMyzrRJ/qcDXOQazLI= 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.77.0-pre.0.20241202172800-8d0c690f8997 h1:jAL0TXstGYT1L0V2qH+zpQSEBwBGgIOVkflckrU0kqo=
tailscale.com v1.73.0-pre.0.20240821174438-af3d3c433b67/go.mod h1:v7OHtg0KLAnhOVf81Z8WrjNefj238QbFhgkWJQoKxbs= 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" "net/http"
"os" "os"
"path/filepath" "path/filepath"
"reflect"
"runtime/debug" "runtime/debug"
"sync" "sync"
"sync/atomic" "sync/atomic"
@ -105,9 +106,7 @@ type settingsFunc func(*router.Config, *dns.OSConfig) error
func (a *App) runBackend(ctx context.Context) error { func (a *App) runBackend(ctx context.Context) error {
paths.AppSharedDir.Store(a.dataDir) paths.AppSharedDir.Store(a.dataDir)
hostinfo.SetOSVersion(a.osVersion()) hostinfo.SetOSVersion(a.osVersion())
if !a.appCtx.IsPlayVersion() { hostinfo.SetPackage(a.appCtx.GetInstallSource())
hostinfo.SetPackage("nogoogle")
}
deviceModel := a.modelName() deviceModel := a.modelName()
if a.isChromeOS() { if a.isChromeOS() {
deviceModel = "ChromeOS: " + deviceModel deviceModel = "ChromeOS: " + deviceModel
@ -150,7 +149,6 @@ func (a *App) runBackend(ctx context.Context) error {
cfg configPair cfg configPair
state ipn.State state ipn.State
networkMap *netmap.NetworkMap networkMap *netmap.NetworkMap
service IPNService
) )
stateCh := make(chan ipn.State) stateCh := make(chan ipn.State)
@ -168,38 +166,26 @@ func (a *App) runBackend(ctx context.Context) error {
select { select {
case s := <-stateCh: case s := <-stateCh:
state = s 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 // 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) { if errors.Is(err, errMultipleUsers) {
// TODO: surface error to user // TODO: surface error to user
} }
log.Printf("VPN update failed: %v", err) a.closeVpnService(err, b)
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()
} }
} }
case n := <-netmapCh: case n := <-netmapCh:
networkMap = n networkMap = n
case c := <-configs: case c := <-configs:
cfg = c cfg = c
if b == nil || service == nil || cfg.rcfg == nil { if vpnService.service == nil || !b.isConfigNonNilAndDifferent(cfg.rcfg, cfg.dcfg) {
configErrs <- nil configErrs <- nil
break break
} }
configErrs <- b.updateTUN(service, cfg.rcfg, cfg.dcfg) configErrs <- b.updateTUN(cfg.rcfg, cfg.dcfg)
case s := <-onVPNRequested: 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 // Still the same VPN instance, do nothing
break break
} }
@ -228,29 +214,24 @@ func (a *App) runBackend(ctx context.Context) error {
// See https://github.com/tailscale/corp/issues/13814 // See https://github.com/tailscale/corp/issues/13814
b.backend.DebugRebind() b.backend.DebugRebind()
service = s vpnService.service = s
if networkMap != nil { if networkMap != nil {
// TODO // TODO
} }
if cfg.rcfg != nil && state >= ipn.Starting { if state >= ipn.Starting && b.isConfigNonNilAndDifferent(cfg.rcfg, cfg.dcfg) {
if err := b.updateTUN(service, cfg.rcfg, cfg.dcfg); err != nil { if err := b.updateTUN(cfg.rcfg, cfg.dcfg); err != nil {
log.Printf("VPN update failed: %v", err) a.closeVpnService(err, b)
service.Close()
b.lastCfg = nil
b.CloseTUNs()
} }
} }
case s := <-onDisconnect: case s := <-onDisconnect:
b.CloseTUNs() b.CloseTUNs()
if service != nil && service.ID() == s.ID() { if vpnService.service != nil && vpnService.service.ID() == s.ID() {
netns.SetAndroidProtectFunc(nil) netns.SetAndroidProtectFunc(nil)
service = nil vpnService.service = nil
} }
case i := <-onDNSConfigChanged: 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, SetSubsystem: sys.Set,
NetMon: b.netMon, NetMon: b.netMon,
HealthTracker: sys.HealthTracker(), HealthTracker: sys.HealthTracker(),
Metrics: sys.UserMetricsRegistry(),
DriveForLocal: driveimpl.NewFileSystemForLocal(logf), DriveForLocal: driveimpl.NewFileSystemForLocal(logf),
}) })
if err != nil { if err != nil {
@ -312,7 +294,7 @@ func (a *App) newBackend(dataDir, directFileRoot string, appCtx AppContext, stor
} }
sys.Set(engine) sys.Set(engine)
b.logIDPublic = logID.Public() 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 { if err != nil {
return nil, fmt.Errorf("netstack.Create: %w", err) 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 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 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) 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. // 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 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 // Start starts the application, storing state in the given dataDir and using
// the given appCtx. // the given appCtx.
@ -31,9 +35,11 @@ type AppContext interface {
// GetModelName gets the Android device's model name. // GetModelName gets the Android device's model name.
GetModelName() (string, error) GetModelName() (string, error)
// IsPlayVersion reports whether this is the Google Play version of the app // GetInstallSource gets information about how the app was installed or updated.
// (as opposed to F-droid/sideloaded). GetInstallSource() string
IsPlayVersion() bool
// ShouldUseGoogleDNSFallback reports whether or not to use Google for DNS fallback.
ShouldUseGoogleDNSFallback() bool
// IsChromeOS reports whether we're on a ChromeOS device. // IsChromeOS reports whether we're on a ChromeOS device.
IsChromeOS() (bool, error) IsChromeOS() (bool, error)
@ -73,6 +79,10 @@ type IPNService interface {
NewBuilder() VPNServiceBuilder NewBuilder() VPNServiceBuilder
Close() Close()
DisconnectVPN()
UpdateVpnStatus(bool)
} }
// VPNServiceBuilder corresponds to Android's VpnService.Builder. // VPNServiceBuilder corresponds to Android's VpnService.Builder.
@ -162,3 +172,13 @@ func RequestVPN(service IPNService) {
func ServiceDisconnect(service IPNService) { func ServiceDisconnect(service IPNService) {
onDisconnect <- service 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" "log"
"net" "net"
"net/netip" "net/netip"
"reflect"
"runtime/debug" "runtime/debug"
"strings" "strings"
"syscall"
"github.com/tailscale/wireguard-go/tun" "github.com/tailscale/wireguard-go/tun"
"golang.org/x/sys/unix"
"inet.af/netaddr" "inet.af/netaddr"
"tailscale.com/net/dns" "tailscale.com/net/dns"
"tailscale.com/net/netmon" "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 // 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") 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. // Report interfaces in the device in net.Interface format.
func (a *App) getInterfaces() ([]netmon.Interface, error) { func (a *App) getInterfaces() ([]netmon.Interface, error) {
var ifaces []netmon.Interface var ifaces []netmon.Interface
@ -116,12 +124,7 @@ var googleDNSServers = []netip.Addr{
netip.MustParseAddr("2001:4860:4860::8844"), netip.MustParseAddr("2001:4860:4860::8844"),
} }
func (b *backend) updateTUN(service IPNService, rcfg *router.Config, dcfg *dns.OSConfig) error { func (b *backend) updateTUN(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
}
b.logger.Logf("updateTUN: changed") b.logger.Logf("updateTUN: changed")
defer b.logger.Logf("updateTUN: finished") 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 { if len(rcfg.LocalAddrs) == 0 {
return nil return nil
} }
builder := service.NewBuilder() builder := vpnService.service.NewBuilder()
b.logger.Logf("updateTUN: got new builder") b.logger.Logf("updateTUN: got new builder")
if err := builder.SetMTU(defaultMTU); err != nil { 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() parcelFD, err := builder.Establish()
if err != nil { if err != nil {
if strings.Contains(err.Error(), "INTERACT_ACROSS_USERS") { 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 errMultipleUsers
} }
return fmt.Errorf("VpnService.Builder.establish: %v", err) 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") b.logger.Logf("updateTUN: established VPN")
if parcelFD == nil { if parcelFD == nil {
@ -205,6 +213,9 @@ func (b *backend) updateTUN(service IPNService, rcfg *router.Config, dcfg *dns.O
// detachFd. // detachFd.
tunFD, err := parcelFD.Detach() tunFD, err := parcelFD.Detach()
vpnService.fdDetached = true
vpnService.fd = tunFD
if err != nil { if err != nil {
return fmt.Errorf("detachFd: %v", err) 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. // Create TUN device.
tunDev, _, err := tun.CreateUnmonitoredTUNFromFD(int(tunFD)) tunDev, _, err := tun.CreateUnmonitoredTUNFromFD(int(tunFD))
if err != nil { if err != nil {
unix.Close(int(tunFD)) closeFileDescriptor()
return err return err
} }
b.logger.Logf("updateTUN: created TUN device") 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 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. // CloseVPN closes any active TUN devices.
func (b *backend) CloseTUNs() { func (b *backend) CloseTUNs() {
b.lastCfg = nil 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 // DNS config are lacking, and almost all Android phones use Google
// services anyway, so it's a reasonable default: it's an ecosystem the // services anyway, so it's a reasonable default: it's an ecosystem the
// user has selected by having an Android device. // 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") log.Printf("getDNSBaseConfig: none found; falling back to Google public DNS")
ret.Nameservers = append(ret.Nameservers, googleDNSServers...) 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 { if filchErr != nil {
log.Printf("SetupLogs: filch setup failed: %v", filchErr) 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 set -x
fi fi
tsandroid=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )/.." &> /dev/null && pwd )
# Allow TOOLCHAINDIR to be overridden, as a special case for the fdroid build # Allow TOOLCHAINDIR to be overridden, as a special case for the fdroid build
if [[ -z "${TOOLCHAINDIR}" ]]; then if [[ -z "${TOOLCHAINDIR}" ]]; then
toolchain="$HOME/.cache/tailscale-go" toolchain="$HOME/.cache/tailscale-go"
@ -29,10 +31,13 @@ if [[ -z "${TOOLCHAINDIR}" ]]; then
rm -rf "$toolchain" "$toolchain.extracted" rm -rf "$toolchain" "$toolchain.extracted"
fi fi
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 case "$REV" in
/*) /*)
@ -61,8 +66,8 @@ if [[ -z "${TOOLCHAINDIR}" ]]; then
esac esac
fi fi
else else
# fdroid supplies it's own toolchain, rather than using ours. # fdroid supplies its own toolchain, rather than using ours.
toolchain="${TOOLCHAINDIR}" toolchain="${TOOLCHAINDIR}"
fi 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