Compare commits

..

1 Commits

Author SHA1 Message Date
kari-ts fd2ca6fa94 go.mod: update for 1.64.0
Signed-off-by: kari-ts <kari@tailscale.com>
2 months ago

@ -18,3 +18,4 @@ Client][]. See also the dependencies in the [Tailscale CLI][].
- [{{.Name}}](https://pkg.go.dev/{{.Name}}) ([{{.LicenseName}}]({{.LicenseURL}}))
{{- end }}
- [tailscale.com](https://pkg.go.dev/tailscale.com) ([BSD-3-Clause](https://github.com/tailscale/tailscale/blob/HEAD/LICENSE))
- [Gio UI](https://gioui.org/) ([MIT License](https://git.sr.ht/~eliasnaur/gio/tree/main/item/LICENSE))

@ -28,7 +28,7 @@ jobs:
java-version: '17'
- name: Build APKs
run: make tailscale-debug.apk
run: make tailscale-new-debug.apk
- name: Run tests
run: make test

@ -0,0 +1,31 @@
name: Build Legacy Debug APK
on:
push:
branches:
- main
pull_request:
branches:
- '*'
jobs:
build:
runs-on: ubuntu-latest
if: "!contains(github.event.head_commit.message, '[ci skip]')"
steps:
- name: Check out code
uses: actions/checkout@v3
- uses: actions/setup-go@v5
with:
go-version-file: 'go.mod'
- name: Switch to Java 17 # Note: 17 is pre-installed on ubuntu-latest
uses: actions/setup-java@v3
with:
distribution: 'temurin'
java-version: '17'
- name: Build Legacy APK
run: make tailscale-debug.apk

@ -1,36 +0,0 @@
name: go mod tidy
on:
push:
branches:
- main
- "release-branch/*"
pull_request:
branches:
- "*"
concurrency:
group: ${{ github.workflow }}-$${{ github.head_ref || github.run_id }}
cancel-in-progress: true
jobs:
check-go-mod-tidy:
runs-on: [ubuntu-latest]
timeout-minutes: 8
steps:
- name: Check out code
uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v5
with:
cache: false
go-version-file: go.mod
- name: Check 'go mod tidy' is clean
run: |
./tool/go mod tidy
echo
echo
git diff --name-only --exit-code || (echo "The files above need updating. Please run 'go mod tidy'."; exit 1)

1
.gitignore vendored

@ -41,4 +41,3 @@ tailscale.jks
libtailscale.aar
libtailscale-sources.jar
.DS_Store

@ -31,8 +31,8 @@ RUN mkdir -p $HOME/tailscale-android
RUN git config --global --add safe.directory $HOME/tailscale-android
WORKDIR $HOME/tailscale-android
# Preload Android SDK
COPY Makefile Makefile
# Get android sdk, ndk, and rest of the stuff needed to build the android app.
RUN make androidsdk
@ -41,7 +41,4 @@ COPY android/gradlew android/gradlew
COPY android/gradle android/gradle
RUN ./android/gradlew
# Run a shell
CMD /bin/bash

@ -2,24 +2,24 @@
# Use of this source code is governed by a BSD-style
# license that can be found in the LICENSE file.
## For signed release build JKS_PASSWORD must be set to the password for the jks keystore
## and JKS_PATH must be set to the path to the jks keystore.
DEBUG_APK=tailscale-debug.apk
RELEASE_AAB=tailscale-release.aab
LIBTAILSCALE=android/libs/libtailscale.aar
APPID=com.tailscale.ipn
AAR=android_legacy/libs/ipn.aar
KEYSTORE=tailscale.jks
KEYSTORE_ALIAS=tailscale
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 --dirty --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)))
VERSIONNAME=$(patsubst "%",%,$(lastword $(shell grep versionName android_legacy/build.gradle)))
# Extract the x.y.z part for the short version.
VERSIONNAME_SHORT=$(shell echo $(VERSIONNAME) | cut -d - -f 1)
TAILSCALE_COMMIT=$(shell echo $(TAILSCALE_VERSION) | cut -d - -f 2 | cut -d t -f 2)
# Extract the version code from build.gradle.
VERSIONCODE=$(lastword $(shell grep versionCode android/build.gradle))
VERSIONCODE=$(lastword $(shell grep versionCode android_legacy/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
@ -59,8 +59,6 @@ export JAVA_HOME ?= $(shell find "$(ANDROID_STUDIO_ROOT)/jbr" "$(ANDROID_STUDIO_
# If JAVA_HOME is still unset, remove it, because SDK tools go into a CPU spin if it is set and empty.
ifeq ($(JAVA_HOME),)
unexport JAVA_HOME
else
export PATH := $(JAVA_HOME)/bin:$(PATH)
endif
# TOOLCHAINDIR is set by fdoid CI and used by tool/* scripts.
@ -70,68 +68,11 @@ export TOOLCHAINDIR
GOBIN ?= $(PWD)/android/build/go/bin
export GOBIN
export PATH := $(PWD)/tool:$(GOBIN):$(ANDROID_HOME)/cmdline-tools/latest/bin:$(ANDROID_HOME)/platform-tools:$(PATH)
export PATH := $(PWD)/tool:$(GOBIN):$(JAVA_HOME)/bin:$(ANDROID_HOME)/cmdline-tools/latest/bin:$(ANDROID_HOME)/platform-tools:$(PATH)
export GOROOT := # Unset
#
# Android Builds:
#
.PHONY: apk
apk: $(DEBUG_APK) ## Build the debug APK
.PHONY: tailscale-debug
tailscale-debug: $(DEBUG_APK) ## Build the debug APK
.PHONY: release
release: jarsign-env $(RELEASE_AAB) ## Build the release AAB
jarsigner -sigalg SHA256withRSA -digestalg SHA-256 -keystore $(JKS_PATH) -storepass $(JKS_PASSWORD) $(RELEASE_AAB) tailscale
# gradle-dependencies groups together the android sources and libtailscale needed to assemble tests/debug/release builds.
.PHONY: gradle-dependencies
gradle-dependencies: $(shell find android -type f -not -path "android/build/*" -not -path '*/.*') $(LIBTAILSCALE)
$(DEBUG_APK): gradle-dependencies
(cd android && ./gradlew test assembleDebug)
install -C android/build/outputs/apk/debug/android-debug.apk $@
$(RELEASE_AAB): gradle-dependencies
(cd android && ./gradlew test bundleRelease)
install -C ./android/build/outputs/bundle/release/android-release.aab $@
tailscale-test.apk: gradle-dependencies
(cd android && ./gradlew assembleApplicationTestAndroidTest)
install -C ./android/build/outputs/apk/androidTest/applicationTest/android-applicationTest-androidTest.apk $@
#
# Go Builds:
#
android/libs:
mkdir -p android/libs
$(GOBIN)/gomobile: $(GOBIN)/gobind go.mod go.sum
go install golang.org/x/mobile/cmd/gomobile
$(GOBIN)/gobind: go.mod go.sum
go install golang.org/x/mobile/cmd/gobind
$(LIBTAILSCALE): Makefile android/libs $(shell find libtailscale -name *.go) go.mod go.sum $(GOBIN)/gomobile
gomobile bind -target android -androidapi 26 \
-ldflags "$(FULL_LDFLAGS)" \
-o $@ ./libtailscale
.PHONY: libtailscale
libtailscale: $(LIBTAILSCALE) ## Build the libtailscale AAR
#
# Utility tasks:
#
.PHONY: all
all: test $(DEBUG_APK) ## Build and test everything
all: tailscale-new-debug.apk test $(DEBUG_APK) tailscale-fdroid.apk ## Build and test everything
.PHONY: env
env:
@echo PATH=$(PATH)
@echo ANDROID_SDK_ROOT=$(ANDROID_SDK_ROOT)
@ -140,35 +81,12 @@ env:
@echo JAVA_HOME=$(JAVA_HOME)
@echo TOOLCHAINDIR=$(TOOLCHAINDIR)
# Ensure that JKS_PATH and JKS_PASSWORD are set before we attempt a build
# that requires signing.
.PHONY: jarsign-env
jarsign-env:
ifeq ($(JKS_PATH),)
$(error JKS_PATH is not set. export JKS_PATH=/path/to/tailcale.jks)
endif
ifeq ($(JKS_PASSWORD),)
$(error JKS_PASSWORD is not set. export JKS_PASSWORD=passwordForTailcale.jks)
endif
ifeq ($(wildcard $(JKS_PATH)),)
$(error JKS_PATH does not point to a file)
endif
@echo "keystore path set to $(JKS_PATH)"
.PHONY: androidpath
androidpath:
@echo "export ANDROID_HOME=$(ANDROID_HOME)"
@echo "export ANDROID_SDK_ROOT=$(ANDROID_SDK_ROOT)"
@echo 'export PATH=$(ANDROID_HOME)/cmdline-tools/latest/bin:$(ANDROID_HOME)/platform-tools:$$PATH'
.PHONY: tag_release
tag_release: ## Tag a release
sed -i'.bak' 's/versionCode $(VERSIONCODE)/versionCode $(VERSIONCODE_PLUSONE)/' android/build.gradle && rm android/build.gradle.bak
sed -i'.bak' 's/versionName .*/versionName "$(VERSION_LONG)"/' android/build.gradle && rm android/build.gradle.bak
git commit -sm "android: bump version code" android/build.gradle
sed -i'.bak' 's/versionCode $(VERSIONCODE)/versionCode $(VERSIONCODE_PLUSONE)/' android_legacy/build.gradle && rm android_legacy/build.gradle.bak
sed -i'.bak' 's/versionName .*/versionName "$(VERSION_LONG)"/' android_legacy/build.gradle && rm android_legacy/build.gradle.bak
git commit -sm "android: bump version code" android_legacy/build.gradle
git tag -a "$(VERSION_LONG)"
.PHONY: bumposs
bumposs: ## Update the tailscale.com go module
GOPROXY=direct go get tailscale.com@main
go run tailscale.com/cmd/printdep --go > go.toolchain.rev
@ -185,7 +103,6 @@ $(ANDROID_HOME)/cmdline-tools/latest/bin/sdkmanager:
mv $(ANDROID_HOME)/tmp/cmdline-tools $(ANDROID_HOME)/cmdline-tools/latest
rm -rf $(ANDROID_HOME)/tmp
.PHONY: androidsdk
androidsdk: $(ANDROID_HOME)/cmdline-tools/latest/bin/sdkmanager ## Install the set of Android SDK packages we need.
yes | $(ANDROID_HOME)/cmdline-tools/latest/bin/sdkmanager --licenses > /dev/null
$(ANDROID_HOME)/cmdline-tools/latest/bin/sdkmanager --update
@ -194,7 +111,6 @@ androidsdk: $(ANDROID_HOME)/cmdline-tools/latest/bin/sdkmanager ## Install the s
# Normally in make you would simply take a dependency on the task that provides
# the binaries, however users may have a decision to make as to whether they
# want to install an SDK or use the one from an Android Studio installation.
.PHONY: checkandroidsdk
checkandroidsdk: ## Check that Android SDK is installed
@$(ANDROID_HOME)/cmdline-tools/latest/bin/sdkmanager --list_installed | grep -q 'ndk' || (\
echo -e "\n\tERROR: Android SDK not installed.\n\
@ -202,51 +118,100 @@ checkandroidsdk: ## Check that Android SDK is installed
\tANDROID_SDK_ROOT=$(ANDROID_SDK_ROOT)\n\n\
See README.md for instructions on how to install the prerequisites.\n"; exit 1)
.PHONY: test
test: gradle-dependencies ## Run the Android tests
(cd android && ./gradlew test)
androidpath:
@echo "export ANDROID_HOME=$(ANDROID_HOME)"
@echo "export ANDROID_SDK_ROOT=$(ANDROID_SDK_ROOT)"
@echo 'export PATH=$(ANDROID_HOME)/cmdline-tools/latest/bin:$(ANDROID_HOME)/platform-tools:$$PATH'
.PHONY: install
install: $(DEBUG_APK) ## Install the debug APK on a connected device
adb install -r $<
$(AAR): checkandroidsdk
@mkdir -p android_legacy/libs && \
go run gioui.org/cmd/gogio \
-ldflags "$(VERSION_LDFLAGS)" \
-buildmode archive -target android -appid $(APPID) -tags novulkan,tailscale_go -o $@ github.com/tailscale/tailscale-android/cmd/tailscale
.PHONY: run
run: install ## Run the debug APK on a connected device
adb shell am start -n com.tailscale.ipn/com.tailscale.ipn.MainActivity
# tailscale-debug.apk builds a debuggable APK with the Google Play SDK.
$(DEBUG_APK): $(AAR)
(cd android_legacy && ./gradlew test assemblePlayDebug)
mv android_legacy/build/outputs/apk/play/debug/android_legacy-play-debug.apk $@
.PHONY: docker-build-image
docker-build-image: ## Builds the docker image for the android build environment
docker build -f docker/DockerFile.amd64-build -t tailscale-android-build-amd64 .
# tailscale-fdroid.apk builds a non-Google Play SDK, without the Google bits.
# This is effectively what the F-Droid build definition produces.
# This is useful for testing on e.g. Amazon Fire Stick devices.
tailscale-fdroid.apk: $(AAR)
(cd android_legacy && ./gradlew test assembleFdroidDebug)
mv android_legacy/build/outputs/apk/fdroid/debug/android_legacy-fdroid-debug.apk $@
.PHONY: docker-run-build
docker-run-build: jarsign-env docker-build-image ## Runs the docker image for the android build environment and builds release
@docker run -v $(CURDIR):/build/tailscale-android --env JKS_PASSWORD=$(JKS_PASSWORD) --env JKS_PATH=$(JKS_PATH) tailscale-android-build-amd64
$(RELEASE_AAB): $(AAR)
(cd android_legacy && ./gradlew test bundlePlayRelease)
mv ./android_legacy/build/outputs/bundle/playRelease/android_legacy-play-release.aab $@
.PHONY: docker-remove-build-image
docker-remove-build-image: ## Removes all docker build image
docker rmi --force tailscale-android-build-amd64
release: $(RELEASE_AAB) ## Build the release AAB
jarsigner -sigalg SHA256withRSA -digestalg SHA-256 -keystore $(KEYSTORE) $(RELEASE_AAB) $(KEYSTORE_ALIAS)
.PHONY: docker-all ## Makes a fresh docker environment, builds docker and cleans up. For CI.
docker-all: docker-build-image docker-run-build docker-remove-build-image
apk: $(DEBUG_APK) ## Build the debug APK
LIBTAILSCALE=android/libs/libtailscale.aar
LIBTAILSCALE_SOURCES=$(shell find libtailscale -name *.go) go.mod go.sum
android/libs:
mkdir -p android/libs
$(GOBIN)/gomobile: $(GOBIN)/gobind go.mod go.sum
go install golang.org/x/mobile/cmd/gomobile
$(GOBIN)/gobind: go.mod go.sum
go install golang.org/x/mobile/cmd/gobind
.PHONY: docker-shell
docker-shell: ## Builds a docker image with the android build env and opens a shell
docker build -f docker/DockerFile.amd64-shell -t tailscale-android-shell-amd64 .
docker run -v $(CURDIR):/build/tailscale-android -it tailscale-android-shell-amd64
# TODO: the version names used below parse the legacy version information,
# though hopefully they won't substantially differ for now.
$(LIBTAILSCALE): Makefile android/libs $(LIBTAILSCALE_SOURCES) $(GOBIN)/gomobile
gomobile bind -target android -androidapi 26 \
-ldflags "$(FULL_LDFLAGS)" \
-o $@ ./libtailscale
libtailscale: $(LIBTAILSCALE)
ANDROID_SOURCES=$(shell find android -type f -not -path "android/build/*" -not -path '*/.*')
DEBUG_INTERMEDIARY = android/build/outputs/apk/debug/android-debug.apk
$(DEBUG_INTERMEDIARY): $(ANDROID_SOURCES) $(LIBTAILSCALE)
cd android && ./gradlew test assembleDebug
tailscale-new-debug.apk: $(DEBUG_INTERMEDIARY)
(cd android && ./gradlew test assembleDebug)
mv $(DEBUG_INTERMEDIARY) $@
tailscale-new-debug: tailscale-new-debug.apk ## Build the new debug APK
ANDROID_TEST_INTERMEDIARY=./android/build/outputs/apk/androidTest/applicationTest/android-applicationTest-androidTest.apk
$(ANDROID_TEST_INTERMEDIARY): $(ANDROID_SOURCES) $(LIBTAILSCALE)
cd android && ./gradlew assembleApplicationTestAndroidTest
tailscale-test.apk: $(ANDROID_TEST_INTERMEDIARY)
mv $(ANDROID_TEST_INTERMEDIARY) $@
test: $(LIBTAILSCALE) ## Run the Android tests
(cd android && ./gradlew test)
install: tailscale-new-debug.apk ## Install the debug APK on a connected device
adb install -r $<
run: install ## Run the debug APK on a connected device
adb shell am start -n com.tailscale.ipn/com.tailscale.ipn.IPNActivity
.PHONY: docker-remove-shell-image
docker-remove-shell-image: ## Removes all docker shell image
docker rmi --force tailscale-android-shell-amd64
dockershell: ## Run a shell in the Docker build container
docker build -t tailscale-android .
docker run -v $(CURDIR):/build/tailscale-android -it --rm tailscale-android
.PHONY: clean
clean: ## Remove build artifacts. Does not purge docker build envs. Use dockerRemoveEnv for that.
-rm -rf android/build $(DEBUG_APK) $(RELEASE_AAB) $(LIBTAILSCALE) android/libs *.apk *.aab
clean: ## Remove build artifacts
-rm -rf android/build android_legacy/build $(DEBUG_APK) $(RELEASE_AAB) $(AAR) $(LIBTAILSCALE) android/libs tailscale-fdroid.apk *.apk
-pkill -f gradle
.PHONY: help
help: ## Show this help
@echo "\nSpecify a command. The choices are:\n"
@grep -hE '^[0-9a-zA-Z_-]+:.*?## .*$$' ${MAKEFILE_LIST} | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[0;36m%-20s\033[m %s\n", $$1, $$2}'
@echo ""
.PHONY: all clean install android_legacy/lib $(DEBUG_APK) $(RELEASE_AAB) $(AAR) release bump_version dockershell lib tailscale-new-debug help
.DEFAULT_GOAL := help

@ -10,19 +10,13 @@ This repository contains the open source Tailscale Android client.
## Using
[<img src="https://fdroid.gitlab.io/artwork/badge/get-it-on.png"
alt="Get it on F-Droid"
height="80">](https://f-droid.org/packages/com.tailscale.ipn/)
[<img src="https://play.google.com/intl/en_us/badges/images/generic/en-play-badge.png"
alt="Get it on Google Play"
height="80">](https://play.google.com/store/apps/details?id=com.tailscale.ipn)
Help us test new features and bug-fixes before they ship to all users! A [beta testing track](https://play.google.com/apps/testing/com.tailscale.ipn) is available on the Play Store.
#### Amazon Appstore
The app can be downloaded from the [Amazon Appstore](https://www.amazon.com/dp/B0D38TRB3N) for Amazon Fire tablets and Fire TV devices.
#### F-Droid
The [F-Droid](https://f-droid.org/packages/com.tailscale.ipn/) project builds the source code in this repository and maintains independently-built APKs. Note that F-Droid builds are not released, updated, or verified by the Tailscale team.
## Preparing a build environment
@ -93,6 +87,38 @@ release candidate builds (currently Go 1.14) in module mode. It might
work in earlier Go versions or in GOPATH mode, but we're making no
effort to keep those working.
## Google Sign-In
Google Sign-In support relies on configuring a [Google API Console
project](https://developers.google.com/identity/sign-in/android/start-integrating)
with the app identifier and [signing key
hashes](https://developers.google.com/android/guides/client-auth).
The official release uses the app identifier `com.tailscale.ipn`;
custom builds should use a different identifier.
## Running in the Android emulator
By default, the android emulator uses an older version of OpenGL ES,
which results in a black screen when opening the Tailscale app. To fix
this, with the emulator running:
- Open the three-dots menu to access emulator settings
- To to `Settings > Advanced`
- Set "OpenGL ES API level" to "Renderer maximum (up to OpenGL ES 3.1)"
- Close the emulator.
- In Android Studio's emulator view (that lists all your emulated
devices), hit the down arrow by the virtual device and select "Cold
boot now" to restart the emulator from scratch.
The Tailscale app should now render correctly.
Additionally, there seems to be a bug that prevents using the
system-level Google sign-in option (the one that pops up a
system-level UI to select your Google account). You can work around
this by selecting "Other" at the sign-in screen, and then selecting
Google from the next screen.
## Developing on a Fire Stick TV
On the Fire Stick:
@ -103,7 +129,7 @@ Then some useful commands:
```
adb connect 10.2.200.213:5555
adb install -r tailscale-fdroid.apk
adb shell am start -n com.tailscale.ipn/com.tailscale.ipn.MainActivity
adb shell am start -n com.tailscale.ipn/com.tailscale.ipn.IPNActivity
adb shell pm uninstall com.tailscale.ipn
```
@ -125,8 +151,8 @@ Origin](https://en.wikipedia.org/wiki/Developer_Certificate_of_Origin)
## About Us
We are [Tailscale](https://tailscale.com). See
https://tailscale.com/company for more about us and what we're
building.
We are apenwarr, bradfitz, crawshaw, danderson, dfcarney,
from Tailscale Inc.
You can learn more about us from [our website](https://tailscale.com).
WireGuard is a registered trademark of Jason A. Donenfeld.

@ -11,7 +11,7 @@ buildscript {
}
}
dependencies {
classpath 'com.android.tools.build:gradle:8.4.0'
classpath "com.android.tools.build:gradle:8.1.4"
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
classpath "org.jetbrains.kotlin:kotlin-serialization:$kotlin_version"
classpath("com.ncorti.ktfmt.gradle:plugin:0.17.0")
@ -37,22 +37,15 @@ android {
defaultConfig {
minSdkVersion 26
targetSdkVersion 34
versionCode 228
versionName "1.69.49-t25eeafde2-gea928ca9712"
versionCode 198
versionName "1.59.53-t0f042b981-g1017015de26"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
kotlinOptions {
jvmTarget = "17"
}
buildFeatures {
compose true
}
@ -69,15 +62,6 @@ android {
buildConfigField "String", "GITHUB_PASSWORD", "\"" + getLocalProperty("githubPassword")+"\""
buildConfigField "String", "GITHUB_2FA_SECRET", "\"" + getLocalProperty("github2FASecret")+"\""
}
release {
minifyEnabled true
shrinkResources true
proguardFiles getDefaultProguardFile(
'proguard-android-optimize.txt'),
'proguard-rules.pro'
}
}
testBuildType "applicationTest"
@ -94,7 +78,6 @@ dependencies {
// Kotlin dependencies.
implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.3"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.0"
implementation 'junit:junit:4.12'
runtimeOnly "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.0"
implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version"
implementation "org.jetbrains.kotlin:kotlin-reflect:$kotlin_version"
@ -102,6 +85,7 @@ dependencies {
// Compose dependencies.
def composeBom = platform('androidx.compose:compose-bom:2023.06.01')
implementation composeBom
androidTestImplementation composeBom
implementation 'androidx.compose.material3:material3:1.2.1'
implementation 'androidx.compose.material:material-icons-core:1.6.3'
implementation "androidx.compose.ui:ui:1.6.3"
@ -124,32 +108,25 @@ dependencies {
// Tailscale dependencies.
implementation ':libtailscale@aar'
// Integration Tests
androidTestImplementation composeBom
// Tests
testImplementation 'junit:junit:4.13.2'
androidTestImplementation 'androidx.test:runner:1.5.2'
androidTestImplementation 'androidx.test.ext:junit-ktx:1.1.5'
androidTestImplementation 'androidx.test.ext:junit:1.1.5'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1'
implementation 'androidx.test.uiautomator:uiautomator:2.3.0'
// Authentication only for tests
androidTestImplementation 'dev.turingcomplete:kotlin-onetimepassword:2.4.0'
androidTestImplementation 'commons-codec:commons-codec:1.16.1'
// Unit Tests
testImplementation 'junit:junit:4.13.2'
debugImplementation("androidx.compose.ui:ui-tooling")
implementation("androidx.compose.ui:ui-tooling-preview")
}
def getLocalProperty(key) {
try {
Properties properties = new Properties()
properties.load(project.file('local.properties').newDataInputStream())
return properties.getProperty(key)
return properties.getProperty(key);
} catch(Throwable ignored) {
return ""
}
}
}

@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionSha256Sum=9631d53cf3e74bfa726893aee1f8994fee4e060c401335946dba2156f440f24c
distributionUrl=https\://services.gradle.org/distributions/gradle-8.6-bin.zip
distributionSha256Sum=38f66cd6eef217b4c35855bb11ea4e9fbc53594ccccb5fb82dfd317ef8c2c5a3
distributionUrl=https\://services.gradle.org/distributions/gradle-8.2-bin.zip
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

@ -1,21 +0,0 @@
# Keep all classes with native methods
-keepclasseswithmembernames class * {
native <methods>;
}
# Keep specific classes from Tink library
-keep class com.google.crypto.tink.** { *; }
# Ignore warnings about missing Error Prone annotations
-dontwarn com.google.errorprone.annotations.**
# Keep Error Prone annotations if referenced
-keep class com.google.errorprone.annotations.** { *; }
# Keep Google HTTP Client classes
-keep class com.google.api.client.http.** { *; }
-dontwarn com.google.api.client.http.**
# Keep Joda-Time classes
-keep class org.joda.time.** { *; }
-dontwarn org.joda.time.**

@ -4,13 +4,12 @@
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="29" />
android:maxSdkVersion="28" />
<uses-permission android:name="android.permission.CHANGE_NETWORK_STATE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SYSTEM_EXEMPTED"/>
<!-- Disable input emulation on ChromeOS -->
<uses-feature
@ -29,7 +28,6 @@
android:name=".App"
android:allowBackup="false"
android:banner="@drawable/tv_banner"
android:requestLegacyExternalStorage="true"
android:icon="@mipmap/ic_launcher"
android:label="Tailscale"
android:roundIcon="@mipmap/ic_launcher_round"
@ -84,21 +82,20 @@
</intent-filter>
</activity>
<receiver
android:name="IPNReceiver"
android:exported="true">
<intent-filter>
<action android:name="com.tailscale.ipn.CONNECT_VPN" />
<action android:name="com.tailscale.ipn.DISCONNECT_VPN" />
<action android:name="com.tailscale.ipn.USE_EXIT_NODE" />
</intent-filter>
</receiver>
<service
android:name=".IPNService"
android:exported="false"
android:permission="android.permission.BIND_VPN_SERVICE"
android:foregroundServiceType="systemExempted">
android:permission="android.permission.BIND_VPN_SERVICE">
<intent-filter>
<action android:name="android.net.VpnService" />
</intent-filter>

@ -3,22 +3,35 @@
package com.tailscale.ipn
import android.Manifest
import android.app.Activity
import android.app.Application
import android.app.Notification
import android.app.DownloadManager
import android.app.Fragment
import android.app.FragmentTransaction
import android.app.NotificationChannel
import android.app.PendingIntent
import android.app.UiModeManager
import android.content.ContentResolver
import android.content.ContentValues
import android.content.Context
import android.content.Intent
import android.content.SharedPreferences
import android.content.pm.PackageInfo
import android.content.pm.PackageManager
import android.content.res.Configuration
import android.net.ConnectivityManager
import android.net.LinkProperties
import android.net.Network
import android.net.NetworkCapabilities
import android.net.NetworkRequest
import android.net.Uri
import android.net.VpnService
import android.os.Build
import android.os.Environment
import android.provider.MediaStore
import android.provider.Settings
import android.util.Log
import androidx.browser.customtabs.CustomTabsIntent
import androidx.core.app.ActivityCompat
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
@ -28,15 +41,12 @@ import com.tailscale.ipn.mdm.MDMSettings
import com.tailscale.ipn.ui.localapi.Client
import com.tailscale.ipn.ui.localapi.Request
import com.tailscale.ipn.ui.model.Ipn
import com.tailscale.ipn.ui.notifier.HealthNotifier
import com.tailscale.ipn.ui.notifier.Notifier
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import libtailscale.Libtailscale
import java.io.File
import java.io.IOException
@ -45,34 +55,42 @@ import java.net.NetworkInterface
import java.security.GeneralSecurityException
import java.util.Locale
class App : UninitializedApp(), libtailscale.AppContext {
class App : Application(), libtailscale.AppContext {
val applicationScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
companion object {
const val STATUS_CHANNEL_ID = "tailscale-status"
const val STATUS_NOTIFICATION_ID = 1
const val NOTIFY_CHANNEL_ID = "tailscale-notify"
const val NOTIFY_NOTIFICATION_ID = 2
private const val PEER_TAG = "peer"
private const val FILE_CHANNEL_ID = "tailscale-files"
private const val FILE_NOTIFICATION_ID = 3
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
lateinit var appInstance: App
@JvmStatic
fun startActivityForResult(act: Activity, intent: Intent?, request: Int) {
val f: Fragment = act.fragmentManager.findFragmentByTag(PEER_TAG)
f.startActivityForResult(intent, request)
}
/**
* Initializes the app (if necessary) and returns the singleton app instance. Always use this
* function to obtain an App reference to make sure the app initializes.
*/
@JvmStatic
fun get(): App {
appInstance.initOnce()
fun getApplication(): App {
return appInstance
}
}
val dns = DnsConfig()
var autoConnect = false
var vpnReady = false
private lateinit var connectivityManager: ConnectivityManager
private lateinit var app: libtailscale.Application
private var healthNotifier: HealthNotifier? = null
override fun getPlatformDNSConfig(): String = dns.dnsConfigAsString
@ -84,41 +102,6 @@ class App : UninitializedApp(), libtailscale.AppContext {
override fun onCreate() {
super.onCreate()
createNotificationChannel(
STATUS_CHANNEL_ID,
getString(R.string.vpn_status),
getString(R.string.optional_notifications_which_display_the_status_of_the_vpn_tunnel),
NotificationManagerCompat.IMPORTANCE_MIN)
createNotificationChannel(
FILE_CHANNEL_ID,
getString(R.string.taildrop_file_transfers),
getString(R.string.notifications_delivered_when_a_file_is_received_using_taildrop),
NotificationManagerCompat.IMPORTANCE_DEFAULT)
createNotificationChannel(
HealthNotifier.HEALTH_CHANNEL_ID,
getString(R.string.health_channel_name),
getString(R.string.health_channel_description),
NotificationManagerCompat.IMPORTANCE_HIGH)
appInstance = this
setUnprotectedInstance(this)
}
override fun onTerminate() {
super.onTerminate()
Notifier.stop()
notificationManager.cancelAll()
applicationScope.cancel()
}
private var isInitialized = false
@Synchronized
private fun initOnce() {
if (isInitialized) {
return
}
isInitialized = true
val dataDir = this.filesDir.absolutePath
// Set this to enable direct mode for taildrop whereby downloads will be saved directly
@ -126,32 +109,35 @@ class App : UninitializedApp(), libtailscale.AppContext {
// an app local directory "Taildrop" if we cannot create that. This mode does not support
// user notifications for incoming files.
val directFileDir = this.prepareDownloadsFolder()
app = Libtailscale.start(dataDir, directFileDir.absolutePath, this)
Request.setApp(app)
Notifier.setApp(app)
Notifier.start(applicationScope)
healthNotifier = HealthNotifier(Notifier.health, applicationScope)
connectivityManager = this.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
setAndRegisterNetworkCallbacks()
createNotificationChannel(
NOTIFY_CHANNEL_ID, "Notifications", NotificationManagerCompat.IMPORTANCE_DEFAULT)
createNotificationChannel(
STATUS_CHANNEL_ID, "VPN Status", NotificationManagerCompat.IMPORTANCE_LOW)
createNotificationChannel(
FILE_CHANNEL_ID, "File transfers", NotificationManagerCompat.IMPORTANCE_DEFAULT)
appInstance = this
applicationScope.launch {
Notifier.state.collect { state ->
val ableToStartVPN = state > Ipn.State.NeedsMachineAuth
// If VPN is stopped, show a disconnected notification. If it is running as a foregrround
// service, IPNService will show a connected notification.
if (state == Ipn.State.Stopped) {
notifyStatus(false)
}
val vpnRunning = state == Ipn.State.Starting || state == Ipn.State.Running
updateConnStatus(ableToStartVPN)
QuickToggleService.setVPNRunning(vpnRunning)
}
Notifier.tileReady.collect { isTileReady -> setTileReady(isTileReady) }
}
}
override fun onTerminate() {
super.onTerminate()
Notifier.stop()
applicationScope.cancel()
}
fun setWantRunning(wantRunning: Boolean) {
val callback: (Result<Ipn.Prefs>) -> Unit = { result ->
result.fold(
onSuccess = {},
onSuccess = { _ -> setTileStatus(wantRunning) },
onFailure = { error ->
Log.d("TAG", "Set want running: failed to update preferences: ${error.message}")
})
@ -182,19 +168,31 @@ class App : UninitializedApp(), libtailscale.AppContext {
}
if (dns.updateDNSFromNetwork(sb.toString())) {
Libtailscale.onDNSConfigChanged(linkProperties?.interfaceName)
Libtailscale.onDnsConfigChanged()
}
}
override fun onLost(network: Network) {
super.onLost(network)
if (dns.updateDNSFromNetwork("")) {
Libtailscale.onDNSConfigChanged("")
Libtailscale.onDnsConfigChanged()
}
}
})
}
fun startVPN() {
val intent = Intent(this, IPNService::class.java)
intent.setAction(IPNService.ACTION_REQUEST_VPN)
startService(intent)
}
fun stopVPN() {
val intent = Intent(this, IPNService::class.java)
intent.setAction(IPNService.ACTION_STOP_VPN)
startService(intent)
}
// encryptToPref a byte array of data using the Jetpack Security
// library and writes it to a global encrypted preference store.
@Throws(IOException::class, GeneralSecurityException::class)
@ -221,15 +219,30 @@ class App : UninitializedApp(), libtailscale.AppContext {
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM)
}
/*
* setAbleToStartVPN remembers whether or not we're able to start the VPN
* by storing this in a shared preference. This allows us to check this
* value without needing a fully initialized instance of the application.
*/
private fun updateConnStatus(ableToStartVPN: Boolean) {
setAbleToStartVPN(ableToStartVPN)
QuickToggleService.updateTile()
Log.d("App", "Set Tile Ready: $ableToStartVPN")
fun setTileReady(ready: Boolean) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) {
return
}
QuickToggleService.setReady(this, ready)
Log.d("App", "Set Tile Ready: $ready $autoConnect")
vpnReady = ready
if (ready && autoConnect) {
startVPN()
}
}
fun setTileStatus(status: Boolean) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) {
return
}
QuickToggleService.setStatus(this, status)
}
fun getHostname(): String {
val userConfiguredDeviceName = getUserConfiguredDeviceName()
if (!userConfiguredDeviceName.isNullOrEmpty()) return userConfiguredDeviceName
return modelName
}
override fun getModelName(): String {
@ -245,10 +258,138 @@ class App : UninitializedApp(), libtailscale.AppContext {
override fun getOSVersion(): String = Build.VERSION.RELEASE
// get user defined nickname from Settings
// returns null if not available
private fun getUserConfiguredDeviceName(): String? {
val nameFromSystemDevice = Settings.Secure.getString(contentResolver, "device_name")
if (!nameFromSystemDevice.isNullOrEmpty()) return nameFromSystemDevice
return null
}
// attachPeer adds a Peer fragment for tracking the Activity
// lifecycle.
fun attachPeer(act: Activity) {
act.runOnUiThread(
Runnable {
val ft: FragmentTransaction = act.fragmentManager.beginTransaction()
ft.add(Peer(), PEER_TAG)
ft.commit()
act.fragmentManager.executePendingTransactions()
})
}
override fun isChromeOS(): Boolean {
return packageManager.hasSystemFeature("android.hardware.type.pc")
}
fun prepareVPN(act: Activity, reqCode: Int) {
act.runOnUiThread(
Runnable {
val intent: Intent? = VpnService.prepare(act)
if (intent == null) {
startVPN()
} else {
startActivityForResult(act, intent, reqCode)
}
})
}
fun showURL(act: Activity, url: String?) {
act.runOnUiThread(
Runnable {
val builder: CustomTabsIntent.Builder = CustomTabsIntent.Builder()
val headerColor = -0xb69b6b
builder.setToolbarColor(headerColor)
val intent: CustomTabsIntent = builder.build()
intent.launchUrl(act, Uri.parse(url))
})
}
@get:Throws(Exception::class)
val packageCertificate: ByteArray?
// getPackageSignatureFingerprint returns the first package signing certificate, if any.
get() {
val info: PackageInfo
info = packageManager.getPackageInfo(packageName, PackageManager.GET_SIGNATURES)
for (signature in info.signatures) {
return signature.toByteArray()
}
return null
}
@Throws(IOException::class)
fun insertMedia(name: String?, mimeType: String): String {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
val resolver: ContentResolver = contentResolver
val contentValues = ContentValues()
contentValues.put(MediaStore.MediaColumns.DISPLAY_NAME, name)
if ("" != mimeType) {
contentValues.put(MediaStore.MediaColumns.MIME_TYPE, mimeType)
}
val root: Uri = MediaStore.Files.getContentUri("external")
resolver.insert(root, contentValues).toString()
} else {
val dir: File = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS)
dir.mkdirs()
val f = File(dir, name)
Uri.fromFile(f).toString()
}
}
@Throws(IOException::class)
fun openUri(uri: String?, mode: String?): Int? {
val resolver: ContentResolver = contentResolver
return mode?.let { resolver.openFileDescriptor(Uri.parse(uri), it)?.detachFd() }
}
fun deleteUri(uri: String?) {
val resolver: ContentResolver = contentResolver
resolver.delete(Uri.parse(uri), null, null)
}
fun notifyFile(uri: String?, msg: String?) {
val viewIntent: Intent
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
viewIntent = Intent(Intent.ACTION_VIEW, Uri.parse(uri))
} else {
// uri is a file:// which is not allowed to be shared outside the app.
viewIntent = Intent(DownloadManager.ACTION_VIEW_DOWNLOADS)
}
val pending: PendingIntent =
PendingIntent.getActivity(this, 0, viewIntent, PendingIntent.FLAG_UPDATE_CURRENT)
val builder: NotificationCompat.Builder =
NotificationCompat.Builder(this, FILE_CHANNEL_ID)
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle("File received")
.setContentText(msg)
.setContentIntent(pending)
.setAutoCancel(true)
.setOnlyAlertOnce(true)
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
val nm: NotificationManagerCompat = NotificationManagerCompat.from(this)
if (ActivityCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) !=
PackageManager.PERMISSION_GRANTED) {
// TODO: Consider calling
// ActivityCompat#requestPermissions
// here to request the missing permissions, and then overriding
// public void onRequestPermissionsResult(int requestCode, String[] permissions,
// int[] grantResults)
// to handle the case where the user grants the permission. See the documentation
// for ActivityCompat#requestPermissions for more details.
return
}
nm.notify(FILE_NOTIFICATION_ID, builder.build())
}
fun createNotificationChannel(id: String?, name: String?, importance: Int) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
return
}
val channel = NotificationChannel(id, name, importance)
val nm: NotificationManagerCompat = NotificationManagerCompat.from(this)
nm.createNotificationChannel(channel)
}
override fun getInterfacesAsString(): String {
val interfaces: ArrayList<NetworkInterface> =
java.util.Collections.list(NetworkInterface.getNetworkInterfaces())
@ -284,7 +425,12 @@ class App : UninitializedApp(), libtailscale.AppContext {
return sb.toString()
}
private fun prepareDownloadsFolder(): File {
fun isTV(): Boolean {
val mm = getSystemService(Context.UI_MODE_SERVICE) as UiModeManager
return mm.currentModeType == Configuration.UI_MODE_TYPE_TELEVISION
}
fun prepareDownloadsFolder(): File {
var downloads = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS)
try {
@ -307,148 +453,17 @@ class App : UninitializedApp(), libtailscale.AppContext {
return downloads
}
@Throws(
IOException::class, GeneralSecurityException::class, MDMSettings.NoSuchKeyException::class)
@Throws(IOException::class, GeneralSecurityException::class)
override fun getSyspolicyBooleanValue(key: String): Boolean {
return getSyspolicyStringValue(key) == "true"
}
@Throws(
IOException::class, GeneralSecurityException::class, MDMSettings.NoSuchKeyException::class)
@Throws(IOException::class, GeneralSecurityException::class)
override fun getSyspolicyStringValue(key: String): String {
return MDMSettings.allSettingsByKey[key]?.flow?.value?.toString()
?: run {
Log.d("MDM", "$key is not defined on Android. Throwing NoSuchKeyException.")
throw MDMSettings.NoSuchKeyException()
}
}
@Throws(
IOException::class, GeneralSecurityException::class, MDMSettings.NoSuchKeyException::class)
override fun getSyspolicyStringArrayJSONValue(key: String): String {
val list = MDMSettings.allSettingsByKey[key]?.flow?.value as? List<String>
try {
return Json.encodeToString(list)
} catch (e: Exception) {
Log.d("MDM", "$key is not defined on Android. Throwing NoSuchKeyException.")
throw MDMSettings.NoSuchKeyException()
}
}
}
/**
* UninitializedApp contains all of the methods of App that can be used without having to initialize
* the Go backend. This is useful when you want to access functions on the App without creating side
* effects from starting the Go backend (such as launching the VPN).
*/
open class UninitializedApp : Application() {
companion object {
const val STATUS_NOTIFICATION_ID = 1
const val STATUS_EXIT_NODE_FAILURE_NOTIFICATION_ID = 2
const val STATUS_CHANNEL_ID = "tailscale-status"
// Key for shared preference that tracks whether or not we're able to start
// the VPN (i.e. we're logged in and machine is authorized).
private const val ABLE_TO_START_VPN_KEY = "ableToStartVPN"
// File for shared preferences that are not encrypted.
private const val UNENCRYPTED_PREFERENCES = "unencrypted"
private lateinit var appInstance: UninitializedApp
lateinit var notificationManager: NotificationManagerCompat
@JvmStatic
fun get(): UninitializedApp {
return appInstance
}
}
protected fun setUnprotectedInstance(instance: UninitializedApp) {
appInstance = instance
}
protected fun setAbleToStartVPN(rdy: Boolean) {
getUnencryptedPrefs().edit().putBoolean(ABLE_TO_START_VPN_KEY, rdy).apply()
}
/** This function can be called without initializing the App. */
fun isAbleToStartVPN(): Boolean {
return getUnencryptedPrefs().getBoolean(ABLE_TO_START_VPN_KEY, false)
}
private fun getUnencryptedPrefs(): SharedPreferences {
return getSharedPreferences(UNENCRYPTED_PREFERENCES, MODE_PRIVATE)
}
fun startVPN() {
val intent = Intent(this, IPNService::class.java).apply { action = IPNService.ACTION_START_VPN }
startForegroundService(intent)
}
fun stopVPN() {
val intent = Intent(this, IPNService::class.java).apply { action = IPNService.ACTION_STOP_VPN }
startService(intent)
}
fun createNotificationChannel(id: String, name: String, description: String, importance: Int) {
val channel = NotificationChannel(id, name, importance)
channel.description = description
notificationManager = NotificationManagerCompat.from(this)
notificationManager.createNotificationChannel(channel)
}
fun notifyStatus(vpnRunning: Boolean) {
notifyStatus(buildStatusNotification(vpnRunning))
}
fun notifyStatus(notification: Notification) {
if (ActivityCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) !=
PackageManager.PERMISSION_GRANTED) {
// TODO: Consider calling
// ActivityCompat#requestPermissions
// here to request the missing permissions, and then overriding
// public void onRequestPermissionsResult(int requestCode, String[] permissions,
// int[] grantResults)
// to handle the case where the user grants the permission. See the documentation
// for ActivityCompat#requestPermissions for more details.
return
}
notificationManager.notify(STATUS_NOTIFICATION_ID, notification)
}
fun buildStatusNotification(vpnRunning: Boolean): Notification {
val message = getString(if (vpnRunning) R.string.connected else R.string.not_connected)
val icon = if (vpnRunning) R.drawable.ic_notification else R.drawable.ic_notification_disabled
val action =
if (vpnRunning) IPNReceiver.INTENT_DISCONNECT_VPN else IPNReceiver.INTENT_CONNECT_VPN
val actionLabel = getString(if (vpnRunning) R.string.disconnect else R.string.connect)
val buttonIntent = Intent(this, IPNReceiver::class.java).apply { this.action = action }
val pendingButtonIntent: PendingIntent =
PendingIntent.getBroadcast(
this,
0,
buttonIntent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
val intent =
Intent(this, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
Log.d("MDM", "$key is not defined on Android. Returning empty.")
""
}
val pendingIntent: PendingIntent =
PendingIntent.getActivity(
this, 1, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
return NotificationCompat.Builder(this, STATUS_CHANNEL_ID)
.setSmallIcon(icon)
.setContentTitle("Tailscale")
.setContentText(message)
.setAutoCancel(!vpnRunning)
.setOnlyAlertOnce(!vpnRunning)
.setOngoing(vpnRunning)
.setSilent(true)
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
.addAction(NotificationCompat.Action.Builder(0, actionLabel, pendingButtonIntent).build())
.setContentIntent(pendingIntent)
.build()
}
}

@ -6,23 +6,17 @@ package com.tailscale.ipn;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import androidx.work.Data;
import androidx.work.OneTimeWorkRequest;
import androidx.work.WorkManager;
import java.util.Objects;
/**
* IPNReceiver allows external applications to start the VPN.
*/
public class IPNReceiver extends BroadcastReceiver {
public static final String INTENT_CONNECT_VPN = "com.tailscale.ipn.CONNECT_VPN";
public static final String INTENT_DISCONNECT_VPN = "com.tailscale.ipn.DISCONNECT_VPN";
private static final String INTENT_USE_EXIT_NODE = "com.tailscale.ipn.USE_EXIT_NODE";
@Override
public void onReceive(Context context, Intent intent) {
WorkManager workManager = WorkManager.getInstance(context);
@ -33,13 +27,5 @@ public class IPNReceiver extends BroadcastReceiver {
} else if (Objects.equals(intent.getAction(), INTENT_DISCONNECT_VPN)) {
workManager.enqueue(new OneTimeWorkRequest.Builder(StopVPNWorker.class).build());
}
else if (Objects.equals(intent.getAction(), INTENT_USE_EXIT_NODE)) {
String exitNode = intent.getStringExtra("exitNode");
boolean allowLanAccess = intent.getBooleanExtra("allowLanAccess", false);
Data.Builder workData = new Data.Builder();
workData.putString(UseExitNodeWorker.EXIT_NODE_NAME, exitNode);
workData.putBoolean(UseExitNodeWorker.ALLOW_LAN_ACCESS, allowLanAccess);
workManager.enqueue(new OneTimeWorkRequest.Builder(UseExitNodeWorker.class).setInputData(workData.build()).build());
}
}
}

@ -8,6 +8,8 @@ import android.content.pm.PackageManager
import android.net.VpnService
import android.os.Build
import android.system.OsConstants
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import libtailscale.Libtailscale
import java.util.UUID
@ -18,51 +20,35 @@ open class IPNService : VpnService(), libtailscale.IPNService {
return randomID
}
override fun onCreate() {
super.onCreate()
// grab app to make sure it initializes
App.get()
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
if (intent != null && ACTION_STOP_VPN == intent.action) {
(applicationContext as App).autoConnect = false
close()
return START_NOT_STICKY
}
val app = applicationContext as App
if (intent != null && "android.net.VpnService" == intent.action) {
// Start VPN and connect to it due to Always-on VPN
val i = Intent(IPNReceiver.INTENT_CONNECT_VPN)
i.setPackage(packageName)
i.setClass(applicationContext, IPNReceiver::class.java)
sendBroadcast(i)
Libtailscale.requestVPN(this)
app.setWantRunning(true)
return START_STICKY
}
Libtailscale.requestVPN(this)
if (app.vpnReady && app.autoConnect) {
app.setWantRunning(true)
}
return START_STICKY
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int =
when (intent?.action) {
ACTION_STOP_VPN -> {
App.get().setWantRunning(false)
close()
START_NOT_STICKY
}
ACTION_START_VPN -> {
showForegroundNotification()
App.get().setWantRunning(true)
Libtailscale.requestVPN(this)
START_STICKY
}
"android.net.VpnService" -> {
// This means we were started by Android due to Always On VPN.
// We show a non-foreground notification because we weren't
// started as a foreground service.
App.get().notifyStatus(true)
App.get().setWantRunning(true)
Libtailscale.requestVPN(this)
START_STICKY
}
else -> {
// This means that we were restarted after the service was killed
// (potentially due to OOM).
if (UninitializedApp.get().isAbleToStartVPN()) {
showForegroundNotification()
App.get()
Libtailscale.requestVPN(this)
START_STICKY
} else {
START_NOT_STICKY
}
}
}
override fun close() {
stopForeground(STOP_FOREGROUND_REMOVE)
override public fun close() {
stopForeground(true)
Libtailscale.serviceDisconnect(this)
val app = applicationContext as App
app.setWantRunning(false)
}
override fun onDestroy() {
@ -75,12 +61,6 @@ open class IPNService : VpnService(), libtailscale.IPNService {
super.onRevoke()
}
private fun showForegroundNotification() {
startForeground(
UninitializedApp.STATUS_NOTIFICATION_ID,
UninitializedApp.get().buildStatusNotification(true))
}
private fun configIntent(): PendingIntent {
return PendingIntent.getActivity(
this,
@ -101,10 +81,10 @@ open class IPNService : VpnService(), libtailscale.IPNService {
.setConfigureIntent(configIntent())
.allowFamily(OsConstants.AF_INET)
.allowFamily(OsConstants.AF_INET6)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
b.setMetered(false) // Inherit the metered status from the underlying networks.
}
b.setUnderlyingNetworks(null) // Use all available networks.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q)
b.setMetered(false) // Inherit the metered status from the underlying networks.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M)
b.setUnderlyingNetworks(null) // Use all available networks.
// RCS/Jibe https://github.com/tailscale/tailscale/issues/2322
disallowApp(b, "com.google.android.apps.messaging")
@ -127,8 +107,33 @@ open class IPNService : VpnService(), libtailscale.IPNService {
return VPNServiceBuilder(b)
}
fun notify(title: String?, message: String?) {
val builder: NotificationCompat.Builder =
NotificationCompat.Builder(this, App.NOTIFY_CHANNEL_ID)
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle(title)
.setContentText(message)
.setContentIntent(configIntent())
.setAutoCancel(true)
.setOnlyAlertOnce(true)
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
val nm: NotificationManagerCompat = NotificationManagerCompat.from(this)
nm.notify(App.NOTIFY_NOTIFICATION_ID, builder.build())
}
fun updateStatusNotification(title: String?, message: String?) {
val builder: NotificationCompat.Builder =
NotificationCompat.Builder(this, App.STATUS_CHANNEL_ID)
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle(title)
.setContentText(message)
.setContentIntent(configIntent())
.setPriority(NotificationCompat.PRIORITY_LOW)
startForeground(App.STATUS_NOTIFICATION_ID, builder.build())
}
companion object {
const val ACTION_START_VPN = "com.tailscale.ipn.START_VPN"
const val ACTION_REQUEST_VPN = "com.tailscale.ipn.REQUEST_VPN"
const val ACTION_STOP_VPN = "com.tailscale.ipn.STOP_VPN"
}
}

@ -11,6 +11,8 @@ import android.content.RestrictionsManager
import android.content.pm.ActivityInfo
import android.content.res.Configuration.SCREENLAYOUT_SIZE_LARGE
import android.content.res.Configuration.SCREENLAYOUT_SIZE_MASK
import android.net.Uri
import android.net.VpnService
import android.os.Bundle
import android.provider.Settings
import android.util.Log
@ -18,7 +20,6 @@ import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContract
import androidx.activity.viewModels
import androidx.browser.customtabs.CustomTabsIntent
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
@ -38,6 +39,7 @@ import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument
import com.tailscale.ipn.Peer.RequestCodes
import com.tailscale.ipn.mdm.MDMSettings
import com.tailscale.ipn.ui.model.Ipn
import com.tailscale.ipn.ui.notifier.Notifier
@ -51,8 +53,6 @@ import com.tailscale.ipn.ui.view.DNSSettingsView
import com.tailscale.ipn.ui.view.ExitNodePicker
import com.tailscale.ipn.ui.view.IntroView
import com.tailscale.ipn.ui.view.LoginQRView
import com.tailscale.ipn.ui.view.LoginWithAuthKeyView
import com.tailscale.ipn.ui.view.LoginWithCustomControlURLView
import com.tailscale.ipn.ui.view.MDMSettingsDebugView
import com.tailscale.ipn.ui.view.MainView
import com.tailscale.ipn.ui.view.MainViewNavigation
@ -64,10 +64,8 @@ import com.tailscale.ipn.ui.view.PermissionsView
import com.tailscale.ipn.ui.view.RunExitNodeView
import com.tailscale.ipn.ui.view.SettingsView
import com.tailscale.ipn.ui.view.TailnetLockSetupView
import com.tailscale.ipn.ui.view.UserSwitcherNav
import com.tailscale.ipn.ui.view.UserSwitcherView
import com.tailscale.ipn.ui.viewModel.ExitNodePickerNav
import com.tailscale.ipn.ui.viewModel.MainViewModel
import com.tailscale.ipn.ui.viewModel.SettingsNav
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.cancel
@ -76,9 +74,8 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
class MainActivity : ComponentActivity() {
private lateinit var requestVpnPermission: ActivityResultLauncher<Unit>
private lateinit var navController: NavHostController
private lateinit var vpnPermissionLauncher: ActivityResultLauncher<Intent>
private val viewModel: MainViewModel by viewModels()
companion object {
private const val TAG = "Main Activity"
@ -99,9 +96,6 @@ class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// grab app to make sure it initializes
App.get()
// (jonathan) TODO: Force the app to be portrait on small screens until we have
// proper landscape layout support
if (!isLandscapeCapable()) {
@ -110,19 +104,6 @@ class MainActivity : ComponentActivity() {
installSplashScreen()
vpnPermissionLauncher =
registerForActivityResult(VpnPermissionContract()) { granted ->
if (granted) {
Log.d("VpnPermission", "VPN permission granted")
viewModel.setVpnPrepared(true)
App.get().startVPN()
} else {
Log.d("VpnPermission", "VPN permission denied")
viewModel.setVpnPrepared(false)
}
}
viewModel.setVpnPermissionLauncher(vpnPermissionLauncher)
setContent {
AppTheme {
navController = rememberNavController()
@ -180,17 +161,8 @@ class MainActivity : ComponentActivity() {
onNavigateToMullvadCountry = { navController.navigate("mullvad/$it") },
onNavigateToRunAsExitNode = { navController.navigate("runExitNode") })
val userSwitcherNav =
UserSwitcherNav(
backToSettings = backTo("settings"),
onNavigateHome = backTo("main"),
onNavigateCustomControl = {
navController.navigate("loginWithCustomControl")
},
onNavigateToAuthKey = { navController.navigate("loginWithAuthKey") })
composable("main", enterTransition = { fadeIn(animationSpec = tween(150)) }) {
MainView(loginAtUrl = ::login, navigation = mainViewNav, viewModel = viewModel)
MainView(navigation = mainViewNav)
}
composable("settings") { SettingsView(settingsNav) }
composable("exitNodes") { ExitNodePicker(exitNodePickerNav) }
@ -214,20 +186,15 @@ class MainActivity : ComponentActivity() {
composable("about") { AboutView(backTo("settings")) }
composable("mdmSettings") { MDMSettingsDebugView(backTo("settings")) }
composable("managedBy") { ManagedByView(backTo("settings")) }
composable("userSwitcher") { UserSwitcherView(userSwitcherNav) }
composable("userSwitcher") {
UserSwitcherView(backTo("settings"), backTo("main"))
}
composable("permissions") {
PermissionsView(backTo("settings"), ::openApplicationSettings)
}
composable("intro", exitTransition = { fadeOut(animationSpec = tween(150)) }) {
IntroView(backTo("main"))
}
composable("loginWithAuthKey") {
LoginWithAuthKeyView(onNavigateHome = backTo("main"), backTo("userSwitcher"))
}
composable("loginWithCustomControl") {
LoginWithCustomControlURLView(
onNavigateHome = backTo("main"), backTo("userSwitcher"))
}
}
// Show the intro screen one time
@ -245,6 +212,12 @@ class MainActivity : ComponentActivity() {
}
}
}
lifecycleScope.launch {
Notifier.readyToPrepareVPN.collect { isReady ->
if (isReady)
App.getApplication().prepareVPN(this@MainActivity, RequestCodes.requestPrepareVPN)
}
}
}
init {
@ -274,16 +247,14 @@ class MainActivity : ComponentActivity() {
override fun onNewIntent(intent: Intent?) {
super.onNewIntent(intent)
if (intent?.getBooleanExtra(START_AT_ROOT, false) == true) {
if (this::navController.isInitialized) {
navController.popBackStack(route = "main", inclusive = false)
}
navController.popBackStack(route = "main", inclusive = false)
}
}
private fun login(urlString: String) {
// Launch coroutine to listen for state changes. When the user completes login, relaunch
// MainActivity to bring the app back to focus.
App.get().applicationScope.launch {
App.getApplication().applicationScope.launch {
try {
Notifier.state.collect { state ->
if (state > Ipn.State.NeedsMachineAuth) {
@ -324,25 +295,45 @@ class MainActivity : ComponentActivity() {
super.onResume()
val restrictionsManager =
this.getSystemService(Context.RESTRICTIONS_SERVICE) as RestrictionsManager
lifecycleScope.launch(Dispatchers.IO) { MDMSettings.update(App.get(), restrictionsManager) }
lifecycleScope.launch(Dispatchers.IO) {
MDMSettings.update(App.getApplication(), restrictionsManager)
}
}
override fun onStart() {
super.onStart()
// (jonathan) TODO: Requesting VPN permissions onStart is a bit aggressive. This should
// be done when the user initiall starts the VPN
requestVpnPermission()
}
override fun onStop() {
super.onStop()
val restrictionsManager =
this.getSystemService(Context.RESTRICTIONS_SERVICE) as RestrictionsManager
lifecycleScope.launch(Dispatchers.IO) { MDMSettings.update(App.get(), restrictionsManager) }
lifecycleScope.launch(Dispatchers.IO) {
MDMSettings.update(App.getApplication(), restrictionsManager)
}
}
private fun requestVpnPermission() {
val vpnIntent = VpnService.prepare(this)
if (vpnIntent != null) {
val contract = VpnPermissionContract()
requestVpnPermission =
registerForActivityResult(contract) { granted ->
Log.i("VPN", "VPN permission ${if (granted) "granted" else "denied"}")
}
requestVpnPermission.launch(Unit)
}
}
private fun openApplicationSettings() {
val intent =
Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS).apply {
putExtra(Settings.EXTRA_APP_PACKAGE, packageName)
}
Intent(
Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
Uri.fromParts("package", packageName, null))
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
startActivity(intent)
}
@ -359,9 +350,9 @@ class MainActivity : ComponentActivity() {
}
}
class VpnPermissionContract : ActivityResultContract<Intent, Boolean>() {
override fun createIntent(context: Context, input: Intent): Intent {
return input
class VpnPermissionContract : ActivityResultContract<Unit, Boolean>() {
override fun createIntent(context: Context, input: Unit): Intent {
return VpnService.prepare(context) ?: Intent()
}
override fun parseResult(resultCode: Int, intent: Intent?): Boolean {

@ -0,0 +1,28 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn;
import android.app.Fragment;
import android.content.Intent;
public class Peer extends Fragment {
private static int resultOK = -1;
public class RequestCodes {
public static final int requestPrepareVPN = 1001;
}
@Override
public void onActivityResult(int requestCode, int resultCode, Intent data) {
if (requestCode == RequestCodes.requestPrepareVPN) {
if (resultCode == resultOK) {
App.getApplication().startVPN();
} else {
App.getApplication().setWantRunning(false);
// notify VPN revoked
}
}
}
}

@ -3,44 +3,46 @@
package com.tailscale.ipn;
import android.app.PendingIntent;
import android.content.Context;
import android.content.Intent;
import android.os.Build;
import android.service.quicksettings.Tile;
import android.service.quicksettings.TileService;
public class QuickToggleService extends TileService {
// lock protects the static fields below it.
private static final Object lock = new Object();
// isRunning tracks whether the VPN is running.
private static boolean isRunning;
// Active tracks whether the VPN is active.
private static boolean active;
// Ready tracks whether the tailscale backend is
// ready to switch on/off.
private static boolean ready;
// currentTile tracks getQsTile while service is listening.
private static Tile currentTile;
public static void updateTile() {
var app = UninitializedApp.get();
private static void updateTile() {
Tile t;
boolean act;
synchronized (lock) {
t = currentTile;
act = isRunning && app.isAbleToStartVPN();
act = active && ready;
}
if (t == null) {
return;
}
t.setLabel("Tailscale");
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
t.setSubtitle(act ? app.getString(R.string.connected) : app.getString(R.string.not_connected));
}
t.setState(act ? Tile.STATE_ACTIVE : Tile.STATE_INACTIVE);
t.updateTile();
}
static void setVPNRunning(boolean running) {
static void setReady(Context ctx, boolean rdy) {
synchronized (lock) {
isRunning = running;
ready = rdy;
}
updateTile();
}
static void setStatus(Context ctx, boolean act) {
synchronized (lock) {
active = act;
}
updateTile();
}
@ -64,34 +66,25 @@ public class QuickToggleService extends TileService {
public void onClick() {
boolean r;
synchronized (lock) {
r = UninitializedApp.get().isAbleToStartVPN();
r = ready;
}
if (r) {
// Get the application to make sure it initializes
App.get();
onTileClick();
} else {
// Start main activity.
Intent i = getPackageManager().getLaunchIntentForPackage(getPackageName());
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
// Request code for opening activity.
startActivityAndCollapse(PendingIntent.getActivity(this, 0, i, PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE));
} else {
startActivityAndCollapse(i);
}
startActivityAndCollapse(i);
}
}
private void onTileClick() {
UninitializedApp app = UninitializedApp.get();
boolean needsToStop;
boolean act;
synchronized (lock) {
needsToStop = app.isAbleToStartVPN() && isRunning;
}
if (needsToStop) {
app.stopVPN();
} else {
app.startVPN();
act = active && ready;
}
Intent i = new Intent(act ? IPNReceiver.INTENT_DISCONNECT_VPN : IPNReceiver.INTENT_CONNECT_VPN);
i.setPackage(getPackageName());
i.setClass(getApplicationContext(), com.tailscale.ipn.IPNReceiver.class);
sendBroadcast(i);
}
}

@ -14,7 +14,9 @@ import androidx.activity.compose.setContent
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.ui.Modifier
import androidx.lifecycle.lifecycleScope
import com.tailscale.ipn.ui.model.Ipn
import com.tailscale.ipn.ui.notifier.Notifier
import com.tailscale.ipn.ui.theme.AppTheme
import com.tailscale.ipn.ui.util.set
import com.tailscale.ipn.ui.util.universalFit
@ -43,11 +45,15 @@ class ShareActivity : ComponentActivity() {
override fun onStart() {
super.onStart()
// Ensure our app instance is initialized
App.get()
Notifier.start(lifecycleScope)
loadFiles()
}
override fun onStop() {
super.onStop()
Notifier.stop()
}
override fun onNewIntent(intent: Intent?) {
super.onNewIntent(intent)
setIntent(intent)

@ -11,53 +11,56 @@ import android.content.Intent;
import android.net.VpnService;
import android.os.Build;
import androidx.annotation.NonNull;
import androidx.work.Worker;
import androidx.work.WorkerParameters;
/**
* A worker that exists to support IPNReceiver.
*/
public final class StartVPNWorker extends Worker {
public StartVPNWorker(Context appContext, WorkerParameters workerParams) {
public StartVPNWorker(
Context appContext,
WorkerParameters workerParams) {
super(appContext, workerParams);
}
@NonNull
@Override
public Result doWork() {
UninitializedApp app = UninitializedApp.get();
boolean ableToStartVPN = app.isAbleToStartVPN();
if (ableToStartVPN) {
if (VpnService.prepare(app) == null) {
// We're ready and have permissions, start the VPN
app.startVPN();
return Result.success();
}
}
App app = ((App) getApplicationContext());
// We will start the VPN from the background
app.setAutoConnect(true);
// We need to make sure we prepare the VPN Service, just in case it isn't prepared.
// 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.");
Intent intent = VpnService.prepare(app);
if (intent == null) {
// If null then the VPN is already prepared and/or it's just been prepared because we have permission
app.startVPN();
return Result.success();
} else {
// This VPN possibly doesn't have permission, we need to display a notification which when clicked launches the intent provided.
android.util.Log.e("StartVPNWorker", "Tailscale doesn't have permission from the system to start VPN. Launching the intent provided.");
// Send notification
NotificationManager notificationManager = (NotificationManager) app.getSystemService(Context.NOTIFICATION_SERVICE);
String channelId = "start_vpn_channel";
// Send notification
NotificationManager notificationManager = (NotificationManager) app.getSystemService(Context.NOTIFICATION_SERVICE);
String channelId = "start_vpn_channel";
// Use createNotificationChannel method from App.java
app.createNotificationChannel(channelId, getApplicationContext().getString(R.string.vpn_start), getApplicationContext().getString(R.string.notifications_delivered_when_user_interaction_is_required_to_establish_the_vpn_tunnel), NotificationManager.IMPORTANCE_HIGH);
// Use createNotificationChannel method from App.java
app.createNotificationChannel(channelId, "Start VPN Channel", NotificationManager.IMPORTANCE_DEFAULT);
// Use prepareIntent if available.
Intent intent = app.getPackageManager().getLaunchIntentForPackage(app.getPackageName());
assert intent != null;
intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
int pendingIntentFlags = PendingIntent.FLAG_ONE_SHOT | (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S ? PendingIntent.FLAG_IMMUTABLE : 0);
PendingIntent pendingIntent = PendingIntent.getActivity(app, 0, intent, pendingIntentFlags);
intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
int pendingIntentFlags = PendingIntent.FLAG_ONE_SHOT | (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S ? PendingIntent.FLAG_IMMUTABLE : 0);
PendingIntent pendingIntent = PendingIntent.getActivity(app, 0, intent, pendingIntentFlags);
Notification notification = new Notification.Builder(app, channelId).setContentTitle(app.getString(R.string.title_connection_failed)).setContentText(app.getString(R.string.body_open_tailscale)).setSmallIcon(R.drawable.ic_notification).setContentIntent(pendingIntent).setAutoCancel(true).build();
Notification notification = new Notification.Builder(app, channelId)
.setContentTitle("Tailscale Connection Failed")
.setContentText("Tap here to renew permission.")
.setSmallIcon(R.drawable.ic_notification)
.setContentIntent(pendingIntent)
.setAutoCancel(true)
.build();
notificationManager.notify(1, notification);
notificationManager.notify(1, notification);
return Result.failure();
return Result.failure();
}
}
}

@ -5,13 +5,9 @@ package com.tailscale.ipn;
import android.content.Context;
import androidx.annotation.NonNull;
import androidx.work.Worker;
import androidx.work.WorkerParameters;
/**
* A worker that exists to support IPNReceiver.
*/
public final class StopVPNWorker extends Worker {
public StopVPNWorker(
@ -20,10 +16,9 @@ public final class StopVPNWorker extends Worker {
super(appContext, workerParams);
}
@NonNull
@Override
public Result doWork() {
UninitializedApp.get().stopVPN();
App.getApplication().setWantRunning(false);
return Result.success();
}
}

@ -1,112 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import androidx.core.app.NotificationCompat
import androidx.work.CoroutineWorker
import androidx.work.Data
import androidx.work.WorkerParameters
import com.tailscale.ipn.UninitializedApp.Companion.STATUS_CHANNEL_ID
import com.tailscale.ipn.UninitializedApp.Companion.STATUS_EXIT_NODE_FAILURE_NOTIFICATION_ID
import com.tailscale.ipn.ui.localapi.Client
import com.tailscale.ipn.ui.model.Ipn
import com.tailscale.ipn.ui.notifier.Notifier
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
class UseExitNodeWorker(
appContext: Context,
workerParams: WorkerParameters
) : CoroutineWorker(appContext, workerParams) {
override suspend fun doWork(): Result {
val app = UninitializedApp.get()
suspend fun runAndGetResult(): String? {
val exitNodeName = inputData.getString(EXIT_NODE_NAME)
val exitNodeId = if (exitNodeName.isNullOrEmpty()) {
null
} else {
if (!app.isAbleToStartVPN()) {
return app.getString(R.string.vpn_is_not_ready_to_start)
}
val peers =
(Notifier.netmap.value
?: run { return@runAndGetResult app.getString(R.string.tailscale_is_not_setup) })
.Peers ?: run { return@runAndGetResult app.getString(R.string.no_peers_found) }
val filteredPeers = peers.filter {
it.displayName == exitNodeName
}.toList()
if (filteredPeers.isEmpty()) {
return app.getString(R.string.no_peers_with_name_found, exitNodeName)
} else if (filteredPeers.size > 1) {
return app.getString(R.string.multiple_peers_with_name_found, exitNodeName)
} else if (!filteredPeers[0].isExitNode) {
return app.getString(
R.string.peer_with_name_is_not_an_exit_node,
exitNodeName
)
}
filteredPeers[0].StableID
}
val allowLanAccess = inputData.getBoolean(ALLOW_LAN_ACCESS, false)
val prefsOut = Ipn.MaskedPrefs()
prefsOut.ExitNodeID = exitNodeId
prefsOut.ExitNodeAllowLANAccess = allowLanAccess
val scope = CoroutineScope(Dispatchers.Default + Job())
var result: String? = null
Client(scope).editPrefs(prefsOut) {
result = if (it.isFailure) {
it.exceptionOrNull()?.message
} else {
null
}
}
scope.coroutineContext[Job]?.join()
return result
}
val result = runAndGetResult()
return if (result != null) {
val intent =
Intent(app, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
}
val pendingIntent: PendingIntent =
PendingIntent.getActivity(
app, 1, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
val notification = NotificationCompat.Builder(app, STATUS_CHANNEL_ID)
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle(app.getString(R.string.use_exit_node_intent_failed))
.setContentText(result)
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
.setContentIntent(pendingIntent)
.build()
app.notifyStatus(notification)
Result.failure(Data.Builder().putString(ERROR_KEY, result).build())
} else {
Result.success()
}
}
companion object {
const val EXIT_NODE_NAME = "EXIT_NODE_NAME"
const val ALLOW_LAN_ACCESS = "ALLOW_LAN_ACCESS"
const val ERROR_KEY = "error"
}
}

@ -5,8 +5,6 @@ package com.tailscale.ipn
import android.net.VpnService
import libtailscale.ParcelFileDescriptor
import java.net.InetAddress
import android.net.IpPrefix as AndroidIpPrefix
class VPNServiceBuilder(private val builder: VpnService.Builder) : libtailscale.VPNServiceBuilder {
override fun addAddress(p0: String, p1: Int) {
@ -21,12 +19,6 @@ class VPNServiceBuilder(private val builder: VpnService.Builder) : libtailscale.
builder.addRoute(p0, p1)
}
override fun excludeRoute(p0: String, p1: Int) {
val inetAddress = InetAddress.getByName(p0)
val prefix = AndroidIpPrefix(inetAddress, p1)
builder.excludeRoute(prefix)
}
override fun addSearchDomain(p0: String) {
builder.addSearchDomain(p0)
}

@ -11,78 +11,42 @@ import kotlin.reflect.full.isSubclassOf
import kotlin.reflect.jvm.jvmErasure
object MDMSettings {
// The String message used in this NoSuchKeyException must match the value of
// syspolicy.ErrNoSuchKey defined in Go, since the backend checks the value
// returned by the handler for equality using errors.Is().
class NoSuchKeyException : Exception("no such key")
val forceEnabled = BooleanMDMSetting("ForceEnabled", "Force Enabled Connection Toggle")
// Handled on the backed
val exitNodeID = StringMDMSetting("ExitNodeID", "Forced Exit Node: Stable ID")
// (jonathan) TODO: Unused but required. There is some funky go string duration parsing required
// here.
val keyExpirationNotice = StringMDMSetting("KeyExpirationNotice", "Key Expiration Notice Period")
val loginURL = StringMDMSetting("LoginURL", "Custom control server URL")
val managedByCaption = StringMDMSetting("ManagedByCaption", "Managed By - Caption")
val managedByOrganizationName =
StringMDMSetting("ManagedByOrganizationName", "Managed By - Organization Name")
val managedByURL = StringMDMSetting("ManagedByURL", "Managed By - Support URL")
// Handled on the backend
val tailnet = StringMDMSetting("Tailnet", "Recommended/Required Tailnet Name")
val hiddenNetworkDevices =
StringArrayListMDMSetting("HiddenNetworkDevices", "Hidden Network Device Categories")
// Unused on Android
val allowIncomingConnections =
AlwaysNeverUserDecidesMDMSetting("AllowIncomingConnections", "Allow Incoming Connections")
// Unused on Android
val detectThirdPartyAppConflicts =
AlwaysNeverUserDecidesMDMSetting(
"DetectThirdPartyAppConflicts", "Detect potentially problematic third-party apps")
val exitNodeAllowLANAccess =
AlwaysNeverUserDecidesMDMSetting(
"ExitNodeAllowLANAccess", "Allow LAN Access when using an exit node")
// Handled on the backend
val postureChecking =
AlwaysNeverUserDecidesMDMSetting("PostureChecking", "Enable Posture Checking")
val useTailscaleDNSSettings =
AlwaysNeverUserDecidesMDMSetting("UseTailscaleDNSSettings", "Use Tailscale DNS Settings")
// Unused on Android
val useTailscaleSubnets =
AlwaysNeverUserDecidesMDMSetting("UseTailscaleSubnets", "Use Tailscale Subnets")
val exitNodesPicker = ShowHideMDMSetting("ExitNodesPicker", "Exit Nodes Picker")
val manageTailnetLock = ShowHideMDMSetting("ManageTailnetLock", "“Manage Tailnet lock” menu item")
// Unused on Android
val resetToDefaults = ShowHideMDMSetting("ResetToDefaults", "“Reset to Defaults” menu item")
val runExitNode = ShowHideMDMSetting("RunExitNode", "Run as Exit Node")
// Unused on Android
val testMenu = ShowHideMDMSetting("TestMenu", "Show Debug Menu")
// Unused on Android
val updateMenu = ShowHideMDMSetting("UpdateMenu", "“Update Available” menu item")
// (jonathan) TODO: Use this when suggested exit nodes are implemented
val allowedSuggestedExitNodes =
StringArrayListMDMSetting("AllowedSuggestedExitNodes", "Allowed Suggested Exit Nodes")
val allSettings by lazy {
MDMSettings::class
.declaredMemberProperties

@ -44,7 +44,7 @@ class AlwaysNeverUserDecidesMDMSetting(key: String, localizedTitle: String) :
override fun getFrom(bundle: Bundle?, app: App): AlwaysNeverUserDecides {
val storedString =
bundle?.getString(key)
?: App.get().getEncryptedPrefs().getString(key, null)
?: App.getApplication().getEncryptedPrefs().getString(key, null)
?: "user-decides"
return when (storedString) {
"always" -> {
@ -64,7 +64,9 @@ class ShowHideMDMSetting(key: String, localizedTitle: String) :
MDMSetting<ShowHide>(ShowHide.Show, key, localizedTitle) {
override fun getFrom(bundle: Bundle?, app: App): ShowHide {
val storedString =
bundle?.getString(key) ?: App.get().getEncryptedPrefs().getString(key, null) ?: "show"
bundle?.getString(key)
?: App.getApplication().getEncryptedPrefs().getString(key, null)
?: "show"
return when (storedString) {
"hide" -> {
ShowHide.Hide
@ -79,12 +81,7 @@ class ShowHideMDMSetting(key: String, localizedTitle: String) :
enum class AlwaysNeverUserDecides(val value: String) {
Always("always"),
Never("never"),
UserDecides("user-decides");
val hiddenFromUser: Boolean
get() {
return this != UserDecides
}
UserDecides("user-decides")
}
enum class ShowHide(val value: String) {

@ -46,7 +46,6 @@ private object Endpoint {
const val FILES = "files"
const val FILE_PUT = "file-put"
const val TAILFS_SERVER_ADDRESS = "tailfs/fileserver-address"
const val ENABLE_EXIT_NODE = "set-use-exit-node-enabled"
}
typealias StatusResponseHandler = (Result<IpnState.Status>) -> Unit
@ -86,11 +85,6 @@ class Client(private val scope: CoroutineScope) {
return patch(Endpoint.PREFS, body, responseHandler = responseHandler)
}
fun setUseExitNode(use: Boolean, responseHandler: (Result<Ipn.Prefs>) -> Unit) {
val path = "${Endpoint.ENABLE_EXIT_NODE}?enabled=$use"
return post(path, responseHandler = responseHandler)
}
fun profiles(responseHandler: (Result<List<IpnLocal.LoginProfile>>) -> Unit) {
get(Endpoint.PROFILES, responseHandler = responseHandler)
}

@ -1,38 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn.ui.model
import kotlinx.serialization.Serializable
class Health {
@Serializable
data class State(
// WarnableCode -> UnhealthyState or null
var Warnings: Map<String, UnhealthyState?>? = null,
)
@Serializable
data class UnhealthyState(
var WarnableCode: String,
var Severity: Severity,
var Title: String,
var Text: String,
var BrokenSince: String? = null,
var Args: Map<String, String>? = null,
var DependsOn: List<String>? = null, // an array of WarnableCodes this depends on
) {
fun hiddenByDependencies(currentWarnableCodes: Set<String>): Boolean {
return this.DependsOn?.let {
it.any { depWarnableCode -> currentWarnableCodes.contains(depWarnableCode) }
} == true
}
}
@Serializable
enum class Severity {
high,
medium,
low
}
}

@ -46,7 +46,6 @@ class Ipn {
var IncomingFiles: List<PartialFile>? = null,
var ClientVersion: Tailcfg.ClientVersion? = null,
var TailFSShares: List<String>? = null,
var Health: Health.State? = null,
)
@Serializable
@ -66,22 +65,7 @@ class Ipn {
var ForceDaemon: Boolean = false,
var HostName: String = "",
var AutoUpdate: AutoUpdatePrefs? = AutoUpdatePrefs(true, true),
var InternalExitNodePrior: String? = null,
) {
// For the InternalExitNodePrior and ExitNodeId, these will treats the empty string as null to
// simplify the downstream logic.
val selectedExitNodeID: String?
get() {
return if (InternalExitNodePrior.isNullOrEmpty()) null else InternalExitNodePrior
}
val activeExitNodeID: String?
get() {
return if (ExitNodeID.isNullOrEmpty()) null else ExitNodeID
}
}
)
@Serializable
data class MaskedPrefs(
@ -95,7 +79,6 @@ class Ipn {
var AdvertiseRoutesSet: Boolean? = null,
var ForceDaemonSet: Boolean? = null,
var HostnameSet: Boolean? = null,
var InternalExitNodePriorSet: Boolean? = null,
) {
var ControlURL: String? = null
@ -122,12 +105,6 @@ class Ipn {
ExitNodeIDSet = true
}
var InternalExitNodePrior: String? = null
set(value) {
field = value
InternalExitNodePriorSet = true
}
var ExitNodeAllowLANAccess: Boolean? = null
set(value) {
field = value
@ -217,8 +194,7 @@ class Ipn {
@Serializable
data class Options(
var FrontendLogID: String? = null,
var UpdatePrefs: Prefs? = null,
var AuthKey: String? = null,
var Prefs: Prefs? = null,
)
}

@ -7,13 +7,11 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import com.tailscale.ipn.R
import com.tailscale.ipn.ui.Links
import com.tailscale.ipn.ui.theme.off
import com.tailscale.ipn.ui.theme.on
import com.tailscale.ipn.ui.util.ComposableStringFormatter
import com.tailscale.ipn.ui.util.DisplayAddress
import com.tailscale.ipn.ui.util.TimeUtil
import com.tailscale.ipn.ui.util.flag
import com.tailscale.ipn.ui.viewModel.PeerSettingInfo
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonElement
@ -93,16 +91,6 @@ class Tailcfg {
Capabilities?.contains("https://tailscale.com/cap/is-admin") == true ||
CapMap?.contains("https://tailscale.com/cap/is-admin") == true
// Derives the url to directly administer a node
val nodeAdminUrl: String
get() = primaryIPv4Address?.let { "${Links.ADMIN_URL}/machines/${it}" } ?: Links.ADMIN_URL
val primaryIPv4Address: String?
get() = displayAddresses.firstOrNull { it.type == DisplayAddress.addrType.V4 }?.address
val primaryIPv6Address: String?
get() = displayAddresses.firstOrNull { it.type == DisplayAddress.addrType.V6 }?.address
// isExitNode reproduces the Go logic in local.go peerStatusFromNode
val isExitNode: Boolean =
AllowedIPs?.contains("0.0.0.0/0") ?: false && AllowedIPs?.contains("::/0") ?: false
@ -113,20 +101,6 @@ class Tailcfg {
val displayName: String
get() = ComputedName ?: Name
val exitNodeName: String
get() {
if (isMullvadNode &&
Hostinfo.Location?.Country != null &&
Hostinfo.Location?.City != null &&
Hostinfo.Location?.CountryCode != null) {
return "${Hostinfo.Location!!.CountryCode!!.flag()} ${Hostinfo.Location!!.Country!!}: ${Hostinfo.Location!!.City!!}"
}
return displayName
}
val keyDoesNotExpire: Boolean
get() = KeyExpiry == "0001-01-01T00:00:00Z"
fun isSelfNode(netmap: Netmap.NetworkMap): Boolean = StableID == netmap.SelfNode.StableID
fun connectedOrSelfNode(nm: Netmap.NetworkMap?) =
@ -157,13 +131,7 @@ class Tailcfg {
PeerSettingInfo(R.string.os, ComposableStringFormatter(Hostinfo.OS!!)),
)
}
if (keyDoesNotExpire) {
result.add(
PeerSettingInfo(
R.string.key_expiry, ComposableStringFormatter(R.string.deviceKeyNeverExpires)))
} else {
result.add(PeerSettingInfo(R.string.key_expiry, TimeUtil.keyExpiryFromGoTime(KeyExpiry)))
}
result.add(PeerSettingInfo(R.string.key_expiry, TimeUtil.keyExpiryFromGoTime(KeyExpiry)))
return result
}

@ -1,117 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn.ui.notifier
import android.Manifest
import android.content.pm.PackageManager
import android.util.Log
import androidx.core.app.ActivityCompat
import androidx.core.app.NotificationCompat
import com.tailscale.ipn.App
import com.tailscale.ipn.R
import com.tailscale.ipn.UninitializedApp.Companion.notificationManager
import com.tailscale.ipn.ui.model.Health
import com.tailscale.ipn.ui.model.Health.UnhealthyState
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.launch
@OptIn(FlowPreview::class)
class HealthNotifier(
healthStateFlow: StateFlow<Health.State?>,
scope: CoroutineScope,
) {
companion object {
const val HEALTH_CHANNEL_ID = "tailscale-health"
}
private val TAG = "Health"
private val ignoredWarnableCodes: Set<String> =
setOf(
// Ignored on Android because installing unstable takes quite some effort
"is-using-unstable-version",
// Ignored on Android because we already have a dedicated connected/not connected
// notification
"wantrunning-false")
init {
scope.launch {
healthStateFlow
.distinctUntilChanged { old, new -> old?.Warnings?.count() == new?.Warnings?.count() }
.debounce(5000)
.collect { health ->
Log.d(TAG, "Health updated: ${health?.Warnings?.keys?.sorted()}")
health?.Warnings?.let {
notifyHealthUpdated(it.values.mapNotNull { it }.toTypedArray())
}
}
}
}
private val currentWarnings: MutableSet<String> = mutableSetOf()
private fun notifyHealthUpdated(warnings: Array<UnhealthyState>) {
val warningsBeforeAdd = currentWarnings
val currentWarnableCodes = warnings.map { it.WarnableCode }.toSet()
val addedWarnings: MutableSet<String> = mutableSetOf()
for (warning in warnings) {
if (ignoredWarnableCodes.contains(warning.WarnableCode)) {
continue
}
addedWarnings.add(warning.WarnableCode)
if (this.currentWarnings.contains(warning.WarnableCode)) {
// Already notified, skip
continue
} else if (warning.hiddenByDependencies(currentWarnableCodes)) {
// Ignore this warning because a dependency is also unhealthy
Log.d(TAG, "Ignoring ${warning.WarnableCode} because of dependency")
continue
} else {
Log.d(TAG, "Adding health warning: ${warning.WarnableCode}")
this.currentWarnings.add(warning.WarnableCode)
this.sendNotification(warning.Title, warning.Text, warning.WarnableCode)
}
}
val warningsToDrop = warningsBeforeAdd.minus(addedWarnings)
if (warningsToDrop.isNotEmpty()) {
Log.d(TAG, "Dropping health warnings with codes $warningsToDrop")
this.removeNotifications(warningsToDrop)
}
currentWarnings.subtract(warningsToDrop)
}
private fun sendNotification(title: String, text: String, code: String) {
Log.d(TAG, "Sending notification for $code")
val notification =
NotificationCompat.Builder(App.get().applicationContext, HEALTH_CHANNEL_ID)
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle(title)
.setContentText(text)
.setStyle(NotificationCompat.BigTextStyle().bigText(text))
.setPriority(NotificationCompat.PRIORITY_HIGH)
.build()
if (ActivityCompat.checkSelfPermission(
App.get().applicationContext, Manifest.permission.POST_NOTIFICATIONS) !=
PackageManager.PERMISSION_GRANTED) {
Log.d(TAG, "Notification permission not granted")
return
}
notificationManager.notify(code.hashCode(), notification)
}
private fun removeNotifications(codes: Set<String>) {
Log.d(TAG, "Removing notifications for $codes")
for (code in codes) {
notificationManager.cancel(code.hashCode())
}
}
}

@ -4,9 +4,7 @@
package com.tailscale.ipn.ui.notifier
import android.util.Log
import com.tailscale.ipn.App
import com.tailscale.ipn.ui.model.Empty
import com.tailscale.ipn.ui.model.Health
import com.tailscale.ipn.ui.model.Ipn
import com.tailscale.ipn.ui.model.Ipn.Notify
import com.tailscale.ipn.ui.model.Netmap
@ -32,6 +30,10 @@ object Notifier {
private val TAG = Notifier::class.simpleName
private val decoder = Json { ignoreUnknownKeys = true }
// Global App State
val tileReady: StateFlow<Boolean> = MutableStateFlow(false)
val readyToPrepareVPN: StateFlow<Boolean> = MutableStateFlow(false)
// General IPN Bus State
val state: StateFlow<Ipn.State> = MutableStateFlow(Ipn.State.NoState)
val netmap: StateFlow<Netmap.NetworkMap?> = MutableStateFlow(null)
@ -41,7 +43,6 @@ object Notifier {
val browseToURL: StateFlow<String?> = MutableStateFlow(null)
val loginFinished: StateFlow<String?> = MutableStateFlow(null)
val version: StateFlow<String?> = MutableStateFlow(null)
val health: StateFlow<Health.State?> = MutableStateFlow(null)
// Taildrop-specific State
val outgoingFiles: StateFlow<List<Ipn.OutgoingFile>?> = MutableStateFlow(null)
@ -59,15 +60,11 @@ object Notifier {
@OptIn(ExperimentalSerializationApi::class)
fun start(scope: CoroutineScope) {
Log.d(TAG, "Starting")
if (!::app.isInitialized) {
App.get()
}
scope.launch(Dispatchers.IO) {
val mask =
NotifyWatchOpt.Netmap.value or
NotifyWatchOpt.Prefs.value or
NotifyWatchOpt.InitialState.value or
NotifyWatchOpt.InitialHealthState.value
NotifyWatchOpt.InitialState.value
manager =
app.watchNotifications(mask.toLong()) { notification ->
val notify = decoder.decodeFromStream<Notify>(notification.inputStream())
@ -82,8 +79,11 @@ object Notifier {
notify.OutgoingFiles?.let(outgoingFiles::set)
notify.FilesWaiting?.let(filesWaiting::set)
notify.IncomingFiles?.let(incomingFiles::set)
notify.Health?.let(health::set)
}
state.collect { currstate ->
readyToPrepareVPN.set(currstate > Ipn.State.Stopped)
tileReady.set(currstate >= Ipn.State.Stopped)
}
}
}
@ -103,8 +103,6 @@ object Notifier {
Prefs(4),
Netmap(8),
NoPrivateKey(16),
InitialTailFSShares(32),
InitialOutgoingFiles(64),
InitialHealthState(128),
InitialTailFSShares(32)
}
}

@ -16,7 +16,6 @@ import androidx.compose.material3.TextFieldColors
import androidx.compose.material3.TopAppBarColors
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.Typography
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
@ -28,12 +27,7 @@ import com.google.accompanist.systemuicontroller.rememberSystemUiController
@Composable
fun AppTheme(useDarkTheme: Boolean = isSystemInDarkTheme(), content: @Composable() () -> Unit) {
val colors =
if (useDarkTheme) {
DarkColors
} else {
LightColors
}
val colors = LightColors
val typography =
Typography(
@ -84,43 +78,8 @@ private val LightColors =
scrim = Color(0xAA000000), // black
)
private val DarkColors =
darkColorScheme(
primary = Color(0xFF3E5DB3), // blue-600
onPrimary = Color(0xFFFFFFFF), // white
primaryContainer = Color(0xFFf0f5ff), // blue-0
onPrimaryContainer = Color(0xFF5A82DC), // blue-400
error = Color(0xFFEF5350), // red-400
onError = Color(0xFFFFFFFF), // white
errorContainer = Color(0xFFfff6f4), // red-0
onErrorContainer = Color(0xFF940822), // red-600
surfaceDim = Color(0xFF1f1e1e), // gray-900
surface = Color(0xFF232222), // gray-800
background = Color(0xFF181717), // gray-1000
surfaceBright = Color(0xFF444342), // gray-600
surfaceContainerLowest = Color(0xFF1f1e1e), // gray-900
surfaceContainerLow = Color(0xFF232222), // gray-800
surfaceContainer = Color(0xFF181717), // gray-1000
surfaceContainerHigh = Color(0xFF232222), // gray-800
surfaceContainerHighest = Color(0xFF2e2d2d), // gray-700
surfaceVariant = Color(0xFF1f1e1e), // gray-900
onSurface = Color(0xFFfaf9f8), // gray-0
onSurfaceVariant = Color(0xFFafacab), // gray-400
outline = Color(0xFF706E6D), // gray-500
outlineVariant = Color(0xFF2E2D2D), // gray-700
inverseSurface = Color(0xFFEDEBEA), // gray-200
inverseOnSurface = Color(0xFF000000), // black
scrim = Color(0xAA000000), // black
)
val ColorScheme.warning: Color
@Composable
get() =
if (isSystemInDarkTheme()) {
Color(0xFFBB5504) // yellow-400
} else {
Color(0xFFD97917) // yellow-300
}
get() = Color(0xFFD97916) // yellow-300
val ColorScheme.onWarning: Color
get() = Color(0xFFFFFFFF) // white
@ -147,35 +106,11 @@ val ColorScheme.on: Color
get() = Color(0xFF1CA672) // green-300
val ColorScheme.off: Color
@Composable
get() =
if (isSystemInDarkTheme()) {
Color(0xFF444342) // gray-600
} else {
Color(0xFFD9D6D5) // gray-300
}
get() = Color(0xFFD9D6D5) // gray-300
val ColorScheme.link: Color
get() = onPrimaryContainer
val ColorScheme.customError: Color
@Composable
get() =
if (isSystemInDarkTheme()) {
Color(0xFF940821) // red-600
} else {
Color(0xFFB22D30) // red-500
}
val ColorScheme.customErrorContainer: Color
@Composable
get() =
if (isSystemInDarkTheme()) {
Color(0xFF760012) // red-700
} else {
Color(0xFF940821) // red-600
}
/**
* Main color scheme for list items, uses onPrimaryContainer color for leading and trailing icons.
*/
@ -272,24 +207,7 @@ val ColorScheme.warningListItem: ListItemColors
containerColor = MaterialTheme.colorScheme.warning,
headlineColor = MaterialTheme.colorScheme.onPrimary,
leadingIconColor = MaterialTheme.colorScheme.onPrimary,
overlineColor = MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.8f),
supportingTextColor = MaterialTheme.colorScheme.onPrimary,
trailingIconColor = MaterialTheme.colorScheme.onPrimary,
disabledHeadlineColor = default.disabledHeadlineColor,
disabledLeadingIconColor = default.disabledLeadingIconColor,
disabledTrailingIconColor = default.disabledTrailingIconColor)
}
/** Color scheme for list items that should be styled as an error item. */
val ColorScheme.errorListItem: ListItemColors
@Composable
get() {
val default = ListItemDefaults.colors()
return ListItemColors(
containerColor = MaterialTheme.colorScheme.customError,
headlineColor = MaterialTheme.colorScheme.onPrimary,
leadingIconColor = MaterialTheme.colorScheme.onPrimary,
overlineColor = MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.8f),
overlineColor = MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.7f),
supportingTextColor = MaterialTheme.colorScheme.onPrimary,
trailingIconColor = MaterialTheme.colorScheme.onPrimary,
disabledHeadlineColor = default.disabledHeadlineColor,
@ -313,130 +231,11 @@ val ColorScheme.secondaryButton: ButtonColors
@Composable
get() {
val defaults = ButtonDefaults.buttonColors()
if (isSystemInDarkTheme()) {
return ButtonColors(
containerColor = Color(0xFF4B70CC), // blue-500
contentColor = Color(0xFFFFFFFF), // white
disabledContainerColor = defaults.disabledContainerColor,
disabledContentColor = defaults.disabledContentColor)
} else {
return ButtonColors(
containerColor = Color(0xFF5A82DC), // blue-400
contentColor = Color(0xFFFFFFFF), // white
disabledContainerColor = defaults.disabledContainerColor,
disabledContentColor = defaults.disabledContentColor)
}
}
val ColorScheme.errorButton: ButtonColors
@Composable
get() {
val defaults = ButtonDefaults.buttonColors()
if (isSystemInDarkTheme()) {
return ButtonColors(
containerColor = Color(0xFFB22D30), // red-500
contentColor = Color(0xFFFFFFFF), // white
disabledContainerColor = defaults.disabledContainerColor,
disabledContentColor = defaults.disabledContentColor)
} else {
return ButtonColors(
containerColor = Color(0xFFD04841), // red-400
contentColor = Color(0xFFFFFFFF), // white
disabledContainerColor = defaults.disabledContainerColor,
disabledContentColor = defaults.disabledContentColor)
}
}
val ColorScheme.warningButton: ButtonColors
@Composable
get() {
val defaults = ButtonDefaults.buttonColors()
if (isSystemInDarkTheme()) {
return ButtonColors(
containerColor = Color(0xFFD97917), // yellow-300
contentColor = Color(0xFFFFFFFF), // white
disabledContainerColor = defaults.disabledContainerColor,
disabledContentColor = defaults.disabledContentColor)
} else {
return ButtonColors(
containerColor = Color(0xFFE5993E), // yellow-200
contentColor = Color(0xFFFFFFFF), // white
disabledContainerColor = defaults.disabledContainerColor,
disabledContentColor = defaults.disabledContentColor)
}
}
val ColorScheme.defaultTextColor: Color
@Composable
get() =
if (isSystemInDarkTheme()) {
Color.White
} else {
Color.Black
}
val ColorScheme.logoBackground: Color
@Composable
get() =
if (isSystemInDarkTheme()) {
Color(0xFFFFFFFF) // white
} else {
Color(0xFF1F1E1E)
}
val ColorScheme.standaloneLogoDotEnabled: Color
@Composable
get() =
if (isSystemInDarkTheme()) {
Color(0xFFFFFFFF)
} else {
Color(0xFF000000)
}
val ColorScheme.standaloneLogoDotDisabled: Color
@Composable
get() =
if (isSystemInDarkTheme()) {
Color(0x66FFFFFF)
} else {
Color(0x661F1E1E)
}
val ColorScheme.onBackgroundLogoDotEnabled: Color
@Composable
get() =
if (isSystemInDarkTheme()) {
Color(0xFF141414)
} else {
Color(0xFFFFFFFF)
}
val ColorScheme.onBackgroundLogoDotDisabled: Color
@Composable
get() =
if (isSystemInDarkTheme()) {
Color(0x66141414)
} else {
Color(0x66FFFFFF)
}
val ColorScheme.exitNodeToggleButton: ButtonColors
@Composable
get() {
val defaults = ButtonDefaults.buttonColors()
return if (isSystemInDarkTheme()) {
ButtonColors(
containerColor = Color(0xFF444342), // grey-600
contentColor = Color(0xFFFFFFFF), // white
disabledContainerColor = defaults.disabledContainerColor,
disabledContentColor = defaults.disabledContentColor)
} else {
ButtonColors(
containerColor = Color(0xFFEDEBEA), // grey-300
contentColor = Color(0xFF000000), // black
disabledContainerColor = defaults.disabledContainerColor,
disabledContentColor = defaults.disabledContentColor)
}
return ButtonColors(
containerColor = Color(0xFF6D94EC), // blue-400
contentColor = Color(0xFFFFFFFF), // white
disabledContainerColor = defaults.disabledContainerColor,
disabledContentColor = defaults.disabledContentColor)
}
val ColorScheme.disabled: Color
@ -453,9 +252,9 @@ val ColorScheme.searchBarColors: TextFieldColors
focusedTextColor = MaterialTheme.colorScheme.onSurface,
unfocusedTextColor = MaterialTheme.colorScheme.onSurfaceVariant,
disabledTextColor = MaterialTheme.colorScheme.onSurfaceVariant,
focusedContainerColor = MaterialTheme.colorScheme.surfaceContainer,
unfocusedContainerColor = MaterialTheme.colorScheme.surfaceContainer,
disabledContainerColor = MaterialTheme.colorScheme.surfaceContainer,
focusedContainerColor = MaterialTheme.colorScheme.background,
unfocusedContainerColor = MaterialTheme.colorScheme.background,
disabledContainerColor = MaterialTheme.colorScheme.background,
focusedBorderColor = Color.Transparent,
unfocusedBorderColor = Color.Transparent)
}

@ -1,24 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn.ui.util
import com.tailscale.ipn.ui.model.Ipn
class AdvertisedRoutesHelper {
companion object {
fun exitNodeOnFromPrefs(prefs: Ipn.Prefs): Boolean {
var v4 = false
var v6 = false
prefs.AdvertiseRoutes?.forEach {
if (it == "0.0.0.0/0") {
v4 = true
}
if (it == "::/0") {
v6 = true
}
}
return v4 && v6
}
}
}

@ -9,14 +9,13 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.unit.dp
import com.tailscale.ipn.UninitializedApp
import com.tailscale.ipn.App
import com.tailscale.ipn.ui.util.AndroidTVUtil.isAndroidTV
object AndroidTVUtil {
fun isAndroidTV(): Boolean {
val pm = UninitializedApp.get().packageManager
return (pm.hasSystemFeature(PackageManager.FEATURE_TELEVISION) ||
pm.hasSystemFeature(PackageManager.FEATURE_LEANBACK))
return (App.appInstance.packageManager.hasSystemFeature(PackageManager.FEATURE_TELEVISION) ||
App.appInstance.packageManager.hasSystemFeature(PackageManager.FEATURE_LEANBACK))
}
}

@ -20,21 +20,13 @@ import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.unit.dp
import com.tailscale.ipn.R
import com.tailscale.ipn.ui.theme.titledListItem
import com.tailscale.ipn.ui.util.AndroidTVUtil.isAndroidTV
@Composable
fun ClipboardValueView(value: String, title: String? = null, subtitle: String? = null) {
val localClipboardManager = LocalClipboardManager.current
val modifier =
if (isAndroidTV()) {
Modifier
} else {
Modifier.clickable { localClipboardManager.setText(AnnotatedString(value)) }
}
ListItem(
colors = MaterialTheme.colorScheme.titledListItem,
modifier = modifier,
modifier = Modifier.clickable { localClipboardManager.setText(AnnotatedString(value)) },
overlineContent = title?.let { { Text(it, style = MaterialTheme.typography.titleMedium) } },
headlineContent = { Text(text = value, style = MaterialTheme.typography.bodyMedium) },
supportingContent =

@ -3,7 +3,7 @@
package com.tailscale.ipn.ui.util
class DisplayAddress(ip: String) {
class DisplayAddress(val ip: String) {
enum class addrType {
V4,
V6,

@ -4,7 +4,6 @@
package com.tailscale.ipn.ui.util
import androidx.compose.foundation.background
import androidx.compose.foundation.focusable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
@ -42,8 +41,7 @@ object Lists {
title: String,
bottomPadding: Dp = 0.dp,
style: TextStyle = MaterialTheme.typography.titleMedium,
fontWeight: FontWeight? = null,
focusable: Boolean = false
fontWeight: FontWeight? = null
) {
Box(
modifier =
@ -52,8 +50,7 @@ object Lists {
Text(
title,
modifier =
Modifier.padding(start = 16.dp, end = 16.dp, top = 8.dp, bottom = bottomPadding)
.focusable(focusable),
Modifier.padding(start = 16.dp, end = 16.dp, top = 8.dp, bottom = bottomPadding),
style = style,
fontWeight = fontWeight)
}

@ -12,11 +12,9 @@ import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.produceState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import com.tailscale.ipn.ui.view.TailscaleLogoView
@ -41,7 +39,7 @@ object LoadingIndicator {
contentAlignment = Alignment.Center,
) {
content()
val isLoading by loading.collectAsState()
val isLoading = loading.collectAsState().value
if (isLoading) {
Box(Modifier.clickable {}.matchParentSize().background(Color.Gray.copy(alpha = 0.0f)))
@ -55,8 +53,7 @@ object LoadingIndicator {
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally) {
TailscaleLogoView(
true, usesOnBackgroundColors = false, Modifier.size(72.dp).alpha(0.4f))
TailscaleLogoView(true, Modifier.size(72.dp))
}
}
}

@ -3,8 +3,6 @@
package com.tailscale.ipn.ui.util
import androidx.compose.ui.util.fastAny
import com.tailscale.ipn.mdm.MDMSettings
import com.tailscale.ipn.ui.model.Netmap
import com.tailscale.ipn.ui.model.Tailcfg
import com.tailscale.ipn.ui.model.UserID
@ -21,42 +19,22 @@ class PeerCategorizer {
val selfNode = netmap.SelfNode
var grouped = mutableMapOf<UserID, MutableList<Tailcfg.Node>>()
val mdm = MDMSettings.hiddenNetworkDevices.flow.value
val hideMyDevices = mdm?.contains("current-user") ?: false
val hideOtherDevices = mdm?.contains("other-users") ?: false
val hideTaggedDevices = mdm?.contains("tagged-devices") ?: false
val me = netmap.currentUserProfile()
for (peer in (peers + selfNode)) {
// (jonathan) TODO: MDM -> There are a number of MDM settings to hide devices from the user
// (jonathan) TODO: MDM -> currentUser, otherUsers, taggedDevices
val userId = peer.User
val profile = netmap.userProfile(userId)
// Mullvad nodes should not be shown in the peer list
if (peer.isMullvadNode) {
continue
}
// Hide devices based on MDM settings
if (hideMyDevices && userId == me?.ID) {
continue
}
if (hideOtherDevices && userId != me?.ID) {
continue
}
if (hideTaggedDevices && (profile?.isTaggedDevice() == true)) {
continue
// Mullvad based nodes should not be shown in the peer list
if (!peer.isMullvadNode) {
if (!grouped.containsKey(userId)) {
grouped[userId] = mutableListOf()
}
grouped[userId]?.add(peer)
}
if (!grouped.containsKey(userId)) {
grouped[userId] = mutableListOf()
}
grouped[userId]?.add(peer)
}
val me = netmap.currentUserProfile()
peerSets =
grouped
.map { (userId, peers) ->
@ -112,10 +90,7 @@ class PeerCategorizer {
}
val matchingPeers =
peers.filter {
it.displayName.contains(searchTerm, ignoreCase = true) ||
(it.Addresses ?: emptyList()).fastAny { addr -> addr.contains(searchTerm) }
}
peers.filter { it.displayName.contains(searchTerm, ignoreCase = true) }
if (matchingPeers.isNotEmpty()) {
PeerSet(user, matchingPeers)
} else {

@ -3,16 +3,12 @@
package com.tailscale.ipn.ui.util
import android.util.Log
import com.tailscale.ipn.R
import java.time.Duration
import java.time.Instant
import java.time.format.DateTimeFormatter
import java.util.Date
object TimeUtil {
val TAG = "TimeUtil"
fun keyExpiryFromGoTime(goTime: String?): ComposableStringFormatter {
val time = goTime ?: return ComposableStringFormatter(R.string.empty)
@ -74,51 +70,10 @@ object TimeUtil {
return Date.from(i)
}
// Returns true if the given Go time string is in the past, or will occur within the given
// duration from now.
fun isWithinExpiryNotificationWindow(window: Duration, goTime: String): Boolean {
// Returns true if the given time is within 24 hours from now or in the past.
fun isWithin24Hours(goTime: String): Boolean {
val expTime = epochMillisFromGoTime(goTime)
val now = Instant.now().toEpochMilli()
return (expTime - now) / 1000 < window.seconds
}
// Parses a Go duration string (e.g. "2h3.2m4s") and returns a Java Duration object.
// Returns null if the input string is not a valid Go duration or contains
// units other than y,w,d,h,m,s (ms and us are explicitly not supported).
fun duration(goDuration: String): Duration? {
if (goDuration.contains("ms") || goDuration.contains("us")) {
return null
}
var duration = 0.0
var valStr = ""
for (c in goDuration) {
// Scan digits and decimal points
if (c.isDigit() || c == '.') {
valStr += c
} else {
try {
val durationFragment = valStr.toDouble()
duration +=
when (c) {
'y' -> durationFragment * 31536000.0 // 365 days
'w' -> durationFragment * 604800.0
'd' -> durationFragment * 86400.0
'h' -> durationFragment * 3600.0
'm' -> durationFragment * 60.0
's' -> durationFragment
else -> {
Log.e(TAG, "Invalid duration string: $goDuration")
return null
}
}
} catch (e: NumberFormatException) {
Log.e(TAG, "Invalid duration string: $goDuration")
return null
}
valStr = ""
}
}
return Duration.ofSeconds(duration.toLong())
return (expTime - now) / 1000 < 86400
}
}

@ -3,8 +3,8 @@
package com.tailscale.ipn.ui.view
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxHeight
@ -22,41 +22,33 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.tailscale.ipn.BuildConfig
import com.tailscale.ipn.R
import com.tailscale.ipn.ui.Links
import com.tailscale.ipn.ui.theme.logoBackground
@Composable
fun AboutView(backToSettings: BackNavigation) {
val localClipboardManager = LocalClipboardManager.current
Scaffold(topBar = { Header(R.string.about_view_header, onBack = backToSettings) }) { innerPadding
Scaffold(topBar = { Header(R.string.about_view_title, onBack = backToSettings) }) { innerPadding
->
Column(
verticalArrangement =
Arrangement.spacedBy(space = 20.dp, alignment = Alignment.CenterVertically),
horizontalAlignment = Alignment.CenterHorizontally,
modifier =
Modifier.fillMaxWidth()
.fillMaxHeight()
.padding(innerPadding)
.verticalScroll(rememberScrollState())) {
TailscaleLogoView(
usesOnBackgroundColors = true,
modifier = Modifier.fillMaxWidth().fillMaxHeight().padding(innerPadding).verticalScroll(rememberScrollState())) {
Image(
modifier =
Modifier.width(100.dp)
.height(100.dp)
.clip(RoundedCornerShape(50))
.background(MaterialTheme.colorScheme.logoBackground)
.padding(25.dp))
.background(MaterialTheme.colorScheme.onSurface)
.padding(15.dp),
painter = painterResource(id = R.drawable.androidicon),
contentDescription = stringResource(R.string.app_icon_content_description))
Column(
verticalArrangement =
@ -67,10 +59,6 @@ fun AboutView(backToSettings: BackNavigation) {
fontWeight = FontWeight.SemiBold,
fontSize = MaterialTheme.typography.titleLarge.fontSize)
Text(
modifier =
Modifier.clickable {
localClipboardManager.setText(AnnotatedString(BuildConfig.VERSION_NAME))
},
text = "${stringResource(R.string.version)} ${BuildConfig.VERSION_NAME}",
fontWeight = MaterialTheme.typography.bodyMedium.fontWeight,
fontSize = MaterialTheme.typography.bodyMedium.fontSize)
@ -90,9 +78,3 @@ fun AboutView(backToSettings: BackNavigation) {
}
}
}
@Preview
@Composable
fun AboutPreview() {
AboutView({})
}

@ -17,11 +17,9 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import coil.annotation.ExperimentalCoilApi
import coil.compose.AsyncImage
import com.tailscale.ipn.R
import com.tailscale.ipn.ui.model.IpnLocal
@OptIn(ExperimentalCoilApi::class)
@ -36,10 +34,7 @@ fun Avatar(profile: IpnLocal.LoginProfile?, size: Int = 50, action: (() -> Unit)
indication = rememberRipple(bounded = false),
onClick = action)
}
Icon(
imageVector = Icons.Default.Person,
contentDescription = stringResource(R.string.settings_title),
modifier = modifier)
Icon(imageVector = Icons.Default.Person, contentDescription = null, modifier = modifier)
profile?.UserProfile?.ProfilePicURL?.let { url ->
AsyncImage(model = url, modifier = Modifier.size((size * 1.2f).dp), contentDescription = null)

@ -14,7 +14,6 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.stringResource
@ -23,21 +22,18 @@ import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.tooling.preview.Preview
import androidx.lifecycle.viewmodel.compose.viewModel
import com.tailscale.ipn.R
import com.tailscale.ipn.ui.Links
import com.tailscale.ipn.ui.theme.defaultTextColor
import com.tailscale.ipn.ui.theme.link
import com.tailscale.ipn.ui.util.ClipboardValueView
import com.tailscale.ipn.ui.util.Lists
import com.tailscale.ipn.ui.util.set
import com.tailscale.ipn.ui.viewModel.BugReportViewModel
@Composable
fun BugReportView(backToSettings: BackNavigation, model: BugReportViewModel = viewModel()) {
val handler = LocalUriHandler.current
val bugReportID by model.bugReportID.collectAsState()
val bugReportID = model.bugReportID.collectAsState().value
Scaffold(topBar = { Header(R.string.bug_report_title, onBack = backToSettings) }) { innerPadding
->
@ -64,9 +60,7 @@ fun BugReportView(backToSettings: BackNavigation, model: BugReportViewModel = vi
@Composable
fun contactText(): AnnotatedString {
val annotatedString = buildAnnotatedString {
withStyle(SpanStyle(color = MaterialTheme.colorScheme.defaultTextColor)) {
append(stringResource(id = R.string.bug_report_instructions_prefix))
}
append(stringResource(id = R.string.bug_report_instructions_prefix))
pushStringAnnotation(tag = "reportLink", annotation = Links.SUPPORT_URL)
withStyle(
@ -78,17 +72,7 @@ fun contactText(): AnnotatedString {
}
pop()
withStyle(SpanStyle(color = MaterialTheme.colorScheme.defaultTextColor)) {
append(stringResource(id = R.string.bug_report_instructions_suffix))
}
append(stringResource(id = R.string.bug_report_instructions_suffix))
}
return annotatedString
}
@Preview
@Composable
fun BugReportPreview() {
val vm = BugReportViewModel()
vm.bugReportID.set("12345678ABCDEF-12345678ABCDEF")
BugReportView({}, vm)
}

@ -6,12 +6,19 @@ package com.tailscale.ipn.ui.view
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Clear
import androidx.compose.material.icons.outlined.Close
import androidx.compose.material3.Button
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.dp
@ -39,3 +46,19 @@ fun OpenURLButton(title: String, url: String) {
)
}
}
@Composable
fun ClearButton(onClick: () -> Unit) {
IconButton(onClick = onClick, modifier = Modifier.size(24.dp)) {
Icon(Icons.Outlined.Clear, null)
}
}
@Composable
fun CloseButton() {
val focusManager = LocalFocusManager.current
IconButton(onClick = { focusManager.clearFocus() }, modifier = Modifier.size(24.dp)) {
Icon(Icons.Outlined.Close, null)
}
}

@ -1,153 +0,0 @@
// 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.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.tailscale.ipn.R
import com.tailscale.ipn.ui.theme.listItem
import com.tailscale.ipn.ui.util.set
import com.tailscale.ipn.ui.viewModel.LoginWithAuthKeyViewModel
import com.tailscale.ipn.ui.viewModel.LoginWithCustomControlURLViewModel
data class LoginViewStrings(
var title: String,
var explanation: String,
var inputTitle: String,
var placeholder: String,
)
@Composable
fun LoginWithCustomControlURLView(
onNavigateHome: BackNavigation,
backToSettings: BackNavigation,
viewModel: LoginWithCustomControlURLViewModel = LoginWithCustomControlURLViewModel()
) {
Scaffold(
topBar = {
Header(
R.string.add_account,
onBack = backToSettings,
)
}) { innerPadding ->
val error by viewModel.errorDialog.collectAsState()
val strings =
LoginViewStrings(
title = stringResource(id = R.string.custom_control_menu),
explanation = stringResource(id = R.string.custom_control_menu_desc),
inputTitle = stringResource(id = R.string.custom_control_url_title),
placeholder = stringResource(id = R.string.custom_control_placeholder),
)
error?.let { ErrorDialog(type = it, action = { viewModel.errorDialog.set(null) }) }
LoginView(
innerPadding = innerPadding,
strings = strings,
onSubmitAction = { viewModel.setControlURL(it, onNavigateHome) })
}
}
@Composable
fun LoginWithAuthKeyView(
onNavigateHome: BackNavigation,
backToSettings: BackNavigation,
viewModel: LoginWithAuthKeyViewModel = LoginWithAuthKeyViewModel()
) {
Scaffold(
topBar = {
Header(
R.string.add_account,
onBack = backToSettings,
)
}) { innerPadding ->
val error by viewModel.errorDialog.collectAsState()
val strings =
LoginViewStrings(
title = stringResource(id = R.string.auth_key_title),
explanation = stringResource(id = R.string.auth_key_explanation),
inputTitle = stringResource(id = R.string.auth_key_input_title),
placeholder = stringResource(id = R.string.auth_key_placeholder),
)
// Show the error overlay if need be
error?.let { ErrorDialog(type = it, action = { viewModel.errorDialog.set(null) }) }
LoginView(
innerPadding = innerPadding,
strings = strings,
onSubmitAction = { viewModel.setAuthKey(it, onNavigateHome) })
}
}
@Composable
fun LoginView(
innerPadding: PaddingValues = PaddingValues(16.dp),
strings: LoginViewStrings,
onSubmitAction: (String) -> Unit,
) {
var textVal by remember { mutableStateOf("") }
Column(
modifier =
Modifier.padding(innerPadding)
.fillMaxWidth()
.background(MaterialTheme.colorScheme.surface)) {
ListItem(
colors = MaterialTheme.colorScheme.listItem,
headlineContent = { Text(text = strings.title) },
supportingContent = { Text(text = strings.explanation) })
ListItem(
colors = MaterialTheme.colorScheme.listItem,
headlineContent = { Text(text = strings.inputTitle) },
supportingContent = {
OutlinedTextField(
modifier = Modifier.fillMaxWidth(),
colors =
TextFieldDefaults.colors(
focusedContainerColor = Color.Transparent,
unfocusedContainerColor = Color.Transparent),
textStyle = MaterialTheme.typography.bodyMedium,
value = textVal,
onValueChange = { textVal = it },
placeholder = {
Text(strings.placeholder, style = MaterialTheme.typography.bodySmall)
})
})
ListItem(
colors = MaterialTheme.colorScheme.listItem,
headlineContent = {
Box(modifier = Modifier.fillMaxWidth()) {
Button(
onClick = { onSubmitAction(textVal) },
content = { Text(stringResource(id = R.string.add_account_short)) })
}
})
}
}

@ -14,22 +14,18 @@ import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.tailscale.ipn.R
import com.tailscale.ipn.mdm.MDMSettings
import com.tailscale.ipn.ui.model.DnsType
import com.tailscale.ipn.ui.notifier.Notifier
import com.tailscale.ipn.ui.util.ClipboardValueView
import com.tailscale.ipn.ui.util.Lists
import com.tailscale.ipn.ui.util.LoadingIndicator
import com.tailscale.ipn.ui.util.itemsWithDividers
import com.tailscale.ipn.ui.util.set
import com.tailscale.ipn.ui.viewModel.DNSEnablementState
import com.tailscale.ipn.ui.viewModel.DNSSettingsViewModel
import com.tailscale.ipn.ui.viewModel.DNSSettingsViewModelFactory
@ -42,7 +38,7 @@ fun DNSSettingsView(
backToSettings: BackNavigation,
model: DNSSettingsViewModel = viewModel(factory = DNSSettingsViewModelFactory())
) {
val state: DNSEnablementState by model.enablementState.collectAsState()
val state: DNSEnablementState = model.enablementState.collectAsState().value
val resolvers = model.dnsConfig.collectAsState().value?.Resolvers ?: emptyList()
val domains = model.dnsConfig.collectAsState().value?.Domains ?: emptyList()
val routes: List<ViewableRoute> =
@ -50,7 +46,6 @@ fun DNSSettingsView(
entry.value?.let { resolvers -> ViewableRoute(name = entry.key, resolvers) } ?: run { null }
} ?: emptyList()
val useCorpDNS = Notifier.prefs.collectAsState().value?.CorpDNS == true
val dnsSettingsMDMDisposition by MDMSettings.useTailscaleDNSSettings.flow.collectAsState()
Scaffold(topBar = { Header(R.string.dns_settings, onBack = backToSettings) }) { innerPadding ->
LoadingIndicator.Wrap {
@ -69,16 +64,14 @@ fun DNSSettingsView(
},
supportingContent = { Text(stringResource(state.caption)) })
if (!dnsSettingsMDMDisposition.hiddenFromUser) {
Lists.ItemDivider()
Setting.Switch(
R.string.use_ts_dns,
isOn = useCorpDNS,
onToggle = {
LoadingIndicator.start()
model.toggleCorpDNS { LoadingIndicator.stop() }
})
}
Lists.ItemDivider()
Setting.Switch(
R.string.use_ts_dns,
isOn = useCorpDNS,
onToggle = {
LoadingIndicator.start()
model.toggleCorpDNS { LoadingIndicator.stop() }
})
}
if (resolvers.isNotEmpty()) {
@ -106,11 +99,3 @@ fun DNSSettingsView(
}
}
}
@Preview
@Composable
fun DNSSettingsViewPreview() {
val vm = DNSSettingsViewModel()
vm.enablementState.set(DNSEnablementState.ENABLED)
DNSSettingsView(backToSettings = {}, vm)
}

@ -8,9 +8,7 @@ import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import com.tailscale.ipn.R
import com.tailscale.ipn.ui.theme.AppTheme
enum class ErrorDialogType {
@ -19,8 +17,7 @@ enum class ErrorDialogType {
SWITCH_USER_FAILED,
ADD_PROFILE_FAILED,
SHARE_DEVICE_NOT_CONNECTED,
SHARE_FAILED,
INVALID_AUTH_KEY;
SHARE_FAILED;
val message: Int
get() {
@ -31,7 +28,6 @@ enum class ErrorDialogType {
ADD_PROFILE_FAILED -> R.string.add_profile_failed
SHARE_DEVICE_NOT_CONNECTED -> R.string.share_device_not_connected
SHARE_FAILED -> R.string.taildrop_share_failed
INVALID_AUTH_KEY -> R.string.invalidAuthKey
}
}
@ -44,7 +40,6 @@ enum class ErrorDialogType {
ADD_PROFILE_FAILED -> R.string.add_profile_failed_title
SHARE_DEVICE_NOT_CONNECTED -> R.string.share_device_not_connected_title
SHARE_FAILED -> R.string.taildrop_share_failed_title
INVALID_AUTH_KEY -> R.string.invalidAuthKeyTitle
}
}
@ -64,19 +59,11 @@ fun ErrorDialog(
@StringRes buttonText: Int = R.string.ok,
onDismiss: () -> Unit = {}
) {
AppTheme {
AlertDialog(
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(text = stringResource(id = title)) },
text = { Text(text = stringResource(id = message)) },
confirmButton = {
PrimaryActionButton(onClick = onDismiss) { Text(text = stringResource(id = buttonText)) }
})
}
}
@Preview
@Composable
fun ErrorDialogPreview() {
ErrorDialog(ErrorDialogType.LOGOUT_FAILED)
}

@ -17,14 +17,10 @@ import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.tailscale.ipn.R
import com.tailscale.ipn.mdm.MDMSettings
import com.tailscale.ipn.mdm.ShowHide
import com.tailscale.ipn.ui.notifier.Notifier
import com.tailscale.ipn.ui.theme.disabledListItem
import com.tailscale.ipn.ui.theme.listItem
@ -35,7 +31,6 @@ import com.tailscale.ipn.ui.viewModel.ExitNodePickerNav
import com.tailscale.ipn.ui.viewModel.ExitNodePickerViewModel
import com.tailscale.ipn.ui.viewModel.ExitNodePickerViewModelFactory
import com.tailscale.ipn.ui.viewModel.selected
import kotlinx.coroutines.flow.MutableStateFlow
@Composable
fun ExitNodePicker(
@ -45,40 +40,23 @@ fun ExitNodePicker(
LoadingIndicator.Wrap {
Scaffold(topBar = { Header(R.string.choose_exit_node, onBack = nav.onNavigateBackHome) }) {
innerPadding ->
val tailnetExitNodes by model.tailnetExitNodes.collectAsState()
val mullvadExitNodesByCountryCode by model.mullvadExitNodesByCountryCode.collectAsState()
val mullvadExitNodeCount by model.mullvadExitNodeCount.collectAsState()
val anyActive by model.anyActive.collectAsState()
val tailnetExitNodes = model.tailnetExitNodes.collectAsState().value
val mullvadExitNodesByCountryCode = model.mullvadExitNodesByCountryCode.collectAsState().value
val mullvadExitNodeCount = model.mullvadExitNodeCount.collectAsState().value
val anyActive = model.anyActive.collectAsState()
val allowLANAccess = Notifier.prefs.collectAsState().value?.ExitNodeAllowLANAccess == true
val showRunAsExitNode by MDMSettings.runExitNode.flow.collectAsState()
val allowLanAccessMDMDisposition by MDMSettings.exitNodeAllowLANAccess.flow.collectAsState()
val managedByOrganization by model.managedByOrganization.collectAsState()
val forcedExitNodeId = MDMSettings.exitNodeID.flow.collectAsState().value
LazyColumn(modifier = Modifier.padding(innerPadding)) {
item(key = "header") {
if (forcedExitNodeId != null) {
Text(
text =
managedByOrganization?.let {
stringResource(R.string.exit_node_mdm_orgname, it)
} ?: stringResource(R.string.exit_node_mdm),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 4.dp))
} else {
ExitNodeItem(
model,
ExitNodePickerViewModel.ExitNode(
label = stringResource(R.string.none),
online = MutableStateFlow(true),
selected = !anyActive,
))
}
if (showRunAsExitNode == ShowHide.Show) {
Lists.ItemDivider()
RunAsExitNodeItem(nav = nav, viewModel = model, anyActive)
}
ExitNodeItem(
model,
ExitNodePickerViewModel.ExitNode(
label = stringResource(R.string.none),
online = true,
selected = !anyActive.value,
))
Lists.ItemDivider()
RunAsExitNodeItem(nav = nav, viewModel = model)
}
item(key = "divider1") { Lists.SectionDivider() }
@ -93,14 +71,13 @@ fun ExitNodePicker(
}
}
if (!allowLanAccessMDMDisposition.hiddenFromUser) {
item(key = "allowLANAccess") {
Lists.SectionDivider()
// TODO: make sure this actually works, and if not, leave it out for now
item(key = "allowLANAccess") {
Lists.SectionDivider()
Setting.Switch(R.string.allow_lan_access, isOn = allowLANAccess) {
LoadingIndicator.start()
model.toggleAllowLANAccess { LoadingIndicator.stop() }
}
Setting.Switch(R.string.allow_lan_access, isOn = allowLANAccess) {
LoadingIndicator.start()
model.toggleAllowLANAccess { LoadingIndicator.stop() }
}
}
}
@ -113,31 +90,27 @@ fun ExitNodeItem(
viewModel: ExitNodePickerViewModel,
node: ExitNodePickerViewModel.ExitNode,
) {
val online by node.online.collectAsState()
val isRunningExitNode = viewModel.isRunningExitNode.collectAsState().value
val forcedExitNodeId = MDMSettings.exitNodeID.flow.collectAsState().value
Box {
var modifier: Modifier = Modifier
if (online && !isRunningExitNode && forcedExitNodeId == null) {
if (node.online) {
modifier = modifier.clickable { viewModel.setExitNode(node) }
}
ListItem(
modifier = modifier,
colors =
if (online && !isRunningExitNode) MaterialTheme.colorScheme.listItem
if (node.online) MaterialTheme.colorScheme.listItem
else MaterialTheme.colorScheme.disabledListItem,
headlineContent = {
Text(node.city.ifEmpty { node.label }, style = MaterialTheme.typography.bodyMedium)
},
supportingContent = {
if (!online)
if (!node.online)
Text(stringResource(R.string.offline), style = MaterialTheme.typography.bodyMedium)
},
trailingContent = {
Row {
if (node.selected) {
Icon(Icons.Outlined.Check, null)
Icon(Icons.Outlined.Check, contentDescription = stringResource(R.string.selected))
}
}
})
@ -161,30 +134,19 @@ fun MullvadItem(nav: ExitNodePickerNav, count: Int, selected: Boolean) {
},
trailingContent = {
if (selected) {
Icon(Icons.Outlined.Check, null)
Icon(Icons.Outlined.Check, contentDescription = stringResource(R.string.selected))
}
})
}
}
@Composable
fun RunAsExitNodeItem(
nav: ExitNodePickerNav,
viewModel: ExitNodePickerViewModel,
anyActive: Boolean
) {
fun RunAsExitNodeItem(nav: ExitNodePickerNav, viewModel: ExitNodePickerViewModel) {
val isRunningExitNode = viewModel.isRunningExitNode.collectAsState().value
Box {
var modifier: Modifier = Modifier
if (!anyActive) {
modifier = modifier.clickable { nav.onNavigateToRunAsExitNode() }
}
ListItem(
modifier = modifier,
colors =
if (!anyActive) MaterialTheme.colorScheme.listItem
else MaterialTheme.colorScheme.disabledListItem,
modifier = Modifier.clickable { nav.onNavigateToRunAsExitNode() },
headlineContent = {
Text(
stringResource(id = R.string.run_as_exit_node),

@ -3,6 +3,7 @@
package com.tailscale.ipn.ui.view
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@ -16,17 +17,15 @@ import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.tailscale.ipn.R
import com.tailscale.ipn.ui.theme.AppTheme
@Composable
fun IntroView(onContinue: () -> Unit) {
@ -34,7 +33,10 @@ fun IntroView(onContinue: () -> Unit) {
modifier = Modifier.fillMaxHeight().fillMaxWidth().verticalScroll(rememberScrollState()),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center) {
TailscaleLogoView(modifier = Modifier.width(60.dp).height(60.dp))
Image(
modifier = Modifier.width(80.dp).height(80.dp),
painter = painterResource(id = R.drawable.androidicon_light),
contentDescription = stringResource(R.string.app_icon_content_description))
Spacer(modifier = Modifier.height(40.dp))
Text(
modifier = Modifier.padding(start = 40.dp, end = 40.dp, bottom = 40.dp),
@ -60,9 +62,3 @@ fun IntroView(onContinue: () -> Unit) {
}
}
}
@Composable
@Preview
fun IntroViewPreview() {
AppTheme { Surface { IntroView({}) } }
}

@ -20,18 +20,14 @@ import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.lifecycle.viewmodel.compose.viewModel
import com.tailscale.ipn.R
import com.tailscale.ipn.ui.theme.AppTheme
import com.tailscale.ipn.ui.util.set
import com.tailscale.ipn.ui.viewModel.LoginQRViewModel
@OptIn(ExperimentalMaterial3Api::class)
@ -39,7 +35,7 @@ import com.tailscale.ipn.ui.viewModel.LoginQRViewModel
fun LoginQRView(onDismiss: () -> Unit = {}, model: LoginQRViewModel = viewModel()) {
Surface(color = MaterialTheme.colorScheme.scrim, modifier = Modifier.fillMaxSize()) {
Dialog(onDismissRequest = onDismiss) {
val image by model.qrCode.collectAsState()
val image = model.qrCode.collectAsState()
Column(
modifier =
@ -58,7 +54,7 @@ fun LoginQRView(onDismiss: () -> Unit = {}, model: LoginQRViewModel = viewModel(
.background(MaterialTheme.colorScheme.onSurface)
.fillMaxWidth(),
contentAlignment = Alignment.Center) {
image?.let {
image.value?.let {
Image(
bitmap = it,
contentDescription = "Scan to login",
@ -70,11 +66,3 @@ fun LoginQRView(onDismiss: () -> Unit = {}, model: LoginQRViewModel = viewModel(
}
}
}
@Composable
@Preview
fun LoginQRViewPreview() {
val vm = LoginQRViewModel()
vm.qrCode.set(vm.generateQRCode("https://tailscale.com", 200, 0))
AppTheme { LoginQRView({}, vm) }
}

@ -12,7 +12,6 @@ import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
@ -39,7 +38,7 @@ fun MDMSettingsDebugView(backToSettings: BackNavigation, model: IpnViewModel = v
@Composable
fun MDMSettingView(setting: MDMSetting<*>) {
val value by setting.flow.collectAsState()
val value = setting.flow.collectAsState().value
ListItem(
headlineContent = { Text(setting.localizedTitle, maxLines = 3) },
supportingContent = {

@ -6,7 +6,6 @@ package com.tailscale.ipn.ui.view
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.focusable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@ -27,7 +26,6 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.ArrowDropDown
import androidx.compose.material.icons.outlined.Clear
import androidx.compose.material.icons.outlined.Close
import androidx.compose.material.icons.outlined.Lock
import androidx.compose.material.icons.outlined.Search
import androidx.compose.material.icons.outlined.Settings
import androidx.compose.material3.Button
@ -41,7 +39,6 @@ import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
@ -50,10 +47,8 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
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.painterResource
import androidx.compose.ui.res.stringResource
@ -62,22 +57,16 @@ import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.tailscale.ipn.R
import com.tailscale.ipn.mdm.MDMSettings
import com.tailscale.ipn.mdm.ShowHide
import com.tailscale.ipn.ui.model.Ipn
import com.tailscale.ipn.ui.model.IpnLocal
import com.tailscale.ipn.ui.model.Netmap
import com.tailscale.ipn.ui.model.Permissions
import com.tailscale.ipn.ui.model.Tailcfg
import com.tailscale.ipn.ui.theme.customErrorContainer
import com.tailscale.ipn.ui.theme.disabled
import com.tailscale.ipn.ui.theme.errorButton
import com.tailscale.ipn.ui.theme.errorListItem
import com.tailscale.ipn.ui.theme.exitNodeToggleButton
import com.tailscale.ipn.ui.theme.listItem
import com.tailscale.ipn.ui.theme.minTextSize
import com.tailscale.ipn.ui.theme.primaryListItem
@ -85,15 +74,14 @@ import com.tailscale.ipn.ui.theme.searchBarColors
import com.tailscale.ipn.ui.theme.secondaryButton
import com.tailscale.ipn.ui.theme.short
import com.tailscale.ipn.ui.theme.surfaceContainerListItem
import com.tailscale.ipn.ui.theme.warningButton
import com.tailscale.ipn.ui.theme.warningListItem
import com.tailscale.ipn.ui.util.AndroidTVUtil.isAndroidTV
import com.tailscale.ipn.ui.util.AutoResizingText
import com.tailscale.ipn.ui.util.Lists
import com.tailscale.ipn.ui.util.LoadingIndicator
import com.tailscale.ipn.ui.util.PeerSet
import com.tailscale.ipn.ui.util.TimeUtil
import com.tailscale.ipn.ui.util.flag
import com.tailscale.ipn.ui.util.itemsWithDividers
import com.tailscale.ipn.ui.viewModel.IpnViewModel.NodeState
import com.tailscale.ipn.ui.viewModel.MainViewModel
// Navigation actions for the MainView
@ -105,46 +93,23 @@ data class MainViewNavigation(
@OptIn(ExperimentalPermissionsApi::class)
@Composable
fun MainView(
loginAtUrl: (String) -> Unit,
navigation: MainViewNavigation,
viewModel: MainViewModel
) {
fun MainView(navigation: MainViewNavigation, viewModel: MainViewModel = viewModel()) {
LoadingIndicator.Wrap {
Scaffold(contentWindowInsets = WindowInsets.Companion.statusBars) { paddingInsets ->
Column(
modifier = Modifier.fillMaxWidth().padding(paddingInsets),
verticalArrangement = Arrangement.Center) {
// Assume VPN has been prepared for optimistic UI. Whether or not it has been prepared
// cannot be known
// until permission has been granted to prepare the VPN.
val isPrepared by viewModel.vpnPrepared.collectAsState(initial = true)
val isOn by viewModel.vpnToggleState.collectAsState(initial = false)
val state by viewModel.ipnState.collectAsState(initial = Ipn.State.NoState)
val user by viewModel.loggedInUser.collectAsState(initial = null)
val stateVal by viewModel.stateRes.collectAsState(initial = R.string.placeholder)
val state = viewModel.ipnState.collectAsState(initial = Ipn.State.NoState).value
val user = viewModel.loggedInUser.collectAsState(initial = null).value
val stateVal = viewModel.stateRes.collectAsState(initial = R.string.placeholder).value
val stateStr = stringResource(id = stateVal)
val netmap by viewModel.netmap.collectAsState(initial = null)
val showExitNodePicker by MDMSettings.exitNodesPicker.flow.collectAsState()
val disableToggle by MDMSettings.forceEnabled.flow.collectAsState(initial = true)
val showKeyExpiry by viewModel.showExpiry.collectAsState(initial = false)
// Hide the header only on Android TV when the user needs to login
val hideHeader = (isAndroidTV() && state == Ipn.State.NeedsLogin)
val netmap = viewModel.netmap.collectAsState(initial = null)
ListItem(
colors = MaterialTheme.colorScheme.surfaceContainerListItem,
leadingContent = {
if (!hideHeader) {
TintedSwitch(
onCheckedChange = {
if (!disableToggle) {
viewModel.toggleVpn()
}
},
enabled = !disableToggle,
checked = isOn)
}
val isOn = viewModel.vpnToggleState.collectAsState(initial = false)
TintedSwitch(onCheckedChange = { viewModel.toggleVpn() }, checked = isOn.value)
},
headlineContent = {
user?.NetworkProfile?.DomainName?.let { domain ->
@ -156,9 +121,7 @@ fun MainView(
}
},
supportingContent = {
if (!hideHeader) {
Text(text = stateStr, style = MaterialTheme.typography.bodyMedium.short)
}
Text(text = stateStr, style = MaterialTheme.typography.bodyMedium.short)
},
trailingContent = {
Box(modifier = Modifier.weight(1f), contentAlignment = Alignment.CenterEnd) {
@ -184,16 +147,10 @@ fun MainView(
PromptPermissionsIfNecessary()
viewModel.showVPNPermissionLauncherIfUnauthorized()
if (showKeyExpiry) {
ExpiryNotification(netmap = netmap, action = { viewModel.login() })
}
ExpiryNotificationIfNecessary(
netmap = netmap.value, action = { viewModel.login {} })
if (showExitNodePicker == ShowHide.Show) {
ExitNodeStatus(
navAction = navigation.onNavigateToExitNodes, viewModel = viewModel)
}
ExitNodeStatus(navAction = navigation.onNavigateToExitNodes, viewModel = viewModel)
PeerList(
viewModel = viewModel,
@ -202,17 +159,7 @@ fun MainView(
}
Ipn.State.NoState,
Ipn.State.Starting -> StartingView()
else -> {
ConnectView(
state,
isPrepared,
user,
{ viewModel.toggleVpn() },
{ viewModel.login() },
loginAtUrl,
netmap?.SelfNode,
{ viewModel.showVPNPermissionLauncherIfUnauthorized() })
}
else -> ConnectView(state, user, { viewModel.toggleVpn() }, { viewModel.login {} })
}
}
}
@ -221,133 +168,61 @@ fun MainView(
@Composable
fun ExitNodeStatus(navAction: () -> Unit, viewModel: MainViewModel) {
val nodeState by viewModel.nodeState.collectAsState()
val maybePrefs by viewModel.prefs.collectAsState()
val netmap by viewModel.netmap.collectAsState()
// There's nothing to render if we haven't loaded the prefs yet
val prefs = maybePrefs ?: return
// The activeExitNode is the source of truth. The selectedExitNode is only relevant if we
// don't have an active node.
val chosenExitNodeId = prefs.activeExitNodeID ?: prefs.selectedExitNodeID
val exitNodePeer = chosenExitNodeId?.let { id -> netmap?.Peers?.find { it.StableID == id } }
val name = exitNodePeer?.exitNodeName
val managedByOrganization by viewModel.managedByOrganization.collectAsState()
val prefs = viewModel.prefs.collectAsState()
val netmap = viewModel.netmap.collectAsState()
val exitNodeId = prefs.value?.ExitNodeID
val peer = exitNodeId?.let { id -> netmap.value?.Peers?.find { it.StableID == id } }
val location = peer?.Hostinfo?.Location
val name = peer?.ComputedName
val active = peer != null
Box(
modifier =
Modifier.fillMaxWidth().background(color = MaterialTheme.colorScheme.surfaceContainer)) {
if (nodeState == NodeState.OFFLINE_MDM) {
Box(
modifier =
Modifier.padding(start = 16.dp, end = 16.dp, top = 56.dp, bottom = 16.dp)
.clip(shape = RoundedCornerShape(10.dp, 10.dp, 10.dp, 10.dp))
.background(MaterialTheme.colorScheme.customErrorContainer)
.fillMaxWidth()
.align(Alignment.TopCenter)) {
Column(
modifier =
Modifier.padding(start = 16.dp, end = 16.dp, top = 36.dp, bottom = 16.dp)) {
Text(
text =
managedByOrganization?.let {
stringResource(R.string.exit_node_offline_mdm_orgname, it)
} ?: stringResource(R.string.exit_node_offline_mdm),
style = MaterialTheme.typography.bodyMedium,
color = Color.White)
}
}
Box(modifier = Modifier.background(color = MaterialTheme.colorScheme.surfaceContainer)) {
Box(
modifier =
Modifier.padding(start = 16.dp, end = 16.dp, top = 4.dp, bottom = 16.dp)
.clip(shape = RoundedCornerShape(10.dp, 10.dp, 10.dp, 10.dp))
.fillMaxWidth()) {
ListItem(
modifier = Modifier.clickable { navAction() },
colors =
if (active) MaterialTheme.colorScheme.primaryListItem
else ListItemDefaults.colors(),
overlineContent = {
Text(
stringResource(R.string.exit_node),
style = MaterialTheme.typography.bodySmall,
)
},
headlineContent = {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text =
location?.let { "${it.CountryCode?.flag()} ${it.Country}: ${it.City}" }
?: name
?: stringResource(id = R.string.none),
style = MaterialTheme.typography.bodyMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis)
Icon(
imageVector = Icons.Outlined.ArrowDropDown,
contentDescription = null,
tint =
if (active) MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.7f)
else MaterialTheme.colorScheme.onSurfaceVariant,
)
}
},
trailingContent = {
if (peer != null) {
Button(
colors = MaterialTheme.colorScheme.secondaryButton,
onClick = { viewModel.disableExitNode() }) {
Text(stringResource(R.string.stop))
}
}
})
}
Box(
modifier =
Modifier.padding(start = 16.dp, end = 16.dp, top = 4.dp, bottom = 16.dp)
.clip(shape = RoundedCornerShape(10.dp, 10.dp, 10.dp, 10.dp))
.fillMaxWidth()) {
ListItem(
modifier = Modifier.clickable { navAction() },
colors =
when (nodeState) {
NodeState.ACTIVE_AND_RUNNING -> MaterialTheme.colorScheme.primaryListItem
NodeState.ACTIVE_NOT_RUNNING -> MaterialTheme.colorScheme.listItem
NodeState.RUNNING_AS_EXIT_NODE -> MaterialTheme.colorScheme.warningListItem
NodeState.OFFLINE_ENABLED -> MaterialTheme.colorScheme.errorListItem
NodeState.OFFLINE_DISABLED -> MaterialTheme.colorScheme.errorListItem
NodeState.OFFLINE_MDM -> MaterialTheme.colorScheme.errorListItem
else ->
ListItemDefaults.colors(
containerColor = MaterialTheme.colorScheme.surface)
},
overlineContent = {
Text(
text =
if (nodeState == NodeState.OFFLINE_ENABLED ||
nodeState == NodeState.OFFLINE_DISABLED ||
nodeState == NodeState.OFFLINE_MDM)
stringResource(R.string.exit_node_offline)
else stringResource(R.string.exit_node),
style = MaterialTheme.typography.bodySmall,
)
},
headlineContent = {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text =
when (nodeState) {
NodeState.NONE -> stringResource(id = R.string.none)
NodeState.RUNNING_AS_EXIT_NODE ->
stringResource(id = R.string.running_exit_node)
else -> name ?: ""
},
style = MaterialTheme.typography.bodyMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis)
Icon(
imageVector = Icons.Outlined.ArrowDropDown,
contentDescription = null,
tint =
if (nodeState == NodeState.NONE)
MaterialTheme.colorScheme.onSurfaceVariant
else MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.7f),
)
}
},
trailingContent = {
if (nodeState != NodeState.NONE) {
Button(
colors =
when (nodeState) {
NodeState.OFFLINE_ENABLED -> MaterialTheme.colorScheme.errorButton
NodeState.OFFLINE_DISABLED -> MaterialTheme.colorScheme.errorButton
NodeState.OFFLINE_MDM -> MaterialTheme.colorScheme.errorButton
NodeState.RUNNING_AS_EXIT_NODE ->
MaterialTheme.colorScheme.warningButton
NodeState.ACTIVE_NOT_RUNNING ->
MaterialTheme.colorScheme.exitNodeToggleButton
else -> MaterialTheme.colorScheme.secondaryButton
},
onClick = {
if (nodeState == NodeState.RUNNING_AS_EXIT_NODE)
viewModel.setRunningExitNode(false)
else viewModel.toggleExitNode()
}) {
Text(
when (nodeState) {
NodeState.OFFLINE_DISABLED -> stringResource(id = R.string.enable)
NodeState.ACTIVE_NOT_RUNNING ->
stringResource(id = R.string.enable)
NodeState.RUNNING_AS_EXIT_NODE ->
stringResource(id = R.string.stop)
else -> stringResource(id = R.string.disable)
})
}
}
})
}
}
}
}
@Composable
@ -355,8 +230,8 @@ fun SettingsButton(action: () -> Unit) {
IconButton(modifier = Modifier.size(24.dp), onClick = { action() }) {
Icon(
Icons.Outlined.Settings,
contentDescription = "Open settings",
tint = MaterialTheme.colorScheme.onSurfaceVariant)
null,
)
}
}
@ -366,27 +241,17 @@ fun StartingView() {
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally) {
TailscaleLogoView(
animated = true, usesOnBackgroundColors = false, Modifier.size(40.dp).alpha(0.3f))
TailscaleLogoView(animated = true, Modifier.size(40.dp))
}
}
@Composable
fun ConnectView(
state: Ipn.State,
isPrepared: Boolean,
user: IpnLocal.LoginProfile?,
connectAction: () -> Unit,
loginAction: () -> Unit,
loginAtUrlAction: (String) -> Unit,
selfNode: Tailcfg.Node?,
showVPNPermissionLauncherIfUnauthorized: () -> Unit
loginAction: () -> Unit
) {
LaunchedEffect(isPrepared) {
if (!isPrepared) {
showVPNPermissionLauncherIfUnauthorized()
}
}
Row(horizontalArrangement = Arrangement.Center, modifier = Modifier.fillMaxWidth()) {
Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.fillMaxWidth()) {
Column(
@ -394,45 +259,7 @@ fun ConnectView(
verticalArrangement = Arrangement.spacedBy(8.dp, alignment = Alignment.CenterVertically),
horizontalAlignment = Alignment.CenterHorizontally,
) {
if (!isPrepared) {
TailscaleLogoView(modifier = Modifier.size(50.dp))
Spacer(modifier = Modifier.size(1.dp))
Text(
text = stringResource(id = R.string.welcome_to_tailscale),
style = MaterialTheme.typography.titleMedium,
textAlign = TextAlign.Center)
Text(
stringResource(R.string.give_permissions),
style = MaterialTheme.typography.titleSmall,
textAlign = TextAlign.Center)
Spacer(modifier = Modifier.size(1.dp))
PrimaryActionButton(onClick = connectAction) {
Text(
text = stringResource(id = R.string.connect),
fontSize = MaterialTheme.typography.titleMedium.fontSize)
}
} else if (state == Ipn.State.NeedsMachineAuth) {
Icon(
modifier = Modifier.size(40.dp),
imageVector = Icons.Outlined.Lock,
contentDescription = "Device requires authentication")
Text(
text = stringResource(id = R.string.machine_auth_required),
style = MaterialTheme.typography.titleMedium,
textAlign = TextAlign.Center)
Text(
text = stringResource(id = R.string.machine_auth_explainer),
style = MaterialTheme.typography.bodyMedium,
textAlign = TextAlign.Center)
Spacer(modifier = Modifier.size(1.dp))
selfNode?.let {
PrimaryActionButton(onClick = { loginAtUrlAction(it.nodeAdminUrl) }) {
Text(
text = stringResource(id = R.string.open_admin_console),
fontSize = MaterialTheme.typography.titleMedium.fontSize)
}
}
} else if (state != Ipn.State.NeedsLogin && user != null && !user.isEmpty()) {
if (state != Ipn.State.NeedsLogin && user != null && !user.isEmpty()) {
Icon(
painter = painterResource(id = R.drawable.power),
contentDescription = null,
@ -493,71 +320,58 @@ fun PeerList(
onNavigateToPeerDetails: (Tailcfg.Node) -> Unit,
onSearch: (String) -> Unit
) {
val peerList by viewModel.peers.collectAsState(initial = emptyList<PeerSet>())
val peerList = viewModel.peers.collectAsState(initial = emptyList<PeerSet>())
val searchTermStr by viewModel.searchTerm.collectAsState(initial = "")
val showNoResults =
remember { derivedStateOf { searchTermStr.isNotEmpty() && peerList.isEmpty() } }.value
derivedStateOf { searchTermStr.isNotEmpty() && peerList.value.isEmpty() }.value
val netmap = viewModel.netmap.collectAsState()
val focusManager = LocalFocusManager.current
var isFocussed by remember { mutableStateOf(false) }
var isListFocussed by remember { mutableStateOf(false) }
val enableSearch = !isAndroidTV()
if (enableSearch) {
Box(modifier = Modifier.fillMaxWidth().background(color = MaterialTheme.colorScheme.surface)) {
OutlinedTextField(
modifier =
Modifier.fillMaxWidth()
.padding(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 0.dp)
.onFocusChanged { isFocussed = it.isFocused },
singleLine = true,
shape = MaterialTheme.shapes.extraLarge,
colors = MaterialTheme.colorScheme.searchBarColors,
leadingIcon = {
Icon(imageVector = Icons.Outlined.Search, contentDescription = "search")
},
trailingIcon = {
if (isFocussed) {
IconButton(
onClick = {
focusManager.clearFocus()
onSearch("")
}) {
Icon(
imageVector =
if (searchTermStr.isEmpty()) Icons.Outlined.Close
else Icons.Outlined.Clear,
contentDescription = "clear search",
tint = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
},
placeholder = {
Text(
text = stringResource(id = R.string.search),
style = MaterialTheme.typography.bodyLarge,
maxLines = 1)
},
value = searchTermStr,
onValueChange = { onSearch(it) })
}
Box(modifier = Modifier.fillMaxWidth().background(color = MaterialTheme.colorScheme.surface)) {
OutlinedTextField(
modifier =
Modifier.fillMaxWidth()
.padding(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 0.dp)
.onFocusChanged { isFocussed = it.isFocused },
singleLine = true,
shape = MaterialTheme.shapes.extraLarge,
colors = MaterialTheme.colorScheme.searchBarColors,
leadingIcon = { Icon(imageVector = Icons.Outlined.Search, contentDescription = "search") },
trailingIcon = {
if (isFocussed) {
IconButton(
onClick = {
focusManager.clearFocus()
onSearch("")
}) {
Icon(
imageVector =
if (searchTermStr.isEmpty()) Icons.Outlined.Close
else Icons.Outlined.Clear,
contentDescription = "clear search",
tint = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
},
placeholder = {
Text(
text = stringResource(id = R.string.search),
style = MaterialTheme.typography.bodyLarge,
maxLines = 1)
},
value = searchTermStr,
onValueChange = { onSearch(it) })
}
LazyColumn(
modifier =
Modifier.fillMaxSize()
.onFocusChanged { isListFocussed = it.isFocused }
.background(color = MaterialTheme.colorScheme.surface)) {
modifier = Modifier.fillMaxSize().background(color = MaterialTheme.colorScheme.surface)) {
if (showNoResults) {
item {
Spacer(
Modifier.height(16.dp)
.fillMaxSize()
.focusable(false)
.background(color = MaterialTheme.colorScheme.surface))
Lists.LargeTitle(
@ -569,17 +383,23 @@ fun PeerList(
}
var first = true
peerList.forEach { peerSet ->
peerList.value.forEach { peerSet ->
if (!first) {
item(key = "user_divider_${peerSet.user?.ID ?: 0L}") { Lists.ItemDivider() }
}
first = false
// Sticky headers are a bit broken on Android TV - they hide their content
if (isAndroidTV()) {
item { NodesSectionHeader(peerSet = peerSet) }
} else {
stickyHeader { NodesSectionHeader(peerSet = peerSet) }
stickyHeader {
Spacer(
Modifier.height(16.dp)
.fillMaxSize()
.background(color = MaterialTheme.colorScheme.surface))
Lists.LargeTitle(
peerSet.user?.DisplayName ?: stringResource(id = R.string.unknown_user),
bottomPadding = 8.dp,
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.SemiBold)
}
itemsWithDividers(peerSet.peers, key = { it.StableID }) { peer ->
@ -612,20 +432,12 @@ fun PeerList(
}
@Composable
fun NodesSectionHeader(peerSet: PeerSet) {
Spacer(Modifier.height(16.dp).fillMaxSize().background(color = MaterialTheme.colorScheme.surface))
Lists.LargeTitle(
peerSet.user?.DisplayName ?: stringResource(id = R.string.unknown_user),
bottomPadding = 8.dp,
focusable = isAndroidTV(),
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.SemiBold)
}
@Composable
fun ExpiryNotification(netmap: Netmap.NetworkMap?, action: () -> Unit = {}) {
if (netmap == null) return
fun ExpiryNotificationIfNecessary(netmap: Netmap.NetworkMap?, action: () -> Unit = {}) {
// Key expiry warning shown only if the key is expiring within 24 hours (or has already expired)
val networkMap = netmap ?: return
if (!TimeUtil.isWithin24Hours(networkMap.SelfNode.KeyExpiry)) {
return
}
Box(modifier = Modifier.background(color = MaterialTheme.colorScheme.surfaceContainer)) {
Box(
@ -638,7 +450,7 @@ fun ExpiryNotification(netmap: Netmap.NetworkMap?, action: () -> Unit = {}) {
colors = MaterialTheme.colorScheme.warningListItem,
headlineContent = {
Text(
netmap.SelfNode.expiryLabel(),
networkMap.SelfNode.expiryLabel(),
style = MaterialTheme.typography.titleMedium,
)
},
@ -663,14 +475,3 @@ fun PromptPermissionsIfNecessary() {
}
}
}
@Preview
@Composable
fun MainViewPreview() {
val vm = MainViewModel()
MainView(
{},
MainViewNavigation(
onNavigateToSettings = {}, onNavigateToPeerDetails = {}, onNavigateToExitNodes = {}),
vm)
}

@ -16,7 +16,6 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.tailscale.ipn.R
@ -47,10 +46,3 @@ fun ManagedByView(backToSettings: BackNavigation, model: IpnViewModel = viewMode
}
}
}
@Preview
@Composable
fun ManagedByViewPreview() {
val vm = IpnViewModel()
ManagedByView(backToSettings = {}, vm)
}

@ -10,7 +10,6 @@ import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.lifecycle.viewmodel.compose.viewModel
@ -30,10 +29,10 @@ fun MullvadExitNodePicker(
nav: ExitNodePickerNav,
model: ExitNodePickerViewModel = viewModel(factory = ExitNodePickerViewModelFactory(nav))
) {
val mullvadExitNodes by model.mullvadExitNodesByCountryCode.collectAsState()
val bestAvailableByCountry by model.mullvadBestAvailableByCountry.collectAsState()
val mullvadExitNodes = model.mullvadExitNodesByCountryCode.collectAsState()
val bestAvailableByCountry = model.mullvadBestAvailableByCountry.collectAsState()
mullvadExitNodes[countryCode]?.toList()?.let { nodes ->
mullvadExitNodes.value[countryCode]?.toList()?.let { nodes ->
val any = nodes.first()
LoadingIndicator.Wrap {
@ -45,7 +44,7 @@ fun MullvadExitNodePicker(
}) { innerPadding ->
LazyColumn(modifier = Modifier.padding(innerPadding)) {
if (nodes.size > 1) {
val bestAvailableNode = bestAvailableByCountry[countryCode]!!
val bestAvailableNode = bestAvailableByCountry.value[countryCode]!!
item {
ExitNodeItem(
model,

@ -17,7 +17,6 @@ import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.lifecycle.viewmodel.compose.viewModel
@ -41,11 +40,11 @@ fun MullvadExitNodePickerList(
topBar = {
Header(R.string.choose_mullvad_exit_node, onBack = nav.onNavigateBackToExitNodes)
}) { innerPadding ->
val mullvadExitNodes by model.mullvadExitNodesByCountryCode.collectAsState()
val mullvadExitNodes = model.mullvadExitNodesByCountryCode.collectAsState()
LazyColumn(modifier = Modifier.padding(innerPadding)) {
val sortedCountries =
mullvadExitNodes.entries.toList().sortedBy {
mullvadExitNodes.value.entries.toList().sortedBy {
it.value.first().country.lowercase()
}
itemsWithDividers(sortedCountries) { (countryCode, nodes) ->

@ -5,7 +5,6 @@ package com.tailscale.ipn.ui.view
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.focusable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
@ -34,7 +33,6 @@ import androidx.lifecycle.viewmodel.compose.viewModel
import com.tailscale.ipn.R
import com.tailscale.ipn.ui.theme.listItem
import com.tailscale.ipn.ui.theme.short
import com.tailscale.ipn.ui.util.AndroidTVUtil.isAndroidTV
import com.tailscale.ipn.ui.util.Lists
import com.tailscale.ipn.ui.util.itemsWithDividers
import com.tailscale.ipn.ui.viewModel.PeerDetailsViewModel
@ -103,24 +101,14 @@ fun PeerDetails(
fun AddressRow(address: String, type: String) {
val localClipboardManager = LocalClipboardManager.current
// Android TV doesn't have a clipboard, nor any way to use the values, so visible only.
val modifier =
if (isAndroidTV()) {
Modifier.focusable(false)
} else {
Modifier.clickable { localClipboardManager.setText(AnnotatedString(address)) }
}
ListItem(
modifier = modifier,
modifier = Modifier.clickable { localClipboardManager.setText(AnnotatedString(address)) },
colors = MaterialTheme.colorScheme.listItem,
headlineContent = { Text(text = address) },
supportingContent = { Text(text = type) },
trailingContent = {
// TODO: there is some overlap with other uses of clipboard, DRY
if (!isAndroidTV()) {
Icon(painter = painterResource(id = R.drawable.clipboard), null)
}
Icon(painter = painterResource(id = R.drawable.clipboard), null)
})
}

@ -28,7 +28,7 @@ fun PeerView(
peer: Tailcfg.Node,
selfPeer: String? = null,
stateVal: Ipn.State? = null,
subtitle: () -> String = { peer.primaryIPv4Address ?: peer.primaryIPv6Address ?: "" },
subtitle: () -> String = { peer.Addresses?.first()?.split("/")?.first() ?: "" },
onClick: (Tailcfg.Node) -> Unit = {},
trailingContent: @Composable () -> Unit = {}
) {

@ -21,7 +21,6 @@ import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
@ -32,11 +31,15 @@ import androidx.lifecycle.viewmodel.compose.viewModel
import com.tailscale.ipn.R
import com.tailscale.ipn.ui.util.LoadingIndicator
import com.tailscale.ipn.ui.viewModel.ExitNodePickerNav
import com.tailscale.ipn.ui.viewModel.IpnViewModel
import com.tailscale.ipn.ui.viewModel.RunExitNodeViewModel
import com.tailscale.ipn.ui.viewModel.RunExitNodeViewModelFactory
@Composable
fun RunExitNodeView(nav: ExitNodePickerNav, model: IpnViewModel = viewModel()) {
val isRunningExitNode by model.isRunningExitNode.collectAsState()
fun RunExitNodeView(
nav: ExitNodePickerNav,
model: RunExitNodeViewModel = viewModel(factory = RunExitNodeViewModelFactory())
) {
val isRunningExitNode = model.isRunningExitNode.collectAsState().value
Scaffold(
topBar = { Header(R.string.run_as_exit_node, onBack = nav.onNavigateBackToExitNodes) }) {
@ -46,11 +49,7 @@ fun RunExitNodeView(nav: ExitNodePickerNav, model: IpnViewModel = viewModel()) {
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement =
Arrangement.spacedBy(24.dp, alignment = Alignment.CenterVertically),
modifier =
Modifier.padding(innerPadding)
.padding(24.dp)
.fillMaxHeight()
.verticalScroll(rememberScrollState())) {
modifier = Modifier.padding(innerPadding).padding(24.dp).fillMaxHeight().verticalScroll(rememberScrollState())) {
RunExitNodeGraphic()
if (isRunningExitNode) {

@ -14,7 +14,6 @@ import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalUriHandler
@ -23,46 +22,36 @@ import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.tooling.preview.Preview
import androidx.lifecycle.viewmodel.compose.viewModel
import com.tailscale.ipn.BuildConfig
import com.tailscale.ipn.R
import com.tailscale.ipn.mdm.MDMSettings
import com.tailscale.ipn.mdm.ShowHide
import com.tailscale.ipn.ui.Links
import com.tailscale.ipn.ui.theme.link
import com.tailscale.ipn.ui.theme.listItem
import com.tailscale.ipn.ui.util.AndroidTVUtil.isAndroidTV
import com.tailscale.ipn.ui.util.Lists
import com.tailscale.ipn.ui.util.set
import com.tailscale.ipn.ui.viewModel.SettingsNav
import com.tailscale.ipn.ui.viewModel.SettingsViewModel
@Composable
fun SettingsView(settingsNav: SettingsNav, viewModel: SettingsViewModel = viewModel()) {
val handler = LocalUriHandler.current
val user by viewModel.loggedInUser.collectAsState()
val isAdmin by viewModel.isAdmin.collectAsState()
val managedByOrganization by viewModel.managedByOrganization.collectAsState()
val tailnetLockEnabled by viewModel.tailNetLockEnabled.collectAsState()
val corpDNSEnabled by viewModel.corpDNSEnabled.collectAsState()
val isVPNPrepared by viewModel.vpnPrepared.collectAsState()
val showTailnetLock by MDMSettings.manageTailnetLock.flow.collectAsState()
val user = viewModel.loggedInUser.collectAsState().value
val isAdmin = viewModel.isAdmin.collectAsState().value
val managedByOrganization = viewModel.managedByOrganization.collectAsState().value
val tailnetLockEnabled = viewModel.tailNetLockEnabled.collectAsState().value
val corpDNSEnabled = viewModel.corpDNSEnabled.collectAsState().value
Scaffold(
topBar = {
Header(titleRes = R.string.settings_title, onBack = settingsNav.onNavigateBackHome)
}) { innerPadding ->
Column(modifier = Modifier.padding(innerPadding).verticalScroll(rememberScrollState())) {
if (isVPNPrepared){
UserView(
profile = user,
actionState = UserActionState.NAV,
onClick = settingsNav.onNavigateToUserSwitcher)
}
UserView(
profile = user,
actionState = UserActionState.NAV,
onClick = settingsNav.onNavigateToUserSwitcher)
if (isAdmin && !isAndroidTV()) {
if (isAdmin) {
Lists.ItemDivider()
AdminTextView { handler.openUri(Links.ADMIN_URL) }
}
@ -77,16 +66,14 @@ fun SettingsView(settingsNav: SettingsNav, viewModel: SettingsViewModel = viewMo
},
onClick = settingsNav.onNavigateToDNSSettings)
if (showTailnetLock == ShowHide.Show) {
Lists.ItemDivider()
Setting.Text(
R.string.tailnet_lock,
subtitle =
tailnetLockEnabled?.let {
stringResource(if (it) R.string.enabled else R.string.disabled)
},
onClick = settingsNav.onNavigateToTailnetLock)
}
Lists.ItemDivider()
Setting.Text(
R.string.tailnet_lock,
subtitle =
tailnetLockEnabled?.let {
stringResource(if (it) R.string.enabled else R.string.disabled)
},
onClick = settingsNav.onNavigateToTailnetLock)
Lists.ItemDivider()
Setting.Text(R.string.permissions, onClick = settingsNav.onNavigateToPermissions)
@ -190,14 +177,3 @@ fun AdminTextView(onNavigateToAdminConsole: () -> Unit) {
Lists.InfoItem(adminStr, onClick = onNavigateToAdminConsole)
}
@Preview
@Composable
fun SettingsPreview() {
val vm = SettingsViewModel()
vm.corpDNSEnabled.set(true)
vm.tailNetLockEnabled.set(true)
vm.isAdmin.set(true)
vm.managedByOrganization.set("Tails and Scales Inc.")
SettingsView(SettingsNav({}, {}, {}, {}, {}, {}, {}, {}, {}, {}), vm)
}

@ -22,16 +22,12 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.tailscale.ipn.ui.theme.topAppBar
import com.tailscale.ipn.ui.theme.ts_color_light_blue
import com.tailscale.ipn.ui.util.AndroidTVUtil.isAndroidTV
typealias BackNavigation = () -> Unit
@ -45,12 +41,6 @@ fun Header(
actions: @Composable RowScope.() -> Unit = {},
onBack: (() -> Unit)? = null
) {
val f = FocusRequester()
if (isAndroidTV()) {
LaunchedEffect(Unit) { f.requestFocus() }
}
TopAppBar(
title = {
title?.let { title() }
@ -61,29 +51,27 @@ fun Header(
},
colors = MaterialTheme.colorScheme.topAppBar,
actions = actions,
navigationIcon = { onBack?.let { BackArrow(action = it, focusRequester = f) } },
navigationIcon = { onBack?.let { BackArrow(action = it) } },
)
}
@Composable
fun BackArrow(action: () -> Unit, focusRequester: FocusRequester) {
fun BackArrow(action: () -> Unit) {
Box(modifier = Modifier.padding(start = 8.dp, end = 8.dp)) {
Icon(
Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Go back to the previous screen",
null,
modifier =
Modifier.focusRequester(focusRequester)
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = rememberRipple(bounded = false),
onClick = { action() }))
Modifier.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = rememberRipple(bounded = false),
onClick = { action() }))
}
}
@Composable
fun CheckedIndicator() {
Icon(Icons.Default.CheckCircle, null, tint = ts_color_light_blue)
Icon(Icons.Default.CheckCircle, "selected", tint = ts_color_light_blue)
}
@Composable

@ -19,7 +19,6 @@ import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
@ -58,7 +57,7 @@ fun TaildropView(
when (viewModel.state.collectAsState().value) {
Ipn.State.Running -> {
val peers by viewModel.myPeers.collectAsState()
val peers = viewModel.myPeers.collectAsState().value
val context = LocalContext.current
FileSharePeerList(
peers = peers,

@ -14,7 +14,6 @@ import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.painterResource
@ -24,16 +23,13 @@ import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.tooling.preview.Preview
import androidx.lifecycle.viewmodel.compose.viewModel
import com.tailscale.ipn.R
import com.tailscale.ipn.ui.Links
import com.tailscale.ipn.ui.theme.defaultTextColor
import com.tailscale.ipn.ui.theme.link
import com.tailscale.ipn.ui.util.ClipboardValueView
import com.tailscale.ipn.ui.util.Lists
import com.tailscale.ipn.ui.util.LoadingIndicator
import com.tailscale.ipn.ui.util.set
import com.tailscale.ipn.ui.viewModel.TailnetLockSetupViewModel
import com.tailscale.ipn.ui.viewModel.TailnetLockSetupViewModelFactory
@ -42,10 +38,9 @@ fun TailnetLockSetupView(
backToSettings: BackNavigation,
model: TailnetLockSetupViewModel = viewModel(factory = TailnetLockSetupViewModelFactory())
) {
val statusItems by model.statusItems.collectAsState()
val nodeKey by model.nodeKey.collectAsState()
val tailnetLockKey by model.tailnetLockKey.collectAsState()
val tailnetLockTlPubKey = tailnetLockKey.replace("nlpub", "tlpub")
val statusItems = model.statusItems.collectAsState().value
val nodeKey = model.nodeKey.collectAsState().value
val tailnetLockKey = model.tailnetLockKey.collectAsState().value
Scaffold(topBar = { Header(R.string.tailnet_lock, onBack = backToSettings) }) { innerPadding ->
LoadingIndicator.Wrap {
@ -78,7 +73,7 @@ fun TailnetLockSetupView(
Lists.SectionDivider()
ClipboardValueView(
value = tailnetLockTlPubKey,
value = tailnetLockKey,
title = stringResource(R.string.tailnet_lock_key),
subtitle = stringResource(R.string.tailnet_lock_key_explainer))
}
@ -102,9 +97,7 @@ private fun ExplainerView() {
@Composable
fun explainerText(): AnnotatedString {
val annotatedString = buildAnnotatedString {
withStyle(SpanStyle(color = MaterialTheme.colorScheme.defaultTextColor)) {
append(stringResource(id = R.string.tailnet_lock_explainer))
}
append(stringResource(id = R.string.tailnet_lock_explainer))
pushStringAnnotation(tag = "tailnetLockSupportURL", annotation = Links.TAILNET_LOCK_KB_URL)
@ -119,12 +112,3 @@ fun explainerText(): AnnotatedString {
}
return annotatedString
}
@Composable
@Preview
fun TailnetLockSetupViewPreview() {
val vm = TailnetLockSetupViewModel()
vm.nodeKey.set("8BADF00D-EA7-1337-DEAD-BEEF")
vm.tailnetLockKey.set("C0FFEE-CAFE-50DA")
TailnetLockSetupView(backToSettings = {}, vm)
}

@ -14,10 +14,6 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import com.tailscale.ipn.ui.theme.onBackgroundLogoDotDisabled
import com.tailscale.ipn.ui.theme.onBackgroundLogoDotEnabled
import com.tailscale.ipn.ui.theme.standaloneLogoDotDisabled
import com.tailscale.ipn.ui.theme.standaloneLogoDotEnabled
import com.tailscale.ipn.ui.util.set
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@ -35,24 +31,10 @@ val logoDotsMatrix: DotsMatrix =
)
@Composable
fun TailscaleLogoView(
animated: Boolean = false,
usesOnBackgroundColors: Boolean = false,
modifier: Modifier
) {
fun TailscaleLogoView(animated: Boolean = false, modifier: Modifier) {
val primaryColor: Color =
if (usesOnBackgroundColors) {
MaterialTheme.colorScheme.onBackgroundLogoDotEnabled
} else {
MaterialTheme.colorScheme.standaloneLogoDotEnabled
}
val secondaryColor: Color =
if (usesOnBackgroundColors) {
MaterialTheme.colorScheme.onBackgroundLogoDotDisabled
} else {
MaterialTheme.colorScheme.standaloneLogoDotDisabled
}
val primaryColor: Color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.3f)
val secondaryColor: Color = primaryColor.copy(alpha = 0.1f)
val currentDotsMatrix: StateFlow<DotsMatrix> = MutableStateFlow(logoDotsMatrix)
var currentDotsMatrixIndex = 0

@ -7,6 +7,7 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
@ -18,16 +19,19 @@ import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.tailscale.ipn.R
@ -36,32 +40,26 @@ import com.tailscale.ipn.ui.util.itemsWithDividers
import com.tailscale.ipn.ui.util.set
import com.tailscale.ipn.ui.viewModel.UserSwitcherViewModel
data class UserSwitcherNav(
val backToSettings: BackNavigation,
val onNavigateHome: () -> Unit,
val onNavigateCustomControl: () -> Unit,
val onNavigateToAuthKey: () -> Unit
)
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun UserSwitcherView(nav: UserSwitcherNav, viewModel: UserSwitcherViewModel = viewModel()) {
fun UserSwitcherView(
backToSettings: BackNavigation,
onNavigateHome: () -> Unit,
viewModel: UserSwitcherViewModel = viewModel()
) {
val users by viewModel.loginProfiles.collectAsState()
val currentUser by viewModel.loggedInUser.collectAsState()
val showHeaderMenu by viewModel.showHeaderMenu.collectAsState()
val users = viewModel.loginProfiles.collectAsState().value
val currentUser = viewModel.loggedInUser.collectAsState().value
val showHeaderMenu = viewModel.showHeaderMenu.collectAsState().value
Scaffold(
topBar = {
Header(
R.string.accounts,
onBack = nav.backToSettings,
onBack = backToSettings,
actions = {
Row {
FusMenu(
viewModel = viewModel,
onAuthKeyClick = nav.onNavigateToAuthKey,
onCustomClick = nav.onNavigateCustomControl)
FusMenu(viewModel = viewModel)
IconButton(onClick = { viewModel.showHeaderMenu.set(!showHeaderMenu) }) {
Icon(Icons.Default.MoreVert, "menu")
}
@ -71,7 +69,7 @@ fun UserSwitcherView(nav: UserSwitcherNav, viewModel: UserSwitcherViewModel = vi
Column(
modifier = Modifier.padding(innerPadding).fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(8.dp)) {
val showErrorDialog by viewModel.errorDialog.collectAsState()
val showErrorDialog = viewModel.errorDialog.collectAsState().value
// Show the error overlay if need be
showErrorDialog?.let {
@ -104,7 +102,7 @@ fun UserSwitcherView(nav: UserSwitcherNav, viewModel: UserSwitcherViewModel = vi
viewModel.errorDialog.set(ErrorDialogType.LOGOUT_FAILED)
nextUserId.value = null
} else {
nav.onNavigateHome()
onNavigateHome()
}
}
})
@ -122,7 +120,7 @@ fun UserSwitcherView(nav: UserSwitcherNav, viewModel: UserSwitcherViewModel = vi
}
Lists.ItemDivider()
Setting.Text(R.string.reauthenticate) { viewModel.login() }
Setting.Text(R.string.reauthenticate) { viewModel.login {} }
if (currentUser != null) {
Lists.ItemDivider()
@ -131,10 +129,9 @@ fun UserSwitcherView(nav: UserSwitcherNav, viewModel: UserSwitcherViewModel = vi
destructive = true,
onClick = {
viewModel.logout {
it.onSuccess { nav.onNavigateHome() }
.onFailure {
viewModel.errorDialog.set(ErrorDialogType.LOGOUT_FAILED)
}
if (it.isFailure) {
viewModel.errorDialog.set(ErrorDialogType.LOGOUT_FAILED)
}
}
})
}
@ -145,49 +142,50 @@ fun UserSwitcherView(nav: UserSwitcherNav, viewModel: UserSwitcherViewModel = vi
}
@Composable
fun FusMenu(
onCustomClick: () -> Unit,
onAuthKeyClick: () -> Unit,
viewModel: UserSwitcherViewModel
) {
val expanded by viewModel.showHeaderMenu.collectAsState()
fun FusMenu(viewModel: UserSwitcherViewModel) {
var url by remember { mutableStateOf("") }
val expanded = viewModel.showHeaderMenu.collectAsState().value
DropdownMenu(
expanded = expanded,
onDismissRequest = { viewModel.showHeaderMenu.set(false) },
onDismissRequest = {
url = ""
viewModel.showHeaderMenu.set(false)
},
modifier = Modifier.background(MaterialTheme.colorScheme.surfaceContainer)) {
MenuItem(
onClick = {
onCustomClick()
viewModel.showHeaderMenu.set(false)
},
text = stringResource(id = R.string.custom_control_menu))
MenuItem(
onClick = {
onAuthKeyClick()
viewModel.showHeaderMenu.set(false)
},
text = stringResource(id = R.string.auth_key_menu))
DropdownMenuItem(
onClick = {},
text = {
Column {
Text(
stringResource(id = R.string.custom_control_menu),
style = MaterialTheme.typography.titleMedium)
Spacer(modifier = Modifier.padding(2.dp))
Text(
stringResource(id = R.string.custom_control_menu_desc),
style = MaterialTheme.typography.bodySmall)
Spacer(modifier = Modifier.padding(8.dp))
OutlinedTextField(
colors =
TextFieldDefaults.colors(
focusedContainerColor = Color.Transparent,
unfocusedContainerColor = Color.Transparent),
textStyle = MaterialTheme.typography.bodyMedium,
value = url,
onValueChange = { url = it },
placeholder = {
Text(
stringResource(id = R.string.custom_control_placeholder),
style = MaterialTheme.typography.bodySmall)
})
Spacer(modifier = Modifier.padding(8.dp))
PrimaryActionButton(onClick = { viewModel.setControlURL(url) }) {
Text(stringResource(id = R.string.add_account_short))
}
}
})
}
}
@Composable
fun MenuItem(text: String, onClick: () -> Unit) {
DropdownMenuItem(
modifier = Modifier.padding(horizontal = 8.dp, vertical = 0.dp),
onClick = onClick,
text = { Text(text = text) })
}
@Composable
@Preview
fun UserSwitcherViewPreview() {
val vm = UserSwitcherViewModel()
val nav =
UserSwitcherNav(
backToSettings = {},
onNavigateHome = {},
onNavigateCustomControl = {},
onNavigateToAuthKey = {})
UserSwitcherView(nav, vm)
}

@ -1,52 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn.ui.viewModel
import com.tailscale.ipn.ui.util.set
import com.tailscale.ipn.ui.view.ErrorDialogType
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
const val AUTH_KEY_LENGTH = 16
open class CustomLoginViewModel : IpnViewModel() {
val errorDialog: StateFlow<ErrorDialogType?> = MutableStateFlow(null)
}
class LoginWithAuthKeyViewModel : CustomLoginViewModel() {
// Sets the auth key and invokes the login flow
fun setAuthKey(authKey: String, onSuccess: () -> Unit) {
// The most basic of checks for auth key syntax
if (authKey.isEmpty()) {
errorDialog.set(ErrorDialogType.INVALID_AUTH_KEY)
return
}
loginWithAuthKey(authKey) {
it.onFailure { errorDialog.set(ErrorDialogType.ADD_PROFILE_FAILED) }
it.onSuccess { onSuccess() }
}
}
}
class LoginWithCustomControlURLViewModel : CustomLoginViewModel() {
// Sets the custom control URL and invokes the login flow
fun setControlURL(urlStr: String, onSuccess: () -> Unit) {
// Some basic checks that the entered URL is "reasonable". The underlying
// localAPIClient will use the default server if we give it a broken URL,
// but we can make sure we can construct a URL from the input string and
// ensure it has an http/https scheme
when (urlStr.startsWith("http") && urlStr.contains("://") && urlStr.length > 7) {
false -> {
errorDialog.set(ErrorDialogType.INVALID_CUSTOM_URL)
return
}
true -> {
loginWithCustomControlURL(urlStr) {
it.onFailure { errorDialog.set(ErrorDialogType.ADD_PROFILE_FAILED) }
it.onSuccess { onSuccess() }
}
}
}
}
}

@ -39,7 +39,7 @@ class ExitNodePickerViewModel(private val nav: ExitNodePickerNav) : IpnViewModel
data class ExitNode(
val id: StableNodeID? = null,
val label: String,
val online: StateFlow<Boolean>,
val online: Boolean,
val selected: Boolean,
val mullvad: Boolean = false,
val priority: Int = 0,
@ -54,6 +54,7 @@ class ExitNodePickerViewModel(private val nav: ExitNodePickerNav) : IpnViewModel
val mullvadBestAvailableByCountry: StateFlow<Map<String, ExitNode>> = MutableStateFlow(TreeMap())
val mullvadExitNodeCount: StateFlow<Int> = MutableStateFlow(0)
val anyActive: StateFlow<Boolean> = MutableStateFlow(false)
val isRunningExitNode: StateFlow<Boolean> = MutableStateFlow(false)
init {
viewModelScope.launch {
@ -61,7 +62,8 @@ class ExitNodePickerViewModel(private val nav: ExitNodePickerNav) : IpnViewModel
.combine(Notifier.prefs) { netmap, prefs -> Pair(netmap, prefs) }
.stateIn(viewModelScope)
.collect { (netmap, prefs) ->
val exitNodeId = prefs?.activeExitNodeID ?: prefs?.selectedExitNodeID
isRunningExitNode.set(prefs?.let { AdvertisedRoutesHelper.exitNodeOnFromPrefs(it) })
val exitNodeId = prefs?.ExitNodeID
netmap?.Peers?.let { peers ->
val allNodes =
peers
@ -70,7 +72,7 @@ class ExitNodePickerViewModel(private val nav: ExitNodePickerNav) : IpnViewModel
ExitNode(
id = it.StableID,
label = it.displayName,
online = MutableStateFlow(it.Online ?: false),
online = it.Online ?: false,
selected = it.StableID == exitNodeId,
mullvad = it.Name.endsWith(".mullvad.ts.net."),
priority = it.Hostinfo.Location?.Priority ?: 0,
@ -84,10 +86,9 @@ class ExitNodePickerViewModel(private val nav: ExitNodePickerNav) : IpnViewModel
tailnetExitNodes.set(tailnetNodes.sortedWith { a, b -> a.label.compareTo(b.label) })
val allMullvadExitNodes =
allNodes.filter { node ->
allNodes.filter {
// Pick all mullvad nodes that are online or the currently selected
val online = node.online.value
node.mullvad && (node.selected || online)
it.mullvad && (it.selected || it.online)
}
val mullvadExitNodes =
allMullvadExitNodes
@ -136,7 +137,6 @@ class ExitNodePickerViewModel(private val nav: ExitNodePickerNav) : IpnViewModel
LoadingIndicator.start()
val prefsOut = Ipn.MaskedPrefs()
prefsOut.ExitNodeID = node.id
Client(viewModelScope).editPrefs(prefsOut) {
nav.onNavigateBackHome()
LoadingIndicator.stop()

@ -3,24 +3,23 @@
package com.tailscale.ipn.ui.viewModel
import android.net.VpnService
import android.app.Activity
import android.content.Context
import android.content.ContextWrapper
import android.content.Intent
import android.util.Log
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.tailscale.ipn.App
import com.tailscale.ipn.UninitializedApp
import com.tailscale.ipn.mdm.MDMSettings
import com.tailscale.ipn.IPNReceiver
import com.tailscale.ipn.ui.localapi.Client
import com.tailscale.ipn.ui.model.Ipn
import com.tailscale.ipn.ui.model.IpnLocal
import com.tailscale.ipn.ui.model.UserID
import com.tailscale.ipn.ui.notifier.Notifier
import com.tailscale.ipn.ui.util.AdvertisedRoutesHelper
import com.tailscale.ipn.ui.util.LoadingIndicator
import com.tailscale.ipn.ui.util.set
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.launch
/**
@ -33,47 +32,10 @@ open class IpnViewModel : ViewModel() {
val loggedInUser: StateFlow<IpnLocal.LoginProfile?> = MutableStateFlow(null)
val loginProfiles: StateFlow<List<IpnLocal.LoginProfile>?> = MutableStateFlow(null)
private val _vpnPrepared = MutableStateFlow(false)
val vpnPrepared: StateFlow<Boolean> = _vpnPrepared
// The userId associated with the current node. ie: The logged in user.
private var selfNodeUserId: UserID? = null
val isRunningExitNode: StateFlow<Boolean> = MutableStateFlow(false)
private var lastPrefs: Ipn.Prefs? = null
val prefs = Notifier.prefs
val netmap = Notifier.netmap
private val _nodeState = MutableStateFlow(NodeState.NONE)
val nodeState: StateFlow<NodeState> = _nodeState
val managedByOrganization = MDMSettings.managedByOrganizationName.flow
enum class NodeState {
NONE,
ACTIVE_AND_RUNNING,
// Last selected exit node is active but is not being used.
ACTIVE_NOT_RUNNING,
// Last selected exit node is currently offline.
OFFLINE_ENABLED,
// Last selected exit node has been de-selected and is currently offline.
OFFLINE_DISABLED,
// Exit node selection is managed by an administrator, and last selected exit node is currently
// offline
OFFLINE_MDM,
RUNNING_AS_EXIT_NODE
}
init {
// Check if the user has granted permission yet.
if (!vpnPrepared.value) {
val vpnIntent = VpnService.prepare(App.get())
if (vpnIntent != null) {
setVpnPrepared(false)
} else {
setVpnPrepared(true)
}
}
viewModelScope.launch {
Notifier.state.collect {
// Reload the user profiles on all state transitions to ensure loggedInUser is correct
@ -94,134 +56,59 @@ open class IpnViewModel : ViewModel() {
}
}
viewModelScope.launch {
Notifier.prefs.collect {
it?.let {
lastPrefs = it
isRunningExitNode.set(AdvertisedRoutesHelper.exitNodeOnFromPrefs(it))
}
}
}
viewModelScope.launch { loadUserProfiles() }
viewModelScope.launch {
combine(prefs, netmap, isRunningExitNode) { prefs, netmap, isRunningExitNode ->
// Handle nullability for prefs and netmap
val validPrefs = prefs ?: return@combine NodeState.NONE
val validNetmap = netmap ?: return@combine NodeState.NONE
val chosenExitNodeId = validPrefs.activeExitNodeID ?: validPrefs.selectedExitNodeID
val exitNodePeer =
chosenExitNodeId?.let { id -> validNetmap.Peers?.find { it.StableID == id } }
when {
exitNodePeer?.Online == false -> {
if (MDMSettings.exitNodeID.flow.value != null) {
NodeState.OFFLINE_MDM
} else if (validPrefs.activeExitNodeID != null) {
NodeState.OFFLINE_ENABLED
} else {
NodeState.OFFLINE_DISABLED
}
}
exitNodePeer != null -> {
if (!validPrefs.activeExitNodeID.isNullOrEmpty()) {
NodeState.ACTIVE_AND_RUNNING
} else {
NodeState.ACTIVE_NOT_RUNNING
}
}
isRunningExitNode == true -> {
NodeState.RUNNING_AS_EXIT_NODE
}
else -> {
NodeState.NONE
}
}
}
.collect { nodeState -> _nodeState.value = nodeState }
}
Log.d(TAG, "Created")
}
// VPN Control
fun setVpnPrepared(prepared: Boolean) {
_vpnPrepared.value = prepared
}
fun startVPN() {
UninitializedApp.get().startVPN()
}
fun stopVPN() {
UninitializedApp.get().stopVPN()
}
// Login/Logout
fun login(
maskedPrefs: Ipn.MaskedPrefs? = null,
authKey: String? = null,
completionHandler: (Result<Unit>) -> Unit = {}
) {
val loginAction = {
Client(viewModelScope).startLoginInteractive { result ->
result
.onSuccess { Log.d(TAG, "Login started: $it") }
.onFailure { Log.e(TAG, "Error starting login: ${it.message}") }
completionHandler(result)
protected fun Context.findActivity(): Activity? =
when (this) {
is Activity -> this
is ContextWrapper -> baseContext.findActivity()
else -> null
}
}
// Need to stop running before logging in to clear routes:
// https://linear.app/tailscale/issue/ENG-3441/routesdns-is-not-cleared-when-switching-profiles-or-reauthenticating
val stopThenLogin = {
Client(viewModelScope).editPrefs(Ipn.MaskedPrefs().apply { WantRunning = false }) { result ->
result
.onSuccess { loginAction() }
.onFailure { Log.e(TAG, "Error setting wantRunning to false: ${it.message}") }
private fun loadUserProfiles() {
Client(viewModelScope).profiles { result ->
result.onSuccess(loginProfiles::set).onFailure {
Log.e(TAG, "Error loading profiles: ${it.message}")
}
}
val startAction = {
Client(viewModelScope).start(Ipn.Options(AuthKey = authKey)) { start ->
start.onFailure { completionHandler(Result.failure(it)) }.onSuccess { stopThenLogin() }
}
Client(viewModelScope).currentProfile { result ->
result
.onSuccess { loggedInUser.set(if (it.isEmpty()) null else it) }
.onFailure { Log.e(TAG, "Error loading current profile: ${it.message}") }
}
}
// If an MDM control URL is set, we will always use that in lieu of anything the user sets.
var prefs = maskedPrefs
val mdmControlURL = MDMSettings.loginURL.flow.value
if (mdmControlURL != null) {
prefs = prefs ?: Ipn.MaskedPrefs()
prefs.ControlURL = mdmControlURL
Log.d(TAG, "Overriding control URL with MDM value: $mdmControlURL")
fun toggleVpn() {
when (Notifier.state.value) {
Ipn.State.Running -> stopVPN()
else -> startVPN()
}
}
prefs?.let {
Client(viewModelScope).editPrefs(it) { result ->
result.onFailure { completionHandler(Result.failure(it)) }.onSuccess { startAction() }
}
} ?: run { startAction() }
fun startVPN() {
val context = App.getApplication().applicationContext
val intent = Intent(context, IPNReceiver::class.java)
intent.action = IPNReceiver.INTENT_CONNECT_VPN
context.sendBroadcast(intent)
}
fun loginWithAuthKey(authKey: String, completionHandler: (Result<Unit>) -> Unit = {}) {
val prefs = Ipn.MaskedPrefs()
prefs.WantRunning = true
login(prefs, authKey = authKey, completionHandler)
fun stopVPN() {
val context = App.getApplication().applicationContext
val intent = Intent(context, IPNReceiver::class.java)
intent.action = IPNReceiver.INTENT_DISCONNECT_VPN
context.sendBroadcast(intent)
}
fun loginWithCustomControlURL(
controlURL: String,
completionHandler: (Result<Unit>) -> Unit = {}
) {
val prefs = Ipn.MaskedPrefs()
prefs.ControlURL = controlURL
login(prefs, completionHandler = completionHandler)
fun login(completionHandler: (Result<Unit>) -> Unit = {}) {
Client(viewModelScope).startLoginInteractive { result ->
result
.onSuccess { Log.d(TAG, "Login started: $it") }
.onFailure { Log.e(TAG, "Error starting login: ${it.message}") }
completionHandler(result)
}
}
fun logout(completionHandler: (Result<String>) -> Unit = {}) {
@ -233,40 +120,17 @@ open class IpnViewModel : ViewModel() {
}
}
// User Profiles
private fun loadUserProfiles() {
Client(viewModelScope).profiles { result ->
result.onSuccess(loginProfiles::set).onFailure {
Log.e(TAG, "Error loading profiles: ${it.message}")
}
}
Client(viewModelScope).currentProfile { result ->
result
.onSuccess { loggedInUser.set(if (it.isEmpty()) null else it) }
.onFailure { Log.e(TAG, "Error loading current profile: ${it.message}") }
}
}
fun switchProfile(profile: IpnLocal.LoginProfile, completionHandler: (Result<String>) -> Unit) {
val switchProfile = {
Client(viewModelScope).switchProfile(profile) {
startVPN()
completionHandler(it)
}
}
Client(viewModelScope).editPrefs(Ipn.MaskedPrefs().apply { WantRunning = false }) { result ->
result
.onSuccess { switchProfile() }
.onFailure { Log.e(TAG, "Error setting wantRunning to false: ${it.message}") }
Client(viewModelScope).switchProfile(profile) {
startVPN()
completionHandler(it)
}
}
fun addProfile(completionHandler: (Result<String>) -> Unit) {
Client(viewModelScope).addProfile {
if (it.isSuccess) {
login()
login {}
}
startVPN()
completionHandler(it)
@ -280,58 +144,51 @@ open class IpnViewModel : ViewModel() {
}
}
// Exit Node Manipulation
fun toggleExitNode() {
val prefs = prefs.value ?: return
LoadingIndicator.start()
if (prefs.activeExitNodeID != null) {
// We have an active exit node so we should keep it, but disable it
Client(viewModelScope).setUseExitNode(false) { LoadingIndicator.stop() }
} else if (prefs.selectedExitNodeID != null) {
// We have a prior exit node to enable
Client(viewModelScope).setUseExitNode(true) { LoadingIndicator.stop() }
} else {
// This should not be possible. In this state the button is hidden
Log.e(TAG, "No exit node to disable and no prior exit node to enable")
}
}
// The below handle all types of preference modifications typically invoked by the UI.
// Callers generally shouldn't care about the returned prefs value - the source of
// truth is the IPNModel, who's prefs flow will change in value to reflect the true
// value of the pref setting in the back end (and will match the value returned here).
// Generally, you will want to inspect the returned value in the callback for errors
// to indicate why a particular setting did not change in the interface.
//
// Usage:
// - User/Interface changed to new value. Render the new value.
// - Submit the new value to the PrefsEditor
// - Observe the prefs on the IpnModel and update the UI when/if the value changes.
// For a typical flow, the changed value should reflect the value already shown.
// - Inform the user of any error which may have occurred
//
// The "toggle' functions here will attempt to set the pref value to the inverse of
// what is currently known in the IpnModel.prefs. If IpnModel.prefs is not available,
// the callback will be called with a NO_PREFS error
fun setWantRunning(wantRunning: Boolean, callback: (Result<Ipn.Prefs>) -> Unit) {
Ipn.MaskedPrefs().WantRunning = wantRunning
Client(viewModelScope).editPrefs(Ipn.MaskedPrefs(), callback)
}
fun toggleShieldsUp(callback: (Result<Ipn.Prefs>) -> Unit) {
val prefs =
Notifier.prefs.value
?: run {
callback(Result.failure(Exception("no prefs")))
return@toggleShieldsUp
}
fun setRunningExitNode(isOn: Boolean) {
LoadingIndicator.start()
lastPrefs?.let { currentPrefs ->
val newPrefs: Ipn.MaskedPrefs
if (isOn) {
newPrefs = setZeroRoutes(currentPrefs)
} else {
newPrefs = removeAllZeroRoutes(currentPrefs)
}
Client(viewModelScope).editPrefs(newPrefs) { result ->
LoadingIndicator.stop()
Log.d("RunExitNodeViewModel", "Edited prefs: $result")
}
}
val prefsOut = Ipn.MaskedPrefs()
prefsOut.ShieldsUp = !prefs.ShieldsUp
Client(viewModelScope).editPrefs(prefsOut, callback)
}
private fun setZeroRoutes(prefs: Ipn.Prefs): Ipn.MaskedPrefs {
val newRoutes = (removeAllZeroRoutes(prefs).AdvertiseRoutes ?: emptyList()).toMutableList()
newRoutes.add("0.0.0.0/0")
newRoutes.add("::/0")
val newPrefs = Ipn.MaskedPrefs()
newPrefs.AdvertiseRoutes = newRoutes
return newPrefs
}
fun toggleRouteAll(callback: (Result<Ipn.Prefs>) -> Unit) {
val prefs =
Notifier.prefs.value
?: run {
callback(Result.failure(Exception("no prefs")))
return@toggleRouteAll
}
private fun removeAllZeroRoutes(prefs: Ipn.Prefs): Ipn.MaskedPrefs {
val newRoutes = emptyList<String>().toMutableList()
(prefs.AdvertiseRoutes ?: emptyList()).forEach {
if (it != "0.0.0.0/0" && it != "::/0") {
newRoutes.add(it)
}
}
val newPrefs = Ipn.MaskedPrefs()
newPrefs.AdvertiseRoutes = newRoutes
return newPrefs
val prefsOut = Ipn.MaskedPrefs()
prefsOut.RouteAll = !prefs.RouteAll
Client(viewModelScope).editPrefs(prefsOut, callback)
}
}

@ -3,38 +3,27 @@
package com.tailscale.ipn.ui.viewModel
import android.content.Intent
import android.net.VpnService
import androidx.activity.result.ActivityResultLauncher
import androidx.lifecycle.viewModelScope
import com.tailscale.ipn.App
import com.tailscale.ipn.R
import com.tailscale.ipn.mdm.MDMSettings
import com.tailscale.ipn.ui.localapi.Client
import com.tailscale.ipn.ui.model.Ipn
import com.tailscale.ipn.ui.model.Ipn.State
import com.tailscale.ipn.ui.notifier.Notifier
import com.tailscale.ipn.ui.util.AndroidTVUtil.isAndroidTV
import com.tailscale.ipn.ui.util.LoadingIndicator
import com.tailscale.ipn.ui.util.PeerCategorizer
import com.tailscale.ipn.ui.util.PeerSet
import com.tailscale.ipn.ui.util.TimeUtil
import com.tailscale.ipn.ui.util.set
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.launch
import java.time.Duration
class MainViewModel : IpnViewModel() {
// The user readable state of the system
val stateRes: StateFlow<Int> = MutableStateFlow(userStringRes(State.NoState, State.NoState, true))
val stateRes: StateFlow<Int> = MutableStateFlow(State.NoState.userStringRes())
// The expected state of the VPN toggle
private val _vpnToggleState = MutableStateFlow(false)
val vpnToggleState: StateFlow<Boolean> = _vpnToggleState
// Permission to prepare VPN
private var vpnPermissionLauncher: ActivityResultLauncher<Intent>? = null
val vpnToggleState: StateFlow<Boolean> = MutableStateFlow(false)
// The list of peers
val peers: StateFlow<List<PeerSet>> = MutableStateFlow(emptyList<PeerSet>())
@ -42,32 +31,21 @@ class MainViewModel : IpnViewModel() {
// The current state of the IPN for determining view visibility
val ipnState = Notifier.state
val prefs = Notifier.prefs
val netmap = Notifier.netmap
// The active search term for filtering peers
val searchTerm: StateFlow<String> = MutableStateFlow("")
// True if we should render the key expiry bannder
val showExpiry: StateFlow<Boolean> = MutableStateFlow(false)
private val peerCategorizer = PeerCategorizer()
init {
viewModelScope.launch {
var previousState: State? = null
combine(Notifier.state, vpnPrepared) { state, prepared -> state to prepared }
.collect { (currentState, prepared) ->
stateRes.set(userStringRes(currentState, previousState, prepared))
val isOn =
when {
currentState == State.Running || currentState == State.Starting -> true
previousState == State.NoState && currentState == State.Starting -> true
else -> false
}
_vpnToggleState.value = isOn
previousState = currentState
}
Notifier.state.collect { state ->
stateRes.set(state.userStringRes())
vpnToggleState.set(
(state == State.Running || state == State.Starting || state == State.NoState))
}
}
viewModelScope.launch {
@ -75,18 +53,6 @@ class MainViewModel : IpnViewModel() {
it?.let { netmap ->
peerCategorizer.regenerateGroupedPeers(netmap)
peers.set(peerCategorizer.groupedAndFilteredPeers(searchTerm.value))
if (netmap.SelfNode.keyDoesNotExpire) {
showExpiry.set(false)
return@let
} else {
val expiryNotificationWindowMDM = MDMSettings.keyExpirationNotice.flow.value
val window =
expiryNotificationWindowMDM?.let { TimeUtil.duration(it) } ?: Duration.ofHours(24)
val expiresSoon =
TimeUtil.isWithinExpiryNotificationWindow(window, it.SelfNode.KeyExpiry)
showExpiry.set(expiresSoon)
}
}
}
}
@ -96,49 +62,27 @@ class MainViewModel : IpnViewModel() {
}
}
fun showVPNPermissionLauncherIfUnauthorized() {
val vpnIntent = VpnService.prepare(App.get())
if (vpnIntent != null) {
vpnPermissionLauncher?.launch(vpnIntent)
} else {
setVpnPrepared(true)
startVPN()
}
}
fun toggleVpn() {
val state = Notifier.state.value
val isPrepared = vpnPrepared.value
when {
!isPrepared -> showVPNPermissionLauncherIfUnauthorized()
state == Ipn.State.Running -> stopVPN()
state == Ipn.State.NeedsLogin && isAndroidTV() -> login()
else -> startVPN()
}
}
fun searchPeers(searchTerm: String) {
this.searchTerm.set(searchTerm)
}
fun setVpnPermissionLauncher(launcher: ActivityResultLauncher<Intent>) {
// No intent means we're already authorized
vpnPermissionLauncher = launcher
fun disableExitNode() {
LoadingIndicator.start()
val prefsOut = Ipn.MaskedPrefs()
prefsOut.ExitNodeID = null
Client(viewModelScope).editPrefs(prefsOut) { LoadingIndicator.stop() }
}
}
private fun userStringRes(currentState: State?, previousState: State?, vpnPrepared: Boolean): Int {
return when {
previousState == State.NoState && currentState == State.Starting -> R.string.starting
currentState == State.NoState -> R.string.placeholder
currentState == State.InUseOtherUser -> R.string.placeholder
currentState == State.NeedsLogin ->
if (vpnPrepared) R.string.please_login else R.string.connect_to_vpn
currentState == State.NeedsMachineAuth -> R.string.needs_machine_auth
currentState == State.Stopped -> R.string.stopped
currentState == State.Starting -> R.string.starting
currentState == State.Running -> R.string.connected
private fun State?.userStringRes(): Int {
return when (this) {
State.NoState -> R.string.waiting
State.InUseOtherUser -> R.string.placeholder
State.NeedsLogin -> R.string.please_login
State.NeedsMachineAuth -> R.string.placeholder
State.Stopped -> R.string.stopped
State.Starting -> R.string.starting
State.Running -> R.string.connected
else -> R.string.placeholder
}
}

@ -27,6 +27,7 @@ class PeerDetailsViewModelFactory(private val nodeId: StableNodeID, private val
}
class PeerDetailsViewModel(val nodeId: StableNodeID, val filesDir: File) : IpnViewModel() {
val netmap: StateFlow<Netmap.NetworkMap?> = MutableStateFlow(null)
val node: StateFlow<Tailcfg.Node?> = MutableStateFlow(null)
init {

@ -0,0 +1,97 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn.ui.viewModel
import android.util.Log
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import com.tailscale.ipn.ui.localapi.Client
import com.tailscale.ipn.ui.model.Ipn
import com.tailscale.ipn.ui.notifier.Notifier
import com.tailscale.ipn.ui.util.LoadingIndicator
import com.tailscale.ipn.ui.util.set
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
class RunExitNodeViewModelFactory() : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return RunExitNodeViewModel() as T
}
}
class AdvertisedRoutesHelper() {
companion object {
fun exitNodeOnFromPrefs(prefs: Ipn.Prefs): Boolean {
var v4 = false
var v6 = false
prefs.AdvertiseRoutes?.forEach {
if (it == "0.0.0.0/0") {
v4 = true
}
if (it == "::/0") {
v6 = true
}
}
return v4 && v6
}
}
}
class RunExitNodeViewModel() : IpnViewModel() {
val isRunningExitNode: StateFlow<Boolean> = MutableStateFlow(false)
var lastPrefs: Ipn.Prefs? = null
init {
viewModelScope.launch {
Notifier.prefs.stateIn(viewModelScope).collect { prefs ->
Log.d("RunExitNode", "prefs: AdvertiseRoutes=" + prefs?.AdvertiseRoutes.toString())
prefs?.let {
lastPrefs = it
isRunningExitNode.set(AdvertisedRoutesHelper.exitNodeOnFromPrefs(it))
} ?: run { isRunningExitNode.set(false) }
}
}
}
fun setRunningExitNode(isOn: Boolean) {
LoadingIndicator.start()
lastPrefs?.let { currentPrefs ->
val newPrefs: Ipn.MaskedPrefs
if (isOn) {
newPrefs = setZeroRoutes(currentPrefs)
} else {
newPrefs = removeAllZeroRoutes(currentPrefs)
}
Client(viewModelScope).editPrefs(newPrefs) { result ->
LoadingIndicator.stop()
Log.d("RunExitNodeViewModel", "Edited prefs: $result")
}
}
}
private fun setZeroRoutes(prefs: Ipn.Prefs): Ipn.MaskedPrefs {
val newRoutes = (removeAllZeroRoutes(prefs).AdvertiseRoutes ?: emptyList()).toMutableList()
newRoutes.add("0.0.0.0/0")
newRoutes.add("::/0")
val newPrefs = Ipn.MaskedPrefs()
newPrefs.AdvertiseRoutes = newRoutes
return newPrefs
}
private fun removeAllZeroRoutes(prefs: Ipn.Prefs): Ipn.MaskedPrefs {
val newRoutes = emptyList<String>().toMutableList()
(prefs.AdvertiseRoutes ?: emptyList()).forEach {
if (it != "0.0.0.0/0" && it != "::/0") {
newRoutes.add(it)
}
}
val newPrefs = Ipn.MaskedPrefs()
newPrefs.AdvertiseRoutes = newRoutes
return newPrefs
}
}

@ -29,6 +29,7 @@ data class SettingsNav(
class SettingsViewModel : IpnViewModel() {
// Display name for the logged in user
val isAdmin: StateFlow<Boolean> = MutableStateFlow(false)
val managedByOrganization = MDMSettings.managedByOrganizationName.flow
// True if tailnet lock is enabled. nil if not yet known.
val tailNetLockEnabled: StateFlow<Boolean?> = MutableStateFlow(null)
// True if tailscaleDNS is enabled. nil if not yet known.

@ -153,7 +153,7 @@ class TaildropViewModel(
fun TrailingContentForPeer(peerId: String) {
// Check our outgoing files for the peer and determine the state of the transfer.
val transfers = this.transfers.collectAsState().value.filter { it.PeerID == peerId }
val status: TransferState = transferState(transfers) ?: return
var status: TransferState = transferState(transfers) ?: return
// Still no status? Nothing to render for this peer

@ -3,6 +3,11 @@
package com.tailscale.ipn.ui.viewModel
import androidx.lifecycle.viewModelScope
import com.tailscale.ipn.ui.localapi.Client
import com.tailscale.ipn.ui.model.Ipn
import com.tailscale.ipn.ui.notifier.Notifier
import com.tailscale.ipn.ui.util.set
import com.tailscale.ipn.ui.view.ErrorDialogType
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@ -14,4 +19,49 @@ class UserSwitcherViewModel : IpnViewModel() {
// True if we should render the kebab menu
val showHeaderMenu: StateFlow<Boolean> = MutableStateFlow(false)
// Sets the custom control URL and immediatly invokes the login flow
fun setControlURL(urlStr: String) {
// Some basic checks that the entered URL is "reasonable". The underlying
// localAPIClient will use the default server if we give it a broken URL,
// but we can make sure we can construct a URL from the input string and
// ensure it has an http/https scheme
when (urlStr.startsWith("http") && urlStr.contains("://") && urlStr.length > 7) {
false -> errorDialog.set(ErrorDialogType.INVALID_CUSTOM_URL)
true -> {
showHeaderMenu.set(false)
// We need to have the current prefs to set them back with the new control URL
val prefs = Notifier.prefs.value
if (prefs == null) {
errorDialog.set(ErrorDialogType.ADD_PROFILE_FAILED)
return
}
// The basic flow for logging in with a custom control URL is to add a profile,
// call start with prefs that include the control URL pref, then
// start an interactive login.
val fail: (Throwable) -> Unit = { errorDialog.set(ErrorDialogType.ADD_PROFILE_FAILED) }
val login = {
Client(viewModelScope).startLoginInteractive { startLogin -> startLogin.onFailure(fail) }
}
val start = {
prefs.ControlURL = urlStr
val options = Ipn.Options(Prefs = prefs)
Client(viewModelScope).start(options) { start ->
start.onFailure(fail).onSuccess { login() }
}
}
Client(viewModelScope).addProfile { addProfile ->
addProfile.onFailure(fail).onSuccess { start() }
}
}
}
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 641 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 406 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 879 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

@ -0,0 +1,41 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="200dp"
android:height="200dp"
android:viewportWidth="200"
android:viewportHeight="200">
<path
android:pathData="M0,0h200v200h-200z"
android:fillColor="#1F1E1E"/>
<path
android:pathData="M50,62.5a12.5,12.5 0,1 0,25 0a12.5,12.5 0,1 0,-25 0z"
android:fillColor="#ffffff"
android:fillAlpha="0.4"/>
<path
android:pathData="M87.5,62.5a12.5,12.5 0,1 0,25 0a12.5,12.5 0,1 0,-25 0z"
android:fillColor="#ffffff"
android:fillAlpha="0.4"/>
<path
android:pathData="M125,62.5a12.5,12.5 0,1 0,25 0a12.5,12.5 0,1 0,-25 0z"
android:fillColor="#ffffff"
android:fillAlpha="0.4"/>
<path
android:pathData="M50,100a12.5,12.5 0,1 0,25 0a12.5,12.5 0,1 0,-25 0z"
android:fillColor="#ffffff"/>
<path
android:pathData="M87.5,100a12.5,12.5 0,1 0,25 0a12.5,12.5 0,1 0,-25 0z"
android:fillColor="#ffffff"/>
<path
android:pathData="M125,100a12.5,12.5 0,1 0,25 0a12.5,12.5 0,1 0,-25 0z"
android:fillColor="#ffffff"/>
<path
android:pathData="M50,137.5a12.5,12.5 0,1 0,25 0a12.5,12.5 0,1 0,-25 0z"
android:fillColor="#ffffff"
android:fillAlpha="0.4"/>
<path
android:pathData="M87.5,137.5a12.5,12.5 0,1 0,25 0a12.5,12.5 0,1 0,-25 0z"
android:fillColor="#ffffff"/>
<path
android:pathData="M125,137.5a12.5,12.5 0,1 0,25 0a12.5,12.5 0,1 0,-25 0z"
android:fillColor="#ffffff"
android:fillAlpha="0.4"/>
</vector>

@ -0,0 +1,34 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="200dp"
android:height="200dp"
android:viewportWidth="200"
android:viewportHeight="200">
<path
android:pathData="M50,62.5a12.5,12.5 0,1 0,25 0a12.5,12.5 0,1 0,-25 0z"
android:fillColor="#cccccc"/>
<path
android:pathData="M87.5,62.5a12.5,12.5 0,1 0,25 0a12.5,12.5 0,1 0,-25 0z"
android:fillColor="#cccccc"/>
<path
android:pathData="M125,62.5a12.5,12.5 0,1 0,25 0a12.5,12.5 0,1 0,-25 0z"
android:fillColor="#cccccc"/>
<path
android:pathData="M50,100a12.5,12.5 0,1 0,25 0a12.5,12.5 0,1 0,-25 0z"
android:fillColor="#222222"/>
<path
android:pathData="M87.5,100a12.5,12.5 0,1 0,25 0a12.5,12.5 0,1 0,-25 0z"
android:fillColor="#222222"/>
<path
android:pathData="M125,100a12.5,12.5 0,1 0,25 0a12.5,12.5 0,1 0,-25 0z"
android:fillColor="#222222"/>
<path
android:pathData="M50,137.5a12.5,12.5 0,1 0,25 0a12.5,12.5 0,1 0,-25 0z"
android:fillColor="#cccccc"/>
<path
android:pathData="M87.5,137.5a12.5,12.5 0,1 0,25 0a12.5,12.5 0,1 0,-25 0z"
android:fillColor="#222222"/>
<path
android:pathData="M125,137.5a12.5,12.5 0,1 0,25 0a12.5,12.5 0,1 0,-25 0z"
android:fillColor="#cccccc"/>
</vector>

@ -1,38 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="100dp"
android:height="100dp"
android:viewportWidth="100"
android:viewportHeight="100">
<path
android:pathData="M0,12.5a12.5,12.5 0,1 0,25 0a12.5,12.5 0,1 0,-25 0z"
android:fillColor="#000000"
android:fillAlpha="0.4"/>
<path
android:pathData="M37.5,12.5a12.5,12.5 0,1 0,25 0a12.5,12.5 0,1 0,-25 0z"
android:fillColor="#000000"
android:fillAlpha="0.4"/>
<path
android:pathData="M75,12.5a12.5,12.5 0,1 0,25 0a12.5,12.5 0,1 0,-25 0z"
android:fillColor="#000000"
android:fillAlpha="0.4"/>
<path
android:pathData="M-0,50a12.5,12.5 0,1 0,25 0a12.5,12.5 0,1 0,-25 0z"
android:fillColor="#000000"/>
<path
android:pathData="M37.5,50a12.5,12.5 0,1 0,25 0a12.5,12.5 0,1 0,-25 0z"
android:fillColor="#000000"/>
<path
android:pathData="M75,50a12.5,12.5 0,1 0,25 0a12.5,12.5 0,1 0,-25 0z"
android:fillColor="#000000"/>
<path
android:pathData="M-0,87.5a12.5,12.5 0,1 0,25 0a12.5,12.5 0,1 0,-25 0z"
android:fillColor="#000000"
android:fillAlpha="0.4"/>
<path
android:pathData="M37.5,87.5a12.5,12.5 0,1 0,25 0a12.5,12.5 0,1 0,-25 0z"
android:fillColor="#000000"/>
<path
android:pathData="M75,87.5a12.5,12.5 0,1 0,25 0a12.5,12.5 0,1 0,-25 0z"
android:fillColor="#000000"
android:fillAlpha="0.4"/>
</vector>

@ -1,46 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="100dp"
android:height="100dp"
android:viewportWidth="100"
android:viewportHeight="100">
<path
android:pathData="M0,12.5a12.5,12.5 0,1 0,25 0a12.5,12.5 0,1 0,-25 0z"
android:fillColor="#000000"
android:fillAlpha="0.4"/>
<path
android:pathData="M37.5,12.5a12.5,12.5 0,1 0,25 0a12.5,12.5 0,1 0,-25 0z"
android:fillColor="#000000"
android:fillAlpha="0.4"/>
<path
android:pathData="M75,12.5a12.5,12.5 0,1 0,25 0a12.5,12.5 0,1 0,-25 0z"
android:fillColor="#000000"
android:fillAlpha="0.4"/>
<path
android:pathData="M-0,50a12.5,12.5 0,1 0,25 0a12.5,12.5 0,1 0,-25 0z"
android:strokeAlpha="0.4"
android:fillColor="#000000"
android:fillAlpha="0.4"/>
<path
android:pathData="M37.5,50a12.5,12.5 0,1 0,25 0a12.5,12.5 0,1 0,-25 0z"
android:strokeAlpha="0.4"
android:fillColor="#000000"
android:fillAlpha="0.4"/>
<path
android:pathData="M75,50a12.5,12.5 0,1 0,25 0a12.5,12.5 0,1 0,-25 0z"
android:strokeAlpha="0.4"
android:fillColor="#000000"
android:fillAlpha="0.4"/>
<path
android:pathData="M-0,87.5a12.5,12.5 0,1 0,25 0a12.5,12.5 0,1 0,-25 0z"
android:fillColor="#000000"
android:fillAlpha="0.4"/>
<path
android:pathData="M37.5,87.5a12.5,12.5 0,1 0,25 0a12.5,12.5 0,1 0,-25 0z"
android:strokeAlpha="0.4"
android:fillColor="#000000"
android:fillAlpha="0.4"/>
<path
android:pathData="M75,87.5a12.5,12.5 0,1 0,25 0a12.5,12.5 0,1 0,-25 0z"
android:fillColor="#000000"
android:fillAlpha="0.4"/>
</vector>

@ -6,12 +6,12 @@
<string name="log_out">Log out</string>
<string name="none">None</string>
<string name="connect">Connect</string>
<string name="disconnect">Disconnect</string>
<string name="unknown_user">Unknown user</string>
<string name="connected">Connected</string>
<string name="not_connected">Not connected</string>
<string name="empty" translatable="false"> </string>
<string name="template" translatable="false">%s</string>
<string name="empty"> </string>
<string name="template">%s</string>
<string name="more">More</string>
<string name="selected">Selected</string>
<string name="offline">Offline</string>
<string name="ok">OK</string>
@ -22,15 +22,15 @@
<string name="no_results">No results</string>
<!-- Strings for the about screen -->
<string name="app_name" translatable="false">Tailscale</string>
<string name="tile_name" translatable="false">Tailscale</string>
<string name="app_name">Tailscale</string>
<string name="tile_name">Tailscale</string>
<string name="version">Version</string>
<string name="about_view_header">About Tailscale</string>
<string name="about_view_title">Tailscale for Android</string>
<string name="acknowledgements">Acknowledgements</string>
<string name="privacy_policy">Privacy Policy</string>
<string name="terms_of_service">Terms of Service</string>
<string name="about_view_footnotes">WireGuard is a registered trademark of Jason A. Donenfeld.\n\n© 2024 Tailscale Inc. All rights reserved.\nTailscale is a registered trademark of Tailscale Inc.</string>
<string name="app_icon_content_description">The Tailscale app icon</string>
<string name="managed_by">Managed by</string>
<!-- Strings for the bug reporting screen -->
@ -51,27 +51,16 @@
<!-- Strings for the main screen -->
<string name="exit_node">EXIT NODE</string>
<string name="exit_node_offline">EXIT NODE OFFLINE</string>
<string name="running_exit_node">Running on this device</string>
<string name="exit_node_offline_mdm_orgname">The exit node selected by %1$s is currently offline. Internet traffic is blocked. Contact a network administrator for help.</string>
<string name="exit_node_offline_mdm">The exit node selected by your organization is currently offline. Internet traffic is blocked. Contact a network administrator for help.</string>
<string name="starting">Starting…</string>
<string name="connect_to_tailnet_prefix">"Connect again to talk to the other devices in the "</string>
<string name="connect_to_tailnet_suffix">" tailnet."</string>
<string name="welcome_to_tailscale">Welcome to Tailscale</string>
<string name="login_to_join_your_tailnet">Log in to join your tailnet and connect your devices.</string>
<string name="give_permissions">Set up a VPN connection so you can start using Tailscale.</string>
<string name="keyExpiryExplainer">Reauthenticate to remain connected to Tailscale.</string>
<string name="deviceKeyNeverExpires">Device key does not expire</string>
<string name="deviceKeyExpires">Device key expires %s</string>
<string name="deviceKeyExpired">Device key expired %s</string>
<string name="machine_auth_required">Admin approval required</string>
<string name="machine_auth_explainer">This device must be approved by an administrator before it can connect to the tailnet.</string>
<string name="open_admin_console">Open admin console</string>
<string name="needs_machine_auth">Authorization required</string>
<string name="disable">Disable</string>
<string name="enable">Enable</string>
<string name="stop">Stop</string>
<!-- Strings for peer details -->
<string name="os">OS</string>
@ -87,12 +76,13 @@
<string name="open_support">Open support</string>
<!-- State strings -->
<string name="placeholder" translatable="false">--</string>
<string name="waiting">Starting…</string>
<string name="placeholder">--</string>
<string name="please_login">Login required</string>
<string name="stopped">Not connected</string>
<string name="connect_to_vpn">Connect to VPN</string>
<!-- Time conversion templates -->
<string name="expired">expired</string>
<string name="under_a_minute">under a minute</string>
<string name="in_x_minutes">in %d minutes</string>
<string name="in_x_hours">in %d hours</string>
@ -104,35 +94,30 @@
<string name="ago_x_hours">%d hours ago</string>
<string name="ago_x_days">%d days ago</string>
<string name="ago_x_months">%d months ago</string>
<string name="ago_x_years">%.1f years ago</string>
<string name="ago_x_years">.1f years ago</string>
<!-- Strings for the user switcher -->
<string name="user_switcher">Accounts</string>
<string name="logout_failed">Unable to logout at this time. Please try again.</string>
<string name="error">Error</string>
<string name="accounts">Accounts</string>
<string name="add_account">Add another account</string>
<string name="manage_accounts">Manage accounts</string>
<string name="add_account">Add another account…</string>
<string name="add_account_short">Add account</string>
<string name="reauthenticate">Reauthenticate</string>
<string name="switch_user_failed">Unable to switch users. Please try again.</string>
<string name="add_profile_failed">Unable to add a new profile. Please try again.</string>
<string name="invalidCustomUrl">Please enter a valid URL in the form https://server.com</string>
<string name="invalidCustomURLTitle">Invalid URL</string>
<string name="custom_control_menu">Use an alternate server</string>
<string name="custom_control_menu">Add account using alternate server</string>
<string name="custom_control_menu_desc">Enter the URL of an alternate Tailscale server or your self-hosted Headscale server.</string>
<string name="custom_control_placeholder" translatable="false">https://my.custom.server.com</string>
<string name="auth_key_menu">Use an auth key</string>
<string name="auth_key_title">Add an account using an auth key</string>
<string name="auth_key_explanation">Enter a valid auth key generated by the Tailscale admin console</string>
<string name="auth_key_placeholder">ie: tskey-auth-mYta1ln3tCNTRL-s3cr3tauthk3yFr0madM1n</string>
<string name="invalidAuthKey">The provided auth key is not in the correct format.</string>
<string name="invalidAuthKeyTitle">Invalid key</string>
<string name="custom_control_url_title">Custom control server URL</string>
<string name="auth_key_input_title">Auth key</string>
<string name="custom_control_placeholder">https://my.custom.server.com</string>
<!-- Strings for ExitNode picker -->
<string name="choose_exit_node">Choose exit node</string>
<string name="choose_mullvad_exit_node">Mullvad exit nodes</string>
<string name="mullvad_exit_nodes" translatable="false">Mullvad VPN</string>
<string name="tailnet_exit_nodes">Tailnet exit nodes</string>
<string name="mullvad_exit_nodes">Mullvad VPN</string>
<string name="best_available">Best available</string>
<string name="run_as_exit_node">Run as exit node</string>
<string name="run_this_device_as_an_exit_node">Run as exit node?</string>
@ -144,8 +129,7 @@
<string name="run_exit_node_explainer_running">Other devices in your tailnet can now route their internet traffic through this Android device. Make sure to approve this exit node in the admin console in order for other devices to see it.</string>
<string name="enabled">Enabled</string>
<string name="disabled">Disabled</string>
<string name="exit_node_mdm_orgname">%1$s requires you to use an exit node.</string>
<string name="exit_node_mdm">Your organization requires you to use an exit node.</string>
<string name="stop">Stop</string>
<!-- Strings for the tailnet lock screen -->
<string name="tailnet_lock">Tailnet lock</string>
@ -211,10 +195,11 @@
<!-- Permissions Management -->
<string name="permissions">Permissions</string>
<string name="permission_required">Permission required</string>
<string name="permission_write_external_storage">Storage</string>
<string name="permission_write_external_storage_needed">We use storage in order to receive files with Taildrop.</string>
<string name="permission_post_notifications">Notifications</string>
<string name="permission_post_notifications_needed">We use notifications to help you troubleshoot broken connections, or notify you before you need to reauthenticate to your network. Persistent status notifications are off by default and can be enabled in system settings. </string>
<string name="permission_post_notifications_needed">We use notifications to help you troubleshoot broken connections, or notify you before you need to reauthenticate to your network.</string>
<!-- Strings for the share activity -->
@ -241,28 +226,6 @@
<string name="getStarted">Get Started</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="scan_to_connect_to_your_tailnet">Scan this QR code to log in to your tailnet</string>
<!-- Strings for intent handling -->
<string name="vpn_is_not_ready_to_start">VPN is not ready to start</string>
<string name="tailscale_is_not_setup">Tailscale is not setup</string>
<string name="no_peers_found">No peers found</string>
<string name="no_peers_with_name_found">No peers with name %1$s found</string>
<string name="multiple_peers_with_name_found">Multiple peers with name %1$s found</string>
<string name="peer_with_name_is_not_an_exit_node">Peer with name %1$s is not an exit node</string>
<string name="use_exit_node_intent_failed">Use Exit Node Intent Failed</string>
<!-- Strings for the IPNReceiver -->
<string name="title_connection_failed">Tailscale Connection Failed</string>
<string name="body_open_tailscale">Tap here to open Tailscale.</string>
<string name="scan_to_connect_to_your_tailnet">Scan this QR code to log in to your tailnet</string>
<!-- Strings describing notification channels -->
<string name="taildrop_file_transfers">Taildrop file transfers</string>
<string name="vpn_status">VPN status</string>
<string name="vpn_start">VPN start</string>
<string name="notifications_delivered_when_user_interaction_is_required_to_establish_the_vpn_tunnel">Notifications delivered when user interaction is required to establish the VPN tunnel.</string>
<string name="optional_notifications_which_display_the_status_of_the_vpn_tunnel">Optional notifications which display the status of the VPN tunnel.</string>
<string name="notifications_delivered_when_a_file_is_received_using_taildrop">Notifications delivered when a file is received using Taildrop.</string>
<string name="health_channel_name">Errors and warnings</string>
<string name="health_channel_description">This notification category is used to deliver important status notifications and should be left enabled. For instance, it is used to notify you about errors or warnings that affect Internet connectivity.</string>
</resources>

@ -1,30 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package android.util;
/**
* This is a mock class for the android.util.Log class. It is used to print log messages to the console.
*/
public class Log {
public static int d(String tag, String msg) {
System.out.println("DEBUG: " + tag + ": " + msg);
return 0;
}
public static int i(String tag, String msg) {
System.out.println("INFO: " + tag + ": " + msg);
return 0;
}
public static int w(String tag, String msg) {
System.out.println("WARN: " + tag + ": " + msg);
return 0;
}
public static int e(String tag, String msg) {
System.out.println("ERROR: " + tag + ": " + msg);
return 0;
}
}

@ -1,60 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailcale.ipn.ui.util
import com.tailscale.ipn.ui.util.TimeUtil
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
import java.time.Duration
class TimeUtilTest {
@Test
fun durationInvalidMsUnits() {
val input = "5s10ms"
val actual = TimeUtil.duration(input)
assertNull("Should return null", actual)
}
@Test
fun durationInvalidUsUnits() {
val input = "5s10us"
val actual = TimeUtil.duration(input)
assertNull("Should return null", actual)
}
@Test
fun durationTestHappyPath() {
val input = arrayOf("1.0y1.0w1.0d1.0h1.0m1.0s", "1s", "1m", "1h", "1d", "1w", "1y")
val expectedSeconds =
arrayOf((31536000 + 604800 + 86400 + 3600 + 60 + 1), 1, 60, 3600, 86400, 604800, 31536000)
val expected = expectedSeconds.map { Duration.ofSeconds(it.toLong()) }
val actual = input.map { TimeUtil.duration(it) }
assertEquals("Incorrect conversion", expected, actual)
}
@Test
fun testBadDurationString() {
val input = "1..0y1.0w1.0d1.0h1.0m1.0s"
val actual = TimeUtil.duration(input)
assertNull("Should return null", actual)
}
@Test
fun testBadDInputString() {
val input = "1.0yy1.0w1.0d1.0h1.0m1.0s"
val actual = TimeUtil.duration(input)
assertNull("Should return null", actual)
}
@Test
fun testIgnoreFractionalSeconds() {
val input = "10.9s"
val expectedSeconds = 10
val expected = Duration.ofSeconds(expectedSeconds.toLong())
val actual = TimeUtil.duration(input)
assertEquals("Should return $expectedSeconds seconds", expected, actual)
}
}

@ -0,0 +1,60 @@
buildscript {
ext.kotlin_version = "1.9.22"
repositories {
google()
mavenCentral()
}
dependencies {
classpath "com.android.tools.build:gradle:8.1.0"
}
}
repositories {
google()
mavenCentral()
flatDir {
dirs 'libs'
}
}
apply plugin: 'com.android.application'
android {
ndkVersion "23.1.7779620"
compileSdk 33
defaultConfig {
minSdkVersion 22
targetSdkVersion 33
versionCode 201
versionName "1.61.105-t7429e8912-g12210d3f26b"
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_17
}
flavorDimensions "version"
productFlavors {
fdroid {
// The fdroid flavor contains only free dependencies and is suitable
// for the F-Droid app store.
}
play {
// The play flavor contains all features and is for the Play Store.
}
}
namespace 'com.tailscale.ipn'
}
dependencies {
implementation "androidx.core:core:1.9.0"
implementation "androidx.browser:browser:1.5.0"
implementation "androidx.security:security-crypto:1.1.0-alpha06"
implementation "androidx.work:work-runtime:2.8.1"
implementation ':ipn@aar'
testImplementation "junit:junit:4.12"
// Non-free dependencies.
playImplementation 'com.google.android.gms:play-services-auth:20.7.0'
}

@ -0,0 +1,5 @@
android.defaults.buildfeatures.buildconfig=true
android.nonFinalResIds=false
android.nonTransitiveRClass=false
android.useAndroidX=true
org.gradle.jvmargs=-Xmx2g -XX:MaxMetaspaceSize=512m

@ -0,0 +1,6 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionSha256Sum=38f66cd6eef217b4c35855bb11ea4e9fbc53594ccccb5fb82dfd317ef8c2c5a3
distributionUrl=https\://services.gradle.org/distributions/gradle-8.2-bin.zip
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

@ -0,0 +1,183 @@
#!/usr/bin/env sh
#
# Copyright 2015 the original author or authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
##############################################################################
##
## Gradle start up script for UN*X
##
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
PRG="$0"
# Need this for relative symlinks.
while [ -h "$PRG" ] ; do
ls=`ls -ld "$PRG"`
link=`expr "$ls" : '.*-> \(.*\)$'`
if expr "$link" : '/.*' > /dev/null; then
PRG="$link"
else
PRG=`dirname "$PRG"`"/$link"
fi
done
SAVED="`pwd`"
cd "`dirname \"$PRG\"`/" >/dev/null
APP_HOME="`pwd -P`"
cd "$SAVED" >/dev/null
APP_NAME="Gradle"
APP_BASE_NAME=`basename "$0"`
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx80m" "-Xms80m"'
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD="maximum"
warn () {
echo "$*"
}
die () {
echo
echo "$*"
echo
exit 1
}
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "`uname`" in
CYGWIN* )
cygwin=true
;;
Darwin* )
darwin=true
;;
MINGW* )
msys=true
;;
NONSTOP* )
nonstop=true
;;
esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD="$JAVA_HOME/jre/sh/java"
else
JAVACMD="$JAVA_HOME/bin/java"
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD="java"
which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
# Increase the maximum file descriptors if we can.
if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
MAX_FD_LIMIT=`ulimit -H -n`
if [ $? -eq 0 ] ; then
if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
MAX_FD="$MAX_FD_LIMIT"
fi
ulimit -n $MAX_FD
if [ $? -ne 0 ] ; then
warn "Could not set maximum file descriptor limit: $MAX_FD"
fi
else
warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
fi
fi
# For Darwin, add options to specify how the application appears in the dock
if $darwin; then
GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
fi
# For Cygwin or MSYS, switch paths to Windows format before running java
if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then
APP_HOME=`cygpath --path --mixed "$APP_HOME"`
CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
JAVACMD=`cygpath --unix "$JAVACMD"`
# We build the pattern for arguments to be converted via cygpath
ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
SEP=""
for dir in $ROOTDIRSRAW ; do
ROOTDIRS="$ROOTDIRS$SEP$dir"
SEP="|"
done
OURCYGPATTERN="(^($ROOTDIRS))"
# Add a user-defined pattern to the cygpath arguments
if [ "$GRADLE_CYGPATTERN" != "" ] ; then
OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
fi
# Now convert the arguments - kludge to limit ourselves to /bin/sh
i=0
for arg in "$@" ; do
CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
else
eval `echo args$i`="\"$arg\""
fi
i=`expr $i + 1`
done
case $i in
0) set -- ;;
1) set -- "$args0" ;;
2) set -- "$args0" "$args1" ;;
3) set -- "$args0" "$args1" "$args2" ;;
4) set -- "$args0" "$args1" "$args2" "$args3" ;;
5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
esac
fi
# Escape application args
save () {
for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
echo " "
}
APP_ARGS=`save "$@"`
# Collect all arguments for the java command, following the shell quoting and substitution rules
eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
exec "$JAVACMD" "$@"

@ -0,0 +1,103 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@if "%DEBUG%" == "" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%" == "" set DIRNAME=.
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx80m" "-Xms80m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if "%ERRORLEVEL%" == "0" goto init
echo.
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto init
echo.
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:init
@rem Get command-line arguments, handling Windows variants
if not "%OS%" == "Windows_NT" goto win9xME_args
:win9xME_args
@rem Slurp the command line arguments.
set CMD_LINE_ARGS=
set _SKIP=2
:win9xME_args_slurp
if "x%~1" == "x" goto execute
set CMD_LINE_ARGS=%*
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS%
:end
@rem End local scope for the variables with windows NT shell
if "%ERRORLEVEL%"=="0" goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
exit /b 1
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega

@ -0,0 +1,85 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="28"/>
<uses-permission android:name="android.permission.CHANGE_NETWORK_STATE" />
<!-- Disable input emulation on ChromeOS -->
<uses-feature android:name="android.hardware.type.pc" android:required="false"/>
<!-- Signal support for Android TV -->
<uses-feature android:name="android.software.leanback" android:required="false" />
<uses-feature android:name="android.hardware.touchscreen" android:required="false" />
<application android:label="Tailscale" android:icon="@mipmap/ic_launcher" android:roundIcon="@mipmap/ic_launcher_round"
android:banner="@drawable/tv_banner"
android:name=".App" android:allowBackup="false">
<activity android:name="IPNActivity"
android:label="@string/app_name"
android:theme="@style/Theme.GioApp"
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|keyboardHidden"
android:windowSoftInputMode="adjustResize"
android:launchMode="singleTask"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
</intent-filter>
<intent-filter>
<action android:name="android.service.quicksettings.action.QS_TILE_PREFERENCES" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT"/>
<data android:mimeType="application/*" />
<data android:mimeType="audio/*" />
<data android:mimeType="image/*" />
<data android:mimeType="message/*" />
<data android:mimeType="multipart/*" />
<data android:mimeType="text/*" />
<data android:mimeType="video/*" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.SEND_MULTIPLE" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="application/*" />
<data android:mimeType="audio/*" />
<data android:mimeType="image/*" />
<data android:mimeType="message/*" />
<data android:mimeType="multipart/*" />
<data android:mimeType="text/*" />
<data android:mimeType="video/*" />
</intent-filter>
</activity>
<receiver android:name="IPNReceiver"
android:exported="true"
>
<intent-filter>
<action android:name="com.tailscale.ipn.CONNECT_VPN" />
<action android:name="com.tailscale.ipn.DISCONNECT_VPN" />
</intent-filter>
</receiver>
<service android:name=".IPNService"
android:permission="android.permission.BIND_VPN_SERVICE"
android:exported="false">
<intent-filter>
<action android:name="android.net.VpnService"/>
</intent-filter>
</service>
<service
android:name=".QuickToggleService"
android:icon="@drawable/ic_tile"
android:label="@string/tile_name"
android:permission="android.permission.BIND_QUICK_SETTINGS_TILE"
android:exported="true">
<intent-filter>
<action android:name="android.service.quicksettings.action.QS_TILE"/>
</intent-filter>
</service>
</application>
</manifest>

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

@ -0,0 +1,416 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn;
import android.app.Application;
import android.app.Activity;
import android.app.DownloadManager;
import android.app.Fragment;
import android.app.FragmentTransaction;
import android.app.NotificationChannel;
import android.app.PendingIntent;
import android.app.UiModeManager;
import android.content.BroadcastReceiver;
import android.content.ContentResolver;
import android.content.ContentValues;
import android.content.Context;
import android.content.Intent;
import android.content.IntentFilter;
import android.content.SharedPreferences;
import android.content.pm.PackageManager;
import android.content.pm.PackageInfo;
import android.content.pm.Signature;
import android.content.res.Configuration;
import android.provider.MediaStore;
import android.provider.Settings;
import android.net.ConnectivityManager;
import android.net.LinkProperties;
import android.net.Network;
import android.net.NetworkInfo;
import android.net.NetworkRequest;
import android.net.Uri;
import android.net.VpnService;
import android.view.View;
import android.os.Build;
import android.os.Environment;
import android.os.Handler;
import android.os.Looper;
import android.Manifest;
import android.webkit.MimeTypeMap;
import java.io.IOException;
import java.io.File;
import java.io.FileOutputStream;
import java.lang.StringBuilder;
import java.net.InetAddress;
import java.net.InterfaceAddress;
import java.net.NetworkInterface;
import java.security.GeneralSecurityException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Locale;
import androidx.core.app.NotificationCompat;
import androidx.core.app.NotificationManagerCompat;
import androidx.core.content.ContextCompat;
import androidx.security.crypto.EncryptedSharedPreferences;
import androidx.security.crypto.MasterKey;
import androidx.browser.customtabs.CustomTabsIntent;
import org.gioui.Gio;
public class App extends Application {
private static final String PEER_TAG = "peer";
static final String STATUS_CHANNEL_ID = "tailscale-status";
static final int STATUS_NOTIFICATION_ID = 1;
static final String NOTIFY_CHANNEL_ID = "tailscale-notify";
static final int NOTIFY_NOTIFICATION_ID = 2;
private static final String FILE_CHANNEL_ID = "tailscale-files";
private static final int FILE_NOTIFICATION_ID = 3;
private static final Handler mainHandler = new Handler(Looper.getMainLooper());
private ConnectivityManager connectivityManager;
public DnsConfig dns = new DnsConfig();
public DnsConfig getDnsConfigObj() { return this.dns; }
@Override public void onCreate() {
super.onCreate();
// Load and initialize the Go library.
Gio.init(this);
this.connectivityManager = (ConnectivityManager) this.getSystemService(Context.CONNECTIVITY_SERVICE);
setAndRegisterNetworkCallbacks();
createNotificationChannel(NOTIFY_CHANNEL_ID, "Notifications", NotificationManagerCompat.IMPORTANCE_DEFAULT);
createNotificationChannel(STATUS_CHANNEL_ID, "VPN Status", NotificationManagerCompat.IMPORTANCE_LOW);
createNotificationChannel(FILE_CHANNEL_ID, "File transfers", NotificationManagerCompat.IMPORTANCE_DEFAULT);
}
// 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 void setAndRegisterNetworkCallbacks() {
connectivityManager.requestNetwork(dns.getDNSConfigNetworkRequest(), new ConnectivityManager.NetworkCallback(){
@Override
public void onAvailable(Network network){
super.onAvailable(network);
StringBuilder sb = new StringBuilder("");
LinkProperties linkProperties = connectivityManager.getLinkProperties(network);
List<InetAddress> dnsList = linkProperties.getDnsServers();
for (InetAddress ip : dnsList) {
sb.append(ip.getHostAddress()).append(" ");
}
String searchDomains = linkProperties.getDomains();
if (searchDomains != null) {
sb.append("\n");
sb.append(searchDomains);
}
dns.updateDNSFromNetwork(sb.toString());
onDnsConfigChanged();
}
@Override
public void onLost(Network network) {
super.onLost(network);
onDnsConfigChanged();
}
});
}
public void startVPN() {
Intent intent = new Intent(this, IPNService.class);
intent.setAction(IPNService.ACTION_REQUEST_VPN);
startService(intent);
}
public void stopVPN() {
Intent intent = new Intent(this, IPNService.class);
intent.setAction(IPNService.ACTION_STOP_VPN);
startService(intent);
}
// encryptToPref a byte array of data using the Jetpack Security
// library and writes it to a global encrypted preference store.
public void encryptToPref(String prefKey, String plaintext) throws IOException, GeneralSecurityException {
getEncryptedPrefs().edit().putString(prefKey, plaintext).commit();
}
// decryptFromPref decrypts a encrypted preference using the Jetpack Security
// library and returns the plaintext.
public String decryptFromPref(String prefKey) throws IOException, GeneralSecurityException {
return getEncryptedPrefs().getString(prefKey, null);
}
private SharedPreferences getEncryptedPrefs() throws IOException, GeneralSecurityException {
MasterKey key = new MasterKey.Builder(this)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build();
return EncryptedSharedPreferences.create(
this,
"secret_shared_prefs",
key,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
);
}
public boolean autoConnect = false;
public boolean vpnReady = false;
void setTileReady(boolean ready) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) {
return;
}
QuickToggleService.setReady(this, ready);
android.util.Log.d("App", "Set Tile Ready: " + ready + " " + autoConnect);
vpnReady = ready;
if (ready && autoConnect) {
startVPN();
}
}
void setTileStatus(boolean status) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) {
return;
}
QuickToggleService.setStatus(this, status);
}
String getHostname() {
String userConfiguredDeviceName = getUserConfiguredDeviceName();
if (!isEmpty(userConfiguredDeviceName)) return userConfiguredDeviceName;
return getModelName();
}
String getModelName() {
String manu = Build.MANUFACTURER;
String model = Build.MODEL;
// Strip manufacturer from model.
int idx = model.toLowerCase().indexOf(manu.toLowerCase());
if (idx != -1) {
model = model.substring(idx + manu.length());
model = model.trim();
}
return manu + " " + model;
}
String getOSVersion() {
return Build.VERSION.RELEASE;
}
// get user defined nickname from Settings
// returns null if not available
private String getUserConfiguredDeviceName() {
String nameFromSystemDevice = Settings.Secure.getString(getContentResolver(), "device_name");
if (!isEmpty(nameFromSystemDevice)) return nameFromSystemDevice;
return null;
}
private static boolean isEmpty(String str) {
return str == null || str.length() == 0;
}
// attachPeer adds a Peer fragment for tracking the Activity
// lifecycle.
void attachPeer(Activity act) {
act.runOnUiThread(new Runnable() {
@Override public void run() {
FragmentTransaction ft = act.getFragmentManager().beginTransaction();
ft.add(new Peer(), PEER_TAG);
ft.commit();
act.getFragmentManager().executePendingTransactions();
}
});
}
boolean isChromeOS() {
return getPackageManager().hasSystemFeature("android.hardware.type.pc");
}
void prepareVPN(Activity act, int reqCode) {
act.runOnUiThread(new Runnable() {
@Override public void run() {
Intent intent = VpnService.prepare(act);
if (intent == null) {
onVPNPrepared();
} else {
startActivityForResult(act, intent, reqCode);
}
}
});
}
static void startActivityForResult(Activity act, Intent intent, int request) {
Fragment f = act.getFragmentManager().findFragmentByTag(PEER_TAG);
f.startActivityForResult(intent, request);
}
void showURL(Activity act, String url) {
act.runOnUiThread(new Runnable() {
@Override public void run() {
CustomTabsIntent.Builder builder = new CustomTabsIntent.Builder();
int headerColor = 0xff496495;
builder.setToolbarColor(headerColor);
CustomTabsIntent intent = builder.build();
intent.launchUrl(act, Uri.parse(url));
}
});
}
// getPackageSignatureFingerprint returns the first package signing certificate, if any.
byte[] getPackageCertificate() throws Exception {
PackageInfo info;
info = getPackageManager().getPackageInfo(getPackageName(), PackageManager.GET_SIGNATURES);
for (Signature signature : info.signatures) {
return signature.toByteArray();
}
return null;
}
void requestWriteStoragePermission(Activity act) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q || Build.VERSION.SDK_INT < Build.VERSION_CODES.M) {
// We can write files without permission.
return;
}
if (ContextCompat.checkSelfPermission(act, Manifest.permission.WRITE_EXTERNAL_STORAGE) == PackageManager.PERMISSION_GRANTED) {
return;
}
act.requestPermissions(new String[]{Manifest.permission.WRITE_EXTERNAL_STORAGE}, IPNActivity.WRITE_STORAGE_RESULT);
}
String insertMedia(String name, String mimeType) throws IOException {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
ContentResolver resolver = getContentResolver();
ContentValues contentValues = new ContentValues();
contentValues.put(MediaStore.MediaColumns.DISPLAY_NAME, name);
if (!"".equals(mimeType)) {
contentValues.put(MediaStore.MediaColumns.MIME_TYPE, mimeType);
}
Uri root = MediaStore.Files.getContentUri("external");
return resolver.insert(root, contentValues).toString();
} else {
File dir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS);
dir.mkdirs();
File f = new File(dir, name);
return Uri.fromFile(f).toString();
}
}
int openUri(String uri, String mode) throws IOException {
ContentResolver resolver = getContentResolver();
return resolver.openFileDescriptor(Uri.parse(uri), mode).detachFd();
}
void deleteUri(String uri) {
ContentResolver resolver = getContentResolver();
resolver.delete(Uri.parse(uri), null, null);
}
public void notifyFile(String uri, String msg) {
Intent viewIntent;
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
viewIntent = new Intent(Intent.ACTION_VIEW, Uri.parse(uri));
} else {
// uri is a file:// which is not allowed to be shared outside the app.
viewIntent = new Intent(DownloadManager.ACTION_VIEW_DOWNLOADS);
}
PendingIntent pending = PendingIntent.getActivity(this, 0, viewIntent, PendingIntent.FLAG_UPDATE_CURRENT);
NotificationCompat.Builder builder = new NotificationCompat.Builder(this, FILE_CHANNEL_ID)
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle("File received")
.setContentText(msg)
.setContentIntent(pending)
.setAutoCancel(true)
.setOnlyAlertOnce(true)
.setPriority(NotificationCompat.PRIORITY_DEFAULT);
NotificationManagerCompat nm = NotificationManagerCompat.from(this);
nm.notify(FILE_NOTIFICATION_ID, builder.build());
}
public void createNotificationChannel(String id, String name, int importance) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
return;
}
NotificationChannel channel = new NotificationChannel(id, name, importance);
NotificationManagerCompat nm = NotificationManagerCompat.from(this);
nm.createNotificationChannel(channel);
}
static native void onVPNPrepared();
private static native void onDnsConfigChanged();
static native void onShareIntent(int nfiles, int[] types, String[] mimes, String[] items, String[] names, long[] sizes);
static native void onWriteStorageGranted();
// Returns details of the interfaces in the system, encoded as a single string for ease
// of JNI transfer over to the Go environment.
//
// Example:
// rmnet_data0 10 2000 true false false false false | fe80::4059:dc16:7ed3:9c6e%rmnet_data0/64
// dummy0 3 1500 true false false false false | fe80::1450:5cff:fe13:f891%dummy0/64
// wlan0 30 1500 true true false false true | fe80::2f60:2c82:4163:8389%wlan0/64 10.1.10.131/24
// r_rmnet_data0 21 1500 true false false false false | fe80::9318:6093:d1ad:ba7f%r_rmnet_data0/64
// rmnet_data2 12 1500 true false false false false | fe80::3c8c:44dc:46a9:9907%rmnet_data2/64
// r_rmnet_data1 22 1500 true false false false false | fe80::b6cd:5cb0:8ae6:fe92%r_rmnet_data1/64
// rmnet_data1 11 1500 true false false false false | fe80::51f2:ee00:edce:d68b%rmnet_data1/64
// lo 1 65536 true false true false false | ::1/128 127.0.0.1/8
// v4-rmnet_data2 68 1472 true true false true true | 192.0.0.4/32
//
// Where the fields are:
// name ifindex mtu isUp hasBroadcast isLoopback isPointToPoint hasMulticast | ip1/N ip2/N ip3/N;
String getInterfacesAsString() {
List<NetworkInterface> interfaces;
try {
interfaces = Collections.list(NetworkInterface.getNetworkInterfaces());
} catch (Exception e) {
return "";
}
StringBuilder sb = new StringBuilder("");
for (NetworkInterface nif : interfaces) {
try {
// Android doesn't have a supportsBroadcast() but the Go net.Interface wants
// one, so we say the interface has broadcast if it has multicast.
sb.append(String.format(java.util.Locale.ROOT, "%s %d %d %b %b %b %b %b |", nif.getName(),
nif.getIndex(), nif.getMTU(), nif.isUp(), nif.supportsMulticast(),
nif.isLoopback(), nif.isPointToPoint(), nif.supportsMulticast()));
for (InterfaceAddress ia : nif.getInterfaceAddresses()) {
// InterfaceAddress == hostname + "/" + IP
String[] parts = ia.toString().split("/", 0);
if (parts.length > 1) {
sb.append(String.format(java.util.Locale.ROOT, "%s/%d ", parts[1], ia.getNetworkPrefixLength()));
}
}
} catch (Exception e) {
// TODO(dgentry) should log the exception not silently suppress it.
continue;
}
sb.append("\n");
}
return sb.toString();
}
boolean isTV() {
UiModeManager mm = (UiModeManager)getSystemService(UI_MODE_SERVICE);
return mm.getCurrentModeType() == Configuration.UI_MODE_TYPE_TELEVISION;
}
}

@ -0,0 +1,63 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn;
import android.net.NetworkCapabilities;
import android.net.NetworkRequest;
import java.lang.reflect.Method;
import java.net.InetAddress;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
// Tailscale DNS Config retrieval
//
// Tailscale's DNS support can either override the local DNS servers with a set of servers
// configured in the admin panel, or supplement the local DNS servers with additional
// servers for specific domains like example.com.beta.tailscale.net. In the non-override mode,
// we need to retrieve the current set of DNS servers from the platform. These will typically
// be the DNS servers received from DHCP.
//
// Importantly, after the Tailscale VPN comes up it will set a DNS server of 100.100.100.100
// but we still want to retrieve the underlying DNS servers received from DHCP. If we roam
// from Wi-Fi to LTE, we want the DNS servers received from LTE.
public class DnsConfig {
private String dnsConfigs;
// getDnsConfigAsString returns the current DNS configuration as a multiline string:
// line[0] DNS server addresses separated by spaces
// line[1] search domains separated by spaces
//
// For example:
// 8.8.8.8 8.8.4.4
// example.com
//
// an empty string means the current DNS configuration could not be retrieved.
String getDnsConfigAsString() {
return getDnsConfigs().trim();
}
private String getDnsConfigs(){
synchronized(this) {
return this.dnsConfigs;
}
}
void updateDNSFromNetwork(String dnsConfigs){
synchronized(this) {
this.dnsConfigs = dnsConfigs;
}
}
NetworkRequest getDNSConfigNetworkRequest(){
// Request networks that are able to reach the Internet.
return new NetworkRequest.Builder().addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET).build();
}
}

@ -0,0 +1,132 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn;
import android.app.Activity;
import android.content.res.AssetFileDescriptor;
import android.content.res.Configuration;
import android.content.Intent;
import android.database.Cursor;
import android.os.Bundle;
import android.provider.OpenableColumns;
import android.net.Uri;
import android.content.pm.PackageManager;
import java.util.List;
import java.util.ArrayList;
import org.gioui.GioView;
public final class IPNActivity extends Activity {
final static int WRITE_STORAGE_RESULT = 1000;
private GioView view;
@Override public void onCreate(Bundle state) {
super.onCreate(state);
view = new GioView(this);
setContentView(view);
handleIntent();
}
@Override public void onNewIntent(Intent i) {
setIntent(i);
handleIntent();
}
private void handleIntent() {
Intent it = getIntent();
String act = it.getAction();
String[] texts;
Uri[] uris;
if (Intent.ACTION_SEND.equals(act)) {
uris = new Uri[]{it.getParcelableExtra(Intent.EXTRA_STREAM)};
texts = new String[]{it.getStringExtra(Intent.EXTRA_TEXT)};
} else if (Intent.ACTION_SEND_MULTIPLE.equals(act)) {
List<Uri> extraUris = it.getParcelableArrayListExtra(Intent.EXTRA_STREAM);
uris = extraUris.toArray(new Uri[0]);
texts = new String[uris.length];
} else {
return;
}
String mime = it.getType();
int nitems = uris.length;
String[] items = new String[nitems];
String[] mimes = new String[nitems];
int[] types = new int[nitems];
String[] names = new String[nitems];
long[] sizes = new long[nitems];
int nfiles = 0;
for (int i = 0; i < uris.length; i++) {
String text = texts[i];
Uri uri = uris[i];
if (text != null) {
types[nfiles] = 1; // FileTypeText
names[nfiles] = "file.txt";
mimes[nfiles] = mime;
items[nfiles] = text;
// Determined by len(text) in Go to eliminate UTF-8 encoding differences.
sizes[nfiles] = 0;
nfiles++;
} else if (uri != null) {
Cursor c = getContentResolver().query(uri, null, null, null, null);
if (c == null) {
// Ignore files we have no permission to access.
continue;
}
int nameCol = c.getColumnIndex(OpenableColumns.DISPLAY_NAME);
int sizeCol = c.getColumnIndex(OpenableColumns.SIZE);
c.moveToFirst();
String name = c.getString(nameCol);
long size = c.getLong(sizeCol);
types[nfiles] = 2; // FileTypeURI
mimes[nfiles] = mime;
items[nfiles] = uri.toString();
names[nfiles] = name;
sizes[nfiles] = size;
nfiles++;
}
}
App.onShareIntent(nfiles, types, mimes, items, names, sizes);
}
@Override public void onRequestPermissionsResult(int reqCode, String[] perms, int[] grants) {
switch (reqCode) {
case WRITE_STORAGE_RESULT:
if (grants.length > 0 && grants[0] == PackageManager.PERMISSION_GRANTED) {
App.onWriteStorageGranted();
}
}
}
@Override public void onDestroy() {
view.destroy();
super.onDestroy();
}
@Override public void onStart() {
super.onStart();
view.start();
}
@Override public void onStop() {
view.stop();
super.onStop();
}
@Override public void onConfigurationChanged(Configuration c) {
super.onConfigurationChanged(c);
view.configurationChanged();
}
@Override public void onLowMemory() {
super.onLowMemory();
view.onLowMemory();
}
@Override public void onBackPressed() {
if (!view.backPressed())
super.onBackPressed();
}
}

@ -0,0 +1,28 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import androidx.work.WorkManager;
import androidx.work.OneTimeWorkRequest;
public class IPNReceiver extends BroadcastReceiver {
public static final String INTENT_CONNECT_VPN = "com.tailscale.ipn.CONNECT_VPN";
public static final String INTENT_DISCONNECT_VPN = "com.tailscale.ipn.DISCONNECT_VPN";
@Override
public void onReceive(Context context, Intent intent) {
WorkManager workManager = WorkManager.getInstance(context);
// On the relevant action, start the relevant worker, which can stay active for longer than this receiver can.
if (intent.getAction() == INTENT_CONNECT_VPN) {
workManager.enqueue(new OneTimeWorkRequest.Builder(StartVPNWorker.class).build());
} else if (intent.getAction() == INTENT_DISCONNECT_VPN) {
workManager.enqueue(new OneTimeWorkRequest.Builder(StopVPNWorker.class).build());
}
}
}

@ -0,0 +1,137 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn;
import android.os.Build;
import android.app.PendingIntent;
import android.content.Intent;
import android.content.pm.PackageManager;
import android.net.VpnService;
import android.system.OsConstants;
import androidx.work.WorkManager;
import androidx.work.OneTimeWorkRequest;
import org.gioui.GioActivity;
import androidx.core.app.NotificationCompat;
import androidx.core.app.NotificationManagerCompat;
public class IPNService extends VpnService {
public static final String ACTION_REQUEST_VPN = "com.tailscale.ipn.REQUEST_VPN";
public static final String ACTION_STOP_VPN = "com.tailscale.ipn.STOP_VPN";
@Override public int onStartCommand(Intent intent, int flags, int startId) {
if (intent != null && ACTION_STOP_VPN.equals(intent.getAction())) {
((App)getApplicationContext()).autoConnect = false;
close();
return START_NOT_STICKY;
}
if (intent != null && "android.net.VpnService".equals(intent.getAction())) {
// Start VPN and connect to it due to Always-on VPN
Intent i = new Intent(IPNReceiver.INTENT_CONNECT_VPN);
i.setPackage(getPackageName());
i.setClass(getApplicationContext(), com.tailscale.ipn.IPNReceiver.class);
sendBroadcast(i);
requestVPN();
connect();
return START_STICKY;
}
requestVPN();
App app = ((App)getApplicationContext());
if (app.vpnReady && app.autoConnect) {
connect();
}
return START_STICKY;
}
private void close() {
stopForeground(true);
disconnect();
}
@Override public void onDestroy() {
close();
super.onDestroy();
}
@Override public void onRevoke() {
close();
super.onRevoke();
}
private PendingIntent configIntent() {
return PendingIntent.getActivity(this, 0, new Intent(this, IPNActivity.class),
PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE);
}
private void disallowApp(VpnService.Builder b, String name) {
try {
b.addDisallowedApplication(name);
} catch (PackageManager.NameNotFoundException e) {
return;
}
}
protected VpnService.Builder newBuilder() {
VpnService.Builder b = new VpnService.Builder()
.setConfigureIntent(configIntent())
.allowFamily(OsConstants.AF_INET)
.allowFamily(OsConstants.AF_INET6);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q)
b.setMetered(false); // Inherit the metered status from the underlying networks.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M)
b.setUnderlyingNetworks(null); // Use all available networks.
// RCS/Jibe https://github.com/tailscale/tailscale/issues/2322
this.disallowApp(b, "com.google.android.apps.messaging");
// Stadia https://github.com/tailscale/tailscale/issues/3460
this.disallowApp(b, "com.google.stadia.android");
// Android Auto https://github.com/tailscale/tailscale/issues/3828
this.disallowApp(b, "com.google.android.projection.gearhead");
// GoPro https://github.com/tailscale/tailscale/issues/2554
this.disallowApp(b, "com.gopro.smarty");
// Sonos https://github.com/tailscale/tailscale/issues/2548
this.disallowApp(b, "com.sonos.acr");
this.disallowApp(b, "com.sonos.acr2");
// Google Chromecast https://github.com/tailscale/tailscale/issues/3636
this.disallowApp(b, "com.google.android.apps.chromecast.app");
return b;
}
public void notify(String title, String message) {
NotificationCompat.Builder builder = new NotificationCompat.Builder(this, App.NOTIFY_CHANNEL_ID)
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle(title)
.setContentText(message)
.setContentIntent(configIntent())
.setAutoCancel(true)
.setOnlyAlertOnce(true)
.setPriority(NotificationCompat.PRIORITY_DEFAULT);
NotificationManagerCompat nm = NotificationManagerCompat.from(this);
nm.notify(App.NOTIFY_NOTIFICATION_ID, builder.build());
}
public void updateStatusNotification(String title, String message) {
NotificationCompat.Builder builder = new NotificationCompat.Builder(this, App.STATUS_CHANNEL_ID)
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle(title)
.setContentText(message)
.setContentIntent(configIntent())
.setPriority(NotificationCompat.PRIORITY_LOW);
startForeground(App.STATUS_NOTIFICATION_ID, builder.build());
}
private native void requestVPN();
private native void disconnect();
private native void connect();
}

@ -0,0 +1,16 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn;
import android.app.Activity;
import android.app.Fragment;
import android.content.Intent;
public class Peer extends Fragment {
@Override public void onActivityResult(int requestCode, int resultCode, Intent data) {
onActivityResult0(getActivity(), requestCode, resultCode);
}
private static native void onActivityResult0(Activity act, int reqCode, int resCode);
}

@ -0,0 +1,87 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn;
import android.content.Context;
import android.content.Intent;
import android.service.quicksettings.Tile;
import android.service.quicksettings.TileService;
public class QuickToggleService extends TileService {
// lock protects the static fields below it.
private static Object lock = new Object();
// Active tracks whether the VPN is active.
private static boolean active;
// Ready tracks whether the tailscale backend is
// ready to switch on/off.
private static boolean ready;
// currentTile tracks getQsTile while service is listening.
private static Tile currentTile;
@Override public void onStartListening() {
synchronized (lock) {
currentTile = getQsTile();
}
updateTile();
}
@Override public void onStopListening() {
synchronized (lock) {
currentTile = null;
}
}
@Override public void onClick() {
boolean r;
synchronized (lock) {
r = ready;
}
if (r) {
onTileClick();
} else {
// Start main activity.
Intent i = getPackageManager().getLaunchIntentForPackage(getPackageName());
startActivityAndCollapse(i);
}
}
private static void updateTile() {
Tile t;
boolean act;
synchronized (lock) {
t = currentTile;
act = active && ready;
}
if (t == null) {
return;
}
t.setState(act ? Tile.STATE_ACTIVE : Tile.STATE_INACTIVE);
t.updateTile();
}
static void setReady(Context ctx, boolean rdy) {
synchronized (lock) {
ready = rdy;
}
updateTile();
}
static void setStatus(Context ctx, boolean act) {
synchronized (lock) {
active = act;
}
updateTile();
}
private void onTileClick() {
boolean act;
synchronized (lock) {
act = active && ready;
}
Intent i = new Intent(act ? IPNReceiver.INTENT_DISCONNECT_VPN : IPNReceiver.INTENT_CONNECT_VPN);
i.setPackage(getPackageName());
i.setClass(getApplicationContext(), com.tailscale.ipn.IPNReceiver.class);
sendBroadcast(i);
}
}

Some files were not shown because too many files have changed in this diff Show More

Loading…
Cancel
Save