Compare commits

..

No commits in common. 'main' and '1.35.108-t692eac23a-g30e46fb8545' have entirely different histories.

@ -1,37 +0,0 @@
name: Android CI
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"
# Clean should essentially be a no-op, but make sure that it works.
- name: Clean
run: make clean
- name: Build APKs
run: make tailscale-debug.apk
- name: Run tests
run: make test

@ -0,0 +1,23 @@
name: Build 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
- name: Build APK
run: make tailscale-debug.apk

@ -0,0 +1,67 @@
name: go-licenses
on:
# run action when a change lands in the main branch which updates go.mod or
# our license template file. Also allow manual triggering.
push:
branches:
- main
paths:
- go.mod
- .github/licenses.tmpl
- .github/workflows/go-licenses.yml
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-$${{ github.head_ref || github.run_id }}
cancel-in-progress: true
jobs:
android:
runs-on: ubuntu-latest
steps:
- name: Check out code
uses: actions/checkout@v3
- name: Check out OSS code
uses: actions/checkout@v3
with:
repository: tailscale/tailscale
path: oss
- name: Set up Go
uses: actions/setup-go@v3
with:
go-version-file: go.mod
- name: Install go-licenses
run: |
go install github.com/google/go-licenses@v1.2.2-0.20220825154955-5eedde1c6584
- name: Run go-licenses
run: |
go-licenses report ./cmd/tailscale --template .github/licenses.tmpl --ignore tailscale.com | tee oss/licenses/android.md
- name: Get access token
uses: tibdex/github-app-token@f717b5ecd4534d3c4df4ce9b5c1c2214f0f7cd06 # v1.6.0
id: generate-token
with:
app_id: ${{ secrets.LICENSING_APP_ID }}
installation_id: ${{ secrets.LICENSING_APP_INSTALLATION_ID }}
private_key: ${{ secrets.LICENSING_APP_PRIVATE_KEY }}
- name: Send pull request
uses: peter-evans/create-pull-request@18f90432bedd2afd6a825469ffd38aa24712a91d #v4.1.1
with:
token: ${{ steps.generate-token.outputs.token }}
path: oss
author: License Updater <noreply@tailscale.com>
Committer: License Updater <noreply@tailscale.com>
branch: licenses/android
commit-message: "licenses: update android licenses"
title: "licenses: update android licenses"
body: Triggered by ${{ github.repository }}@${{ github.sha }}
signoff: true
delete-branch: true
team-reviewers: opensource-license-reviewers

@ -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,19 +0,0 @@
on:
push:
branches:
- "main"
- "release-branch/*"
pull_request:
# all PRs on all branches
merge_group:
branches:
- "main"
jobs:
license_headers:
runs-on: ubuntu-latest
steps:
- name: checkout
uses: actions/checkout@v4
- name: check license headers
run: ./scripts/check_license_headers.sh .

33
.gitignore vendored

@ -6,37 +6,6 @@ build
# The destination for the Go Android archive.
android/libs
android_legacy/libs
# Ignore ABI
android/src/main/jniLibs/*
# Android Studio files
android_legacy/.idea
android_legacy/local.properties
android/.idea
android/local.properties
.idea
# Output files from the Makefile:
*.apk
*.aab
# Signing key
tailscale.jks
# android sdk dir
./android-sdk
# Java profiling output
*.hprof
#IDE
.vscode
.idea
libtailscale.aar
libtailscale-sources.jar
.DS_Store
tailscale.version
tailscale-debug.apk

@ -1,47 +1,50 @@
# This is a Dockerfile for creating a build environment for
# tailscale-android.
FROM --platform=linux/amd64 eclipse-temurin:20-jdk
FROM openjdk:8-jdk
# To enable running android tools such as aapt
RUN apt-get update && apt-get -y upgrade
RUN apt-get install -y libz1 libstdc++6 unzip
RUN apt-get install -y lib32z1 lib32stdc++6
# For Go:
RUN apt-get -y --no-install-recommends install curl gcc
RUN apt-get -y --no-install-recommends install ca-certificates libc6-dev git
RUN apt-get -y install make
RUN mkdir -p build
RUN mkdir -p BUILD
ENV HOME /build
# Make android sdk location, the later make step will populate it.
# Get android sdk, ndk, and rest of the stuff needed to build the android app.
WORKDIR $HOME
RUN mkdir android-sdk
ENV ANDROID_HOME $HOME/android-sdk
ENV ANDROID_SDK_ROOT $ANDROID_HOME
WORKDIR $ANDROID_HOME
RUN curl -O https://dl.google.com/android/repository/sdk-tools-linux-3859397.zip
RUN echo '444e22ce8ca0f67353bda4b85175ed3731cae3ffa695ca18119cbacef1c1bea0 sdk-tools-linux-3859397.zip' | sha256sum -c
RUN unzip sdk-tools-linux-3859397.zip
RUN echo y | $ANDROID_HOME/tools/bin/sdkmanager --update
RUN echo y | $ANDROID_HOME/tools/bin/sdkmanager 'platforms;android-31'
RUN echo y | $ANDROID_HOME/tools/bin/sdkmanager 'extras;android;m2repository'
RUN echo y | $ANDROID_HOME/tools/bin/sdkmanager 'ndk;23.1.7779620'
RUN echo y | $ANDROID_HOME/tools/bin/sdkmanager 'platform-tools'
RUN echo y | $ANDROID_HOME/tools/bin/sdkmanager 'build-tools;28.0.3'
ENV PATH $PATH:$HOME/bin:$ANDROID_HOME/platform-tools
ENV ANDROID_SDK_ROOT /build/android-sdk
# We need some version of Go new enough to support the "embed" package
# to run "go run tailscale.com/cmd/printdep" to figure out which Tailscale Go
# version we need later, but otherwise this toolchain isn't used:
RUN curl -L https://go.dev/dl/go1.23.0.linux-amd64.tar.gz | tar -C /usr/local -zxv
RUN curl -L https://go.dev/dl/go1.17.5.linux-amd64.tar.gz | tar -C /usr/local -zxv
RUN ln -s /usr/local/go/bin/go /usr/bin
RUN mkdir -p $HOME/tailscale-android
RUN git config --global --add safe.directory $HOME/tailscale-android
WORKDIR $HOME/tailscale-android
COPY Makefile Makefile
# Get android sdk, ndk, and rest of the stuff needed to build the android app.
RUN make androidsdk
# Preload Gradle
COPY android/gradlew android/gradlew
COPY android/gradle android/gradle
RUN ./android/gradlew
# Run a shell
CMD /bin/bash

@ -2,315 +2,94 @@
# 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.
# The docker image to use for the build environment. Changing this
# will force a rebuild of the docker image. If there is an existing image
# with this name, it will be used.
#
# The convention here is tailscale-android-build-amd64-<date>
DOCKER_IMAGE=tailscale-android-build-amd64-120924
export TS_USE_TOOLCHAIN=1
DEBUG_APK=tailscale-debug.apk
RELEASE_AAB=tailscale-release.aab
RELEASE_TV_AAB=tailscale-tv-release.aab
LIBTAILSCALE=android/libs/libtailscale.aar
APPID=com.tailscale.ipn
AAR=android/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)))
# 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.
ifeq ($(shell uname),Linux)
ANDROID_TOOLS_URL="https://dl.google.com/android/repository/commandlinetools-linux-9477386_latest.zip"
ANDROID_TOOLS_SUM="bd1aa17c7ef10066949c88dc6c9c8d536be27f992a1f3b5a584f9bd2ba5646a0 commandlinetools-linux-9477386_latest.zip"
else
ANDROID_TOOLS_URL="https://dl.google.com/android/repository/commandlinetools-mac-9477386_latest.zip"
ANDROID_TOOLS_SUM="2072ffce4f54cdc0e6d2074d2f381e7e579b7d63e915c220b96a7db95b2900ee commandlinetools-mac-9477386_latest.zip"
endif
ANDROID_SDK_PACKAGES='platforms;android-31' 'extras;android;m2repository' 'ndk;23.1.7779620' 'platform-tools' 'build-tools;33.0.2'
# Attempt to find an ANDROID_SDK_ROOT / ANDROID_HOME based either from
# preexisting environment or common locations.
export ANDROID_SDK_ROOT ?= $(shell find $$ANDROID_SDK_ROOT $$ANDROID_HOME $$HOME/Library/Android/sdk $$HOME/Android/Sdk $$HOME/AppData/Local/Android/Sdk /usr/lib/android-sdk -maxdepth 1 -type d 2>/dev/null | head -n 1)
# If ANDROID_SDK_ROOT is still unset, set it to a default location by platform.
ifeq ($(ANDROID_SDK_ROOT),)
ifeq ($(shell uname),Linux)
export ANDROID_SDK_ROOT=$(HOME)/Android/Sdk
else ifeq ($(shell uname),Darwin)
export ANDROID_SDK_ROOT=$(HOME)/Library/Android/sdk
else ifneq ($(WINDIR),))
export ANDROID_SDK_ROOT=$(HOME)/AppData/Local/Android/sdk
else
export ANDROID_SDK_ROOT=$(PWD)/android-sdk
endif
endif
export ANDROID_HOME ?= $(ANDROID_SDK_ROOT)
# Attempt to find Android Studio for Linux configuration, which does not have a
# predetermined location.
ANDROID_STUDIO_ROOT ?= $(shell find ~/android-studio /usr/local/android-studio /opt/android-studio /Applications/Android\ Studio.app $(PROGRAMFILES)/Android/Android\ Studio -type d -maxdepth 1 2>/dev/null | head -n 1)
# Set JAVA_HOME to the Android Studio bundled JDK.
export JAVA_HOME ?= $(shell find "$(ANDROID_STUDIO_ROOT)/jbr" "$(ANDROID_STUDIO_ROOT)/jre" "$(ANDROID_STUDIO_ROOT)/Contents/jbr/Contents/Home" "$(ANDROID_STUDIO_ROOT)/Contents/jre/Contents/Home" -maxdepth 1 -type d 2>/dev/null | head -n 1)
# 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
AVD_BASE_IMAGE := "system-images;android-33;google_apis;"
export HOST_ARCH=$(shell uname -m)
ifeq ($(HOST_ARCH),aarch64)
AVD_IMAGE := "$(AVD_BASE_IMAGE)arm64-v8a"
else ifeq ($(HOST_ARCH),arm64)
AVD_IMAGE := "$(AVD_BASE_IMAGE)arm64-v8a"
else
AVD_IMAGE := "$(AVD_BASE_IMAGE)x86_64"
endif
AVD ?= tailscale-$(HOST_ARCH)
export AVD_IMAGE
export AVD
# Use our toolchain or the one that is specified, do not perform dynamic toolchain switching.
GOTOOLCHAIN=local
export GOTOOLCHAIN
# TOOLCHAINDIR is set by fdoid CI and used by tool/* scripts.
TOOLCHAINDIR ?=
export TOOLCHAINDIR
GOBIN ?= $(PWD)/android/build/go/bin
export GOBIN
export PATH := $(PWD)/tool:$(GOBIN):$(ANDROID_HOME)/cmdline-tools/latest/bin:$(ANDROID_HOME)/platform-tools:$(PATH)
VERSIONCODE=$(lastword $(shell grep versionCode android/build.gradle))
VERSIONCODE_PLUSONE=$(shell expr $(VERSIONCODE) + 1)
TOOLCHAINREV=$(shell go run tailscale.com/cmd/printdep --go)
TOOLCHAINDIR=${HOME}/.cache/tailscale-android-go-$(TOOLCHAINREV)
TOOLCHAINSUM=$(shell $(TOOLCHAINDIR)/go/bin/go version >/dev/null && echo "okay" || echo "bad")
TOOLCHAINWANT=okay
export PATH := $(TOOLCHAINDIR)/go/bin:$(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
# Builds the release AAB and signs it (phone/tablet/chromeOS variant)
.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
# Builds the release AAB and signs it (androidTV variant)
.PHONY: release-tv
release-tv: jarsign-env $(RELEASE_TV_AAB) ## Build the release AAB
@jarsigner -sigalg SHA256withRSA -digestalg SHA-256 -keystore $(JKS_PATH) -storepass $(JKS_PASSWORD) $(RELEASE_TV_AAB) tailscale
# gradle-dependencies groups together the android sources and libtailscale needed to assemble tests/debug/release builds.
.PHONY: gradle-dependencies
gradle-dependencies: $(shell find android -type f -not -path "android/build/*" -not -path '*/.*') $(LIBTAILSCALE) tailscale.version
$(DEBUG_APK): gradle-dependencies
(cd android && ./gradlew test assembleDebug)
install -C android/build/outputs/apk/debug/android-debug.apk $@
$(RELEASE_AAB): gradle-dependencies
@echo "Building release AAB"
(cd android && ./gradlew test bundleRelease)
install -C ./android/build/outputs/bundle/release/android-release.aab $@
$(RELEASE_TV_AAB): gradle-dependencies
@echo "Building TV release AAB"
(cd android && ./gradlew test bundleRelease_tv)
install -C ./android/build/outputs/bundle/release_tv/android-release_tv.aab $@
tailscale-test.apk: gradle-dependencies
(cd android && ./gradlew assembleApplicationTestAndroidTest)
install -C ./android/build/outputs/apk/androidTest/applicationTest/android-applicationTest-androidTest.apk $@
tailscale.version: go.mod go.sum $(wildcard .git/HEAD)
$(shell ./tool/go run tailscale.com/cmd/mkversion > tailscale.version)
.PHONY: version
version: tailscale.version ## print the current version information
cat tailscale.version
#
# Go Builds:
#
android/libs:
mkdir -p android/libs
$(GOBIN)/gomobile: $(GOBIN)/gobind go.mod go.sum
./tool/go install golang.org/x/mobile/cmd/gomobile
$(GOBIN)/gobind: go.mod go.sum
./tool/go install golang.org/x/mobile/cmd/gobind
$(LIBTAILSCALE): Makefile android/libs $(shell find libtailscale -name *.go) go.mod go.sum $(GOBIN)/gomobile tailscale.version
$(GOBIN)/gomobile bind -target android -androidapi 26 \
-tags "$$(./build-tags.sh)" \
-ldflags "-w $$(./version-ldflags.sh)" \
-o $@ ./libtailscale
.PHONY: libtailscale
libtailscale: $(LIBTAILSCALE) ## Build the libtailscale AAR
#
# Utility tasks:
#
.PHONY: all
all: test $(DEBUG_APK) ## Build and test everything
.PHONY: env
env:
@echo PATH=$(PATH)
@echo ANDROID_SDK_ROOT=$(ANDROID_SDK_ROOT)
@echo ANDROID_HOME=$(ANDROID_HOME)
@echo ANDROID_STUDIO_ROOT=$(ANDROID_STUDIO_ROOT)
@echo JAVA_HOME=$(JAVA_HOME)
@echo TOOLCHAINDIR=$(TOOLCHAINDIR)
@echo AVD_IMAGE="$(AVD_IMAGE)"
# Ensure that JKS_PATH and JKS_PASSWORD are set before we attempt a build
# that requires signing.
.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)
all: $(APK)
tag_release:
sed -i'.bak' 's/versionCode [[:digit:]]\+/versionCode $(VERSIONCODE_PLUSONE)/' android/build.gradle
sed -i'.bak' 's/versionName .*/versionName "$(VERSION_LONG)"/' android/build.gradle
git commit -sm "android: bump version code" android/build.gradle
git tag -a "$(VERSION_LONG)"
bumposs: toolchain
GOPROXY=direct go get tailscale.com@main
go mod tidy -compat=1.19
toolchain:
ifneq ($(TOOLCHAINWANT),$(TOOLCHAINSUM))
@echo want: $(TOOLCHAINWANT)
@echo got: $(TOOLCHAINSUM)
rm -rf ${HOME}/.cache/tailscale-android-go-*
mkdir -p $(TOOLCHAINDIR)
curl --silent -L $(shell go run tailscale.com/cmd/printdep --go-url) | tar -C $(TOOLCHAINDIR) -zx
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: tailscale.version ## Tag the current commit with the current version
source tailscale.version && git tag -a "$${VERSION_LONG}" -m "OSS and Version updated to $${VERSION_LONG}"
.PHONY: bumposs ## Bump to the latest oss and update the versions.
bumposs: update-oss tailscale.version
source tailscale.version && git commit -sm "android: bump OSS" -m "OSS and Version updated to $${VERSION_LONG}" go.toolchain.rev android/build.gradle go.mod go.sum
source tailscale.version && git tag -a "$${VERSION_LONG}" -m "OSS and Version updated to $${VERSION_LONG}"
.PHONY: bump_version_code
bump_version_code: ## Bump the version code in build.gradle
sed -i'.bak' "s/versionCode .*/versionCode $$(expr $$(awk '/versionCode ([0-9]+)/{print $$2}' android/build.gradle) + 1)/" android/build.gradle && rm android/build.gradle.bak
.PHONY: update-oss
update-oss: ## Update the tailscale.com go module
GOPROXY=direct ./tool/go get tailscale.com@main
./tool/go mod tidy -compat=1.23
./tool/go run tailscale.com/cmd/printdep --go > go.toolchain.rev.new
mv go.toolchain.rev.new go.toolchain.rev
# Get the commandline tools package, this provides (among other things) the sdkmanager binary.
$(ANDROID_HOME)/cmdline-tools/latest/bin/sdkmanager:
mkdir -p $(ANDROID_HOME)/tmp
mkdir -p $(ANDROID_HOME)/cmdline-tools
(cd $(ANDROID_HOME)/tmp && \
curl --silent -O -L $(ANDROID_TOOLS_URL) && \
echo $(ANDROID_TOOLS_SUM) | sha256sum -c && \
unzip $(shell basename $(ANDROID_TOOLS_URL)))
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
$(ANDROID_HOME)/cmdline-tools/latest/bin/sdkmanager $(ANDROID_SDK_PACKAGES)
# 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\
\tANDROID_HOME=$(ANDROID_HOME)\n\
\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)
.PHONY: emulator
emulator: ## Start an android emulator instance
@echo "Checking installed SDK packages..."
@if ! $(ANDROID_HOME)/cmdline-tools/latest/bin/sdkmanager --list_installed | grep -q "$(AVD_IMAGE)"; then \
echo "$(AVD_IMAGE) not found, installing..."; \
$(ANDROID_HOME)/cmdline-tools/latest/bin/sdkmanager "$(AVD_IMAGE)"; \
fi
@echo "Checking if AVD exists..."
@if ! $(ANDROID_HOME)/cmdline-tools/latest/bin/avdmanager list avd | grep -q "$(AVD)"; then \
echo "AVD $(AVD) not found, creating..."; \
$(ANDROID_HOME)/cmdline-tools/latest/bin/avdmanager create avd -n "$(AVD)" -k "$(AVD_IMAGE)"; \
fi
@echo "Starting emulator..."
@$(ANDROID_HOME)/emulator/emulator -avd "$(AVD)" -logcat-output /dev/stdout -netdelay none -netspeed full
.PHONY: install
install: $(DEBUG_APK) ## Install the debug APK on a connected device
adb install -r $<
.PHONY: run
run: install ## Run the debug APK on a connected device
adb shell am start -n com.tailscale.ipn/com.tailscale.ipn.MainActivity
.PHONY: docker-build-image
docker-build-image: ## Builds the docker image for the android build environment if it does not exist
@echo "Checking if docker image $(DOCKER_IMAGE) already exists..."
@if ! docker images $(DOCKER_IMAGE) -q | grep -q . ; then \
echo "Image does not exist. Building..."; \
docker build -f docker/DockerFile.amd64-build -t $(DOCKER_IMAGE) .; \
fi
.PHONY: docker-run-build
docker-run-build: clean jarsign-env docker-build-image ## Runs the docker image for the android build environment and builds release
@docker run --rm -v $(CURDIR):/build/tailscale-android --env JKS_PASSWORD=$(JKS_PASSWORD) --env JKS_PATH=$(JKS_PATH) $(DOCKER_IMAGE)
$(DEBUG_APK): toolchain
mkdir -p android/libs
go run gioui.org/cmd/gogio -buildmode archive -target android -appid $(APPID) -tags novulkan -o $(AAR) github.com/tailscale/tailscale-android/cmd/tailscale
(cd android && ./gradlew test assemblePlayDebug)
mv android/build/outputs/apk/play/debug/android-play-debug.apk $@
rundebug: $(DEBUG_APK)
adb install -r $(DEBUG_APK)
adb shell am start -n com.tailscale.ipn/com.tailscale.ipn.IPNActivity
# 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: toolchain
mkdir -p android/libs
go run gioui.org/cmd/gogio -buildmode archive -target android -appid $(APPID) -tags novulkan -o $(AAR) github.com/tailscale/tailscale-android/cmd/tailscale
(cd android && ./gradlew test assembleFdroidDebug)
mv android/build/outputs/apk/fdroid/debug/android-fdroid-debug.apk $@
.PHONY: docker-remove-build-image
docker-remove-build-image: ## Removes the current docker build image
docker rmi --force $(DOCKER_IMAGE)
# This target is also used by the F-Droid builder.
release_aar: toolchain
release_aar:
mkdir -p android/libs
go run gioui.org/cmd/gogio -ldflags "-X tailscale.com/version.Long=$(VERSIONNAME) -X tailscale.com/version.Short=$(VERSIONNAME_SHORT) -X tailscale.com/version.GitCommit=$(TAILSCALE_COMMIT) -X tailscale.com/version.ExtraGitCommit=$(OUR_VERSION)" -buildmode archive -target android -appid $(APPID) -tags novulkan -o $(AAR) github.com/tailscale/tailscale-android/cmd/tailscale
.PHONY: docker-all ## Makes a fresh docker environment, builds docker and cleans up. For CI.
docker-all: docker-build-image docker-run-build $(DOCKER_IMAGE)
$(RELEASE_AAB): release_aar
(cd android && ./gradlew test bundlePlayRelease)
mv ./android/build/outputs/bundle/playRelease/android-play-release.aab $@
.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 --rm -v $(CURDIR):/build/tailscale-android -it tailscale-android-shell-amd64
release: $(RELEASE_AAB)
jarsigner -sigalg SHA256withRSA -digestalg SHA-256 -keystore $(KEYSTORE) $(RELEASE_AAB) $(KEYSTORE_ALIAS)
.PHONY: docker-remove-shell-image
docker-remove-shell-image: ## Removes all docker shell image
docker rmi --force tailscale-android-shell-amd64
install: $(DEBUG_APK)
adb install -r $(DEBUG_APK)
.PHONY: clean
clean: ## Remove build artifacts. Does not purge docker build envs. Use dockerRemoveEnv for that.
@echo "Cleaning up old build artifacts"
-rm -rf android/build $(DEBUG_APK) $(RELEASE_AAB) $(RELEASE_TV_AAB) $(LIBTAILSCALE) android/libs *.apk *.aab
@echo "Cleaning cached toolchain"
-rm -rf $(HOME)/.cache/tailscale-go{,.extracted}
-pkill -f gradle
-rm tailscale.version
dockershell:
docker build -t tailscale-android .
docker run -v $(CURDIR):/build/tailscale-android -it --rm tailscale-android
.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 ""
clean:
rm -rf android/build $(RELEASE_AAB) $(DEBUG_APK) $(AAR)
.DEFAULT_GOAL := help
.PHONY: all clean install $(DEBUG_APK) $(RELEASE_AAB) release_aar release bump_version dockershell

@ -10,87 +10,40 @@ 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
There are several options for setting up a build environment. The Android Studio
path is the most useful path for longer term development.
In all cases you will need:
- Go runtime
- Android SDK
- Android SDK components (`make androidsdk` will install them)
### Android Studio
1. Install a Go runtime (https://go.dev/dl/).
2. Install Android Studio (https://developer.android.com/studio).
3. Start Android Studio, from the Welcome screen select "More Actions" and "SDK Manager".
4. In the SDK manager, select the "SDK Tools" tab and install the "Android SDK Command-line Tools (latest)".
3. Run `make androidsdk` to install the necessary SDK components.
If you would prefer to avoid Android Studio, you can also install an Android
SDK. The makefile detects common paths, so `sudo apt install android-sdk` is
sufficient on Debian / Ubuntu systems. To use an Android SDK installed in a
non-standard location, set the `ANDROID_SDK_ROOT` environment variable to the
path to the SDK.
If you installed Android Studio the tools may not be in your path. To get the
correct tool path, run `make androidpath` and export the provided path in your
shell.
#### Code Formatting
The ktmft plugin on the default setting should be used to autoformat all Java, Kotlin
and XML files in Android Studio. Enable "Format on Save".
### Docker
## Building
If you wish to avoid installing software on your host system, a Docker based development strategy is available, you can build and start a shell with:
[Go](https://golang.org), the [Android
SDK](https://developer.android.com/studio/releases/platform-tools),
the [Android NDK](https://developer.android.com/ndk) are required.
```sh
make docker-shell
$ make tailscale-debug.apk
$ adb install -r tailscale-debug.apk
```
Several other makefile recipes are available for setting up the proper build environment and running builds.
Note that the docker makefile recipes s will preserve the image and remove container on completion.
If changes are made to the build environment or toolchain, cached docker images may need to be rebuilt.
The docker build image name is parameterized in the makefile and changing it provides a simple means to do this.
### Nix
If you have Nix 2.4 or later installed, a Nix development environment can
be set up with:
The `dockershell` target builds a container with the necessary
dependencies and runs a shell inside it.
```sh
alias nix='nix --extra-experimental-features "nix-command flakes"'
nix develop
$ make dockershell
# make tailscale-debug.apk
```
## Building
If you have Nix 2.4 or later installed, a Nix development environment can
be set up with
```sh
make apk
make install
$ alias nix='nix --extra-experimental-features "nix-command flakes"'
$ nix develop
```
## Building a release
Use `make tag_release` to bump the Android version code, update the version
name, and tag the current commit.
@ -99,6 +52,37 @@ 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:
@ -109,10 +93,23 @@ 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
```
## Building on macOS
To build from the CLI on macOS:
1. Install Android Studio
2. In Android Studio's home screen: "More Actions" > "SDK Manager", install NDK.
3. You can now close Android Studio, unless you want it to create virtual devices
("More Actions" > "Virtual Device Manager").
4. Then, from CLI:
5. `export JAVA_HOME='/Applications/Android Studio.app/Contents/jre/Contents/Home'`
6. `export ANDROID_SDK_ROOT=$HOME/Library/Android/sdk`
7. `make tailscale-fdroid.apk`, etc
## Bugs
Please file any issues about this code or the hosted service on
@ -131,8 +128,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.

@ -1,195 +1,57 @@
buildscript {
ext.kotlin_version = "1.9.22"
ext.compose_version = "1.5.10"
ext.accompanist_version = "0.34.0"
repositories {
google()
mavenCentral()
maven {
url = uri("https://plugins.gradle.org/m2/")
}
jcenter()
}
dependencies {
classpath 'com.android.tools.build:gradle:8.6.1'
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
classpath "org.jetbrains.kotlin:kotlin-serialization:$kotlin_version"
classpath("com.ncorti.ktfmt.gradle:plugin:0.17.0")
classpath 'com.android.tools.build:gradle:4.2.0'
}
}
allprojects {
repositories {
google()
mavenCentral()
jcenter()
flatDir {
dirs 'libs'
}
}
}
apply plugin: 'kotlin-android'
apply plugin: 'com.android.application'
apply plugin: 'org.jetbrains.kotlin.plugin.serialization'
apply plugin: 'com.ncorti.ktfmt.gradle'
android {
ndkVersion "23.1.7779620"
compileSdkVersion 34
compileSdkVersion 30
defaultConfig {
minSdkVersion 26
targetSdkVersion 34
versionCode 242
versionName getVersionProperty("VERSION_LONG")
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
// This setting, which defaults to 'true', will cause Tailscale to fall
// back to the Google DNS servers if it cannot determine what the
// operating system's DNS configuration is.
//
// Set it to false either here or in your local.properties file to
// disable this behaviour.
buildConfigField "boolean", "USE_GOOGLE_DNS_FALLBACK", getLocalProperty("tailscale.useGoogleDnsFallback", "true")
minSdkVersion 22
targetSdkVersion 31
versionCode 144
versionName "1.35.80-t237f030cd-gfd874ed58e9"
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_17
}
lintOptions {
warningsAsErrors true
}
kotlinOptions {
jvmTarget = "17"
}
kotlinOptions {
jvmTarget = "17"
}
buildFeatures {
buildConfig true
compose true
}
composeOptions {
kotlinCompilerExtensionVersion = "$compose_version"
sourceCompatibility 1.8
targetCompatibility 1.8
}
flavorDimensions "version"
namespace 'com.tailscale.ipn'
buildTypes {
applicationTest {
initWith debug
manifestPlaceholders.leanbackRequired = false
buildConfigField "String", "GITHUB_USERNAME", "\"" + getLocalProperty("githubUsername", "")+"\""
buildConfigField "String", "GITHUB_PASSWORD", "\"" + getLocalProperty("githubPassword", "")+"\""
buildConfigField "String", "GITHUB_2FA_SECRET", "\"" + getLocalProperty("github2FASecret", "")+"\""
}
debug {
manifestPlaceholders.leanbackRequired = false
}
release {
manifestPlaceholders.leanbackRequired = false
minifyEnabled true
shrinkResources true
proguardFiles getDefaultProguardFile(
'proguard-android-optimize.txt'),
'proguard-rules.pro'
productFlavors {
fdroid {
// The fdroid flavor contains only free dependencies and is suitable
// for the F-Droid app store.
}
release_tv {
initWith release
manifestPlaceholders.leanbackRequired = true
play {
// The play flavor contains all features and is for the Play Store.
}
}
testBuildType "applicationTest"
}
dependencies {
// Android dependencies.
implementation "androidx.core:core:1.13.1"
implementation 'androidx.core:core-ktx:1.13.1'
implementation "androidx.browser:browser:1.8.0"
implementation "androidx.security:security-crypto:1.1.0-alpha06"
implementation "androidx.work:work-runtime:2.9.1"
// Kotlin dependencies.
implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.3"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.1"
implementation 'junit:junit:4.13.2'
runtimeOnly "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1"
implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version"
implementation "org.jetbrains.kotlin:kotlin-reflect:$kotlin_version"
// Compose dependencies.
def composeBom = platform('androidx.compose:compose-bom:2024.09.03')
implementation composeBom
implementation 'androidx.compose.material3:material3:1.3.0'
implementation 'androidx.compose.material:material-icons-core:1.7.3'
implementation "androidx.compose.ui:ui:1.7.3"
implementation "androidx.compose.ui:ui-tooling:1.7.3"
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.8.6'
implementation 'androidx.activity:activity-compose:1.9.2'
implementation "com.google.accompanist:accompanist-permissions:$accompanist_version"
implementation "com.google.accompanist:accompanist-systemuicontroller:$accompanist_version"
implementation "androidx.core:core-splashscreen:1.1.0-rc01"
implementation "androidx.compose.animation:animation:1.7.4"
// Navigation dependencies.
def nav_version = "2.8.2"
implementation "androidx.navigation:navigation-compose:$nav_version"
implementation "androidx.navigation:navigation-ui-ktx:$nav_version"
// Supporting libraries.
implementation("io.coil-kt:coil-compose:2.6.0")
implementation("com.google.zxing:core:3.5.1")
implementation("com.patrykandpatrick.vico:compose:1.15.0")
implementation("com.patrykandpatrick.vico:compose-m3:1.15.0")
// Tailscale dependencies.
implementation ':libtailscale@aar'
// Integration Tests
androidTestImplementation composeBom
androidTestImplementation 'androidx.test:runner:1.6.2'
androidTestImplementation 'androidx.test.ext:junit-ktx:1.2.1'
androidTestImplementation 'androidx.test.ext:junit:1.2.1'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.6.1'
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'
testImplementation 'org.mockito:mockito-core:5.12.0'
testImplementation 'org.mockito:mockito-inline:5.2.0'
testImplementation 'org.mockito.kotlin:mockito-kotlin:5.4.0'
debugImplementation("androidx.compose.ui:ui-tooling")
implementation("androidx.compose.ui:ui-tooling-preview")
}
def getLocalProperty(key, defaultValue) {
try {
Properties properties = new Properties()
properties.load(project.file('local.properties').newDataInputStream())
return properties.getProperty(key) ?: defaultValue
} catch(Throwable ignored) {
return defaultValue
}
}
def getVersionProperty(key) {
// tailscale.version is created / updated by the makefile, it is in a loosely
// Makfile/envfile format, which is also loosely a properties file format.
// make tailscale.version
def versionProps = new Properties()
versionProps.load(project.file('../tailscale.version').newDataInputStream())
return versionProps.getProperty(key).replaceAll('^\"|\"$', '')
implementation "androidx.core:core:1.2.0"
implementation "androidx.browser:browser:1.2.0"
implementation "androidx.security:security-crypto:1.1.0-alpha03"
implementation ':ipn@aar'
testCompile "junit:junit:4.12"
// Non-free dependencies.
playImplementation 'com.google.android.gms:play-services-auth:18.0.0'
}

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

@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionSha256Sum=544c35d6bd849ae8a5ed0bcea39ba677dc40f49df7d1835561582da2009b961d
distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip
distributionSha256Sum=3239b5ed86c3838a37d983ac100573f64c1f3fd8e1eb6c89fa5f9529b5ec091d
distributionUrl=https\://services.gradle.org/distributions/gradle-6.7.1-bin.zip
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

2
android/gradlew vendored

@ -44,7 +44,7 @@ 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"'
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD="maximum"

@ -33,7 +33,7 @@ set APP_HOME=%DIRNAME%
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"
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome

@ -1,25 +0,0 @@
# Keep all classes with native methods
-keepclasseswithmembernames class * {
native <methods>;
}
# Keep the classes with syspolicy MDM keys, some of which
# get used only by the Go backend.
-keep class com.tailscale.ipn.mdm.** { *; }
# 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.**

@ -1,163 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn
import android.os.Build
import android.util.Log
import android.widget.Button
import android.widget.EditText
import androidx.test.ext.junit.rules.activityScenarioRule
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.LargeTest
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.uiautomator.By
import androidx.test.uiautomator.UiDevice
import androidx.test.uiautomator.UiSelector
import dev.turingcomplete.kotlinonetimepassword.HmacAlgorithm
import dev.turingcomplete.kotlinonetimepassword.TimeBasedOneTimePasswordConfig
import dev.turingcomplete.kotlinonetimepassword.TimeBasedOneTimePasswordGenerator
import java.net.HttpURLConnection
import java.net.URL
import java.util.concurrent.TimeUnit
import kotlin.time.Duration.Companion.minutes
import kotlin.time.Duration.Companion.seconds
import org.apache.commons.codec.binary.Base32
import org.junit.After
import org.junit.Assert
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
@LargeTest
class MainActivityTest {
companion object {
const val TAG = "MainActivityTest"
}
@get:Rule val activityRule = activityScenarioRule<MainActivity>()
@Before fun setUp() {}
@After fun tearDown() {}
/**
* This test starts with a clean install, logs the user in to a tailnet using credentials provided
* through a build config, and then makes sure we can hit https://hello.ts.net.
*/
@Test
fun loginAndVisitHello() {
val githubUsername = BuildConfig.GITHUB_USERNAME
val githubPassword = BuildConfig.GITHUB_PASSWORD
val github2FASecret = Base32().decode(BuildConfig.GITHUB_2FA_SECRET)
val config =
TimeBasedOneTimePasswordConfig(
codeDigits = 6,
hmacAlgorithm = HmacAlgorithm.SHA1,
timeStep = 30,
timeStepUnit = TimeUnit.SECONDS)
val githubTOTP = TimeBasedOneTimePasswordGenerator(github2FASecret, config)
val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation())
Log.d(TAG, "Click through Get Started screen")
device.find(By.text("Get Started"))
device.find(By.text("Get Started")).click()
Log.d(TAG, "Wait for VPN permission prompt and accept")
device.find(By.text("Connection request"))
device.find(By.text("OK")).click()
asNecessary(
2.minutes,
{
Log.d(TAG, "Log in")
device.find(By.text("Log in")).click()
},
{
Log.d(TAG, "Accept Chrome terms and conditions (if necessary)")
device.find(By.text("Welcome to Chrome"))
val dismissIndex =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) 1 else 0
device.find(UiSelector().instance(dismissIndex).className(Button::class.java)).click()
},
{
Log.d(TAG, "Don't turn on sync")
device.find(By.text("Turn on sync?"))
device.find(By.text("No thanks")).click()
},
{
Log.d(TAG, "Log in with GitHub")
device.find(By.text("Sign in with GitHub")).click()
},
{
Log.d(TAG, "Make sure GitHub page has loaded")
device.find(By.text("Username or email address"))
device.find(By.text("Sign in"))
},
{
Log.d(TAG, "Enter credentials")
device
.find(UiSelector().instance(0).className(EditText::class.java))
.setText(githubUsername)
device
.find(UiSelector().instance(1).className(EditText::class.java))
.setText(githubPassword)
device.find(By.text("Sign in")).click()
},
{
Log.d(TAG, "Enter 2FA")
device.find(By.text("Two-factor authentication"))
device
.find(UiSelector().instance(0).className(EditText::class.java))
.setText(githubTOTP.generate())
device.find(UiSelector().instance(0).className(Button::class.java)).click()
},
{
Log.d(TAG, "Authorizing Tailscale")
device.find(By.text("Authorize tailscale")).click()
},
{
Log.d(TAG, "Accept Tailscale app")
device.find(By.text("Learn more about OAuth"))
// Sleep a little to give button time to activate
Thread.sleep(5.seconds.inWholeMilliseconds)
device.find(UiSelector().instance(1).className(Button::class.java)).click()
},
{
Log.d(TAG, "Connect device")
device.find(By.text("Connect device"))
device.find(UiSelector().instance(0).className(Button::class.java)).click()
})
try {
Log.d(TAG, "Accept Permission (Either Storage or Notifications)")
device.find(By.text("Continue")).click()
device.find(By.text("Allow")).click()
} catch (t: Throwable) {
// we're not always prompted for permissions, that's okay
}
Log.d(TAG, "Wait for VPN to connect")
device.find(By.text("Connected"))
val helloResponse = helloTSNet
Assert.assertTrue(
"Response from hello.ts.net should show success",
helloResponse.contains("You're connected over Tailscale!"))
}
}
private val helloTSNet: String
get() {
return URL("https://hello.ts.net").run {
openConnection().run {
this as HttpURLConnection
connectTimeout = 30000
readTimeout = 5000
inputStream.bufferedReader().readText()
}
}
}

@ -1,92 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn
import android.util.Log
import androidx.test.uiautomator.BySelector
import androidx.test.uiautomator.UiDevice
import androidx.test.uiautomator.UiObject
import androidx.test.uiautomator.UiObject2
import androidx.test.uiautomator.UiSelector
import androidx.test.uiautomator.Until
import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds
private val defaultTimeout = 10.seconds
private val threadLocalTimeout = ThreadLocal<Duration>()
/**
* Wait until the specified timeout for the given selector and return the matching UiObject2.
* Timeout defaults to 10 seconds.
*
* @throws Exception if selector is not found within timeout.
*/
fun UiDevice.find(
selector: BySelector,
timeout: Duration = threadLocalTimeout.get() ?: defaultTimeout
): UiObject2 {
wait(Until.findObject(selector), timeout.inWholeMilliseconds)?.let {
return it
} ?: run { throw Exception("not found") }
}
/**
* Wait until the specified timeout for the given selector and return the matching UiObject. Timeout
* defaults to 10 seconds.
*
* @throws Exception if selector is not found within timeout.
*/
fun UiDevice.find(
selector: UiSelector,
timeout: Duration = threadLocalTimeout.get() ?: defaultTimeout
): UiObject {
val obj = findObject(selector)
if (!obj.waitForExists(timeout.inWholeMilliseconds)) {
throw Exception("not found")
}
return obj
}
/**
* Execute an ordered collection of steps as necessary. If an earlier step fails but a subsequent
* step succeeds, this skips the earlier step. This is useful for interruptible sequences like
* logging in that may resume in an intermediate state.
*/
fun asNecessary(timeout: Duration, vararg steps: () -> Unit) {
val interval = 250.milliseconds
// Use a short timeout to avoid waiting on actions that can be skipped
threadLocalTimeout.set(interval)
try {
val start = System.currentTimeMillis()
var furthestSuccessful = -1
while (System.currentTimeMillis() - start < timeout.inWholeMilliseconds) {
for (i in furthestSuccessful + 1 ..< steps.size) {
val step = steps[i]
try {
step()
furthestSuccessful = i
Log.d("TestUtil.asNecessary", "SUCCESS!")
// Going forward, use the normal timeout on the assumption that subsequent steps will
// succeed.
threadLocalTimeout.remove()
} catch (t: Throwable) {
Log.d("TestUtil.asNecessary", t.toString())
// Going forward, use a short timeout to avoid waiting on actions that can be skipped
threadLocalTimeout.set(interval)
}
}
if (furthestSuccessful == steps.size - 1) {
// All steps have completed successfully
return
}
// Still some steps left to run
Thread.sleep(interval.inWholeMilliseconds)
}
throw Exception("failed to complete within timeout")
} finally {
threadLocalTimeout.remove()
}
}

@ -1,72 +1,40 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
package="com.tailscale.ipn">
<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.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="29" />
<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" />
<uses-permission
android:name="android.permission.QUERY_ALL_PACKAGES"
tools:ignore="QueryAllPackagesPermission" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="28"/>
<!-- Disable input emulation on ChromeOS -->
<uses-feature
android:name="android.hardware.type.pc"
android:required="false" />
<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="${leanbackRequired}" />
<uses-feature
android:name="android.hardware.touchscreen"
android:required="false" />
<uses-feature android:name="android.software.leanback" android:required="false" />
<uses-feature android:name="android.hardware.touchscreen" android:required="false" />
<application
android:name=".App"
android:allowBackup="false"
<application android:label="Tailscale" android:icon="@mipmap/ic_launcher" android:roundIcon="@mipmap/ic_launcher_round"
android:banner="@drawable/tv_banner"
android:icon="@mipmap/ic_launcher"
android:label="Tailscale"
android:requestLegacyExternalStorage="true"
android:roundIcon="@mipmap/ic_launcher_round"
android:theme="@style/Theme.App.SplashScreen">
<activity
android:name="MainActivity"
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|keyboardHidden"
android:exported="true"
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:windowSoftInputMode="adjustResize">
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>
</activity>
<activity
android:name="ShareActivity"
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|keyboardHidden"
android:exported="true"
android:label="@string/app_name"
android:launchMode="singleTask"
android:theme="@style/Theme.MaterialComponents.DayNight.NoActionBar"
android:windowSoftInputMode="adjustResize">
<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/*" />
@ -78,7 +46,6 @@
<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/*" />
@ -88,39 +55,23 @@
<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" />
<action android:name="com.tailscale.ipn.USE_EXIT_NODE" />
</intent-filter>
</receiver>
<service
android:name=".IPNService"
android:exported="false"
android:foregroundServiceType="systemExempted"
android:permission="android.permission.BIND_VPN_SERVICE">
<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:exported="true"
android:icon="@drawable/ic_tile"
android:label="@string/tile_name"
android:permission="android.permission.BIND_QUICK_SETTINGS_TILE">
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>
<meta-data
android:name="android.content.APP_RESTRICTIONS"
android:resource="@xml/app_restrictions" />
</application>
</manifest>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 17 KiB

@ -0,0 +1,404 @@
// Copyright (c) 2020 Tailscale Inc & AUTHORS All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
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 final static 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 final static Handler mainHandler = new Handler(Looper.getMainLooper());
public DnsConfig dns = new DnsConfig(this);
public DnsConfig getDnsConfigObj() { return this.dns; }
@Override public void onCreate() {
super.onCreate();
// Load and initialize the Go library.
Gio.init(this);
registerNetworkCallback();
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);
}
private void registerNetworkCallback() {
ConnectivityManager cMgr = (ConnectivityManager) this.getSystemService(Context.CONNECTIVITY_SERVICE);
cMgr.registerNetworkCallback(new NetworkRequest.Builder().build(), new ConnectivityManager.NetworkCallback() {
private void reportConnectivityChange() {
NetworkInfo active = cMgr.getActiveNetworkInfo();
// https://developer.android.com/training/monitoring-device-state/connectivity-status-type
boolean isConnected = active != null && active.isConnectedOrConnecting();
onConnectivityChanged(isConnected);
}
@Override
public void onLost(Network network) {
super.onLost(network);
this.reportConnectivityChange();
}
@Override
public void onLinkPropertiesChanged(Network network, LinkProperties linkProperties) {
super.onLinkPropertiesChanged(network, linkProperties);
this.reportConnectivityChange();
}
});
}
public void startVPN() {
Intent intent = new Intent(this, IPNService.class);
intent.setAction(IPNService.ACTION_CONNECT);
startService(intent);
}
public void stopVPN() {
Intent intent = new Intent(this, IPNService.class);
intent.setAction(IPNService.ACTION_DISCONNECT);
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
);
}
void setTileReady(boolean ready) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) {
return;
}
QuickToggleService.setReady(this, ready);
}
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 nameFromSystemBluetooth = Settings.System.getString(getContentResolver(), "bluetooth_name");
String nameFromSecureBluetooth = Settings.Secure.getString(getContentResolver(), "bluetooth_name");
String nameFromSystemDevice = Settings.Secure.getString(getContentResolver(), "device_name");
if (!isEmpty(nameFromSystemBluetooth)) return nameFromSystemBluetooth;
if (!isEmpty(nameFromSecureBluetooth)) return nameFromSecureBluetooth;
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());
}
private 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 onConnectivityChanged(boolean connected);
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;
}
}

@ -1,587 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn
import android.Manifest
import android.app.Application
import android.app.Notification
import android.app.NotificationChannel
import android.app.PendingIntent
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.content.SharedPreferences
import android.content.pm.PackageManager
import android.net.ConnectivityManager
import android.os.Build
import android.os.Environment
import android.util.Log
import androidx.core.app.ActivityCompat
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.ViewModelStore
import androidx.lifecycle.ViewModelStoreOwner
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
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 com.tailscale.ipn.ui.viewModel.VpnViewModel
import com.tailscale.ipn.ui.viewModel.VpnViewModelFactory
import com.tailscale.ipn.util.TSLog
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import libtailscale.Libtailscale
import java.io.File
import java.io.IOException
import java.net.NetworkInterface
import java.security.GeneralSecurityException
import java.util.Locale
class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner {
val applicationScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
companion object {
private const val FILE_CHANNEL_ID = "tailscale-files"
private const val TAG = "App"
private lateinit var appInstance: App
/**
* 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()
return appInstance
}
}
val dns = DnsConfig()
private lateinit var connectivityManager: ConnectivityManager
private lateinit var app: libtailscale.Application
override val viewModelStore: ViewModelStore
get() = appViewModelStore
private val appViewModelStore: ViewModelStore by lazy { ViewModelStore() }
var healthNotifier: HealthNotifier? = null
override fun getPlatformDNSConfig(): String = dns.dnsConfigAsString
override fun getInstallSource(): String = AppSourceChecker.getInstallSource(this)
override fun shouldUseGoogleDNSFallback(): Boolean = BuildConfig.USE_GOOGLE_DNS_FALLBACK
override fun log(s: String, s1: String) {
Log.d(s, s1)
}
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()
viewModelStore.clear()
}
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
// to the given folder. We will preferentially use <shared>/Downloads and fallback to
// 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
NetworkChangeCallback.monitorDnsChanges(connectivityManager, dns)
initViewModels()
applicationScope.launch {
Notifier.state.collect { state ->
combine(Notifier.state, MDMSettings.forceEnabled.flow) { state, forceEnabled ->
Pair(state, forceEnabled)
}
.collect { (state, hideDisconnectAction) ->
val ableToStartVPN = state > Ipn.State.NeedsMachineAuth
// If VPN is stopped, show a disconnected notification. If it is running as a
// foreground
// service, IPNService will show a connected notification.
if (state == Ipn.State.Stopped) {
notifyStatus(vpnRunning = false, hideDisconnectAction = hideDisconnectAction.value)
}
val vpnRunning = state == Ipn.State.Starting || state == Ipn.State.Running
updateConnStatus(ableToStartVPN)
QuickToggleService.setVPNRunning(vpnRunning)
// Update notification status when VPN is running
if (vpnRunning) {
notifyStatus(vpnRunning = true, hideDisconnectAction = hideDisconnectAction.value)
}
}
}
}
applicationScope.launch {
val hideDisconnectAction = MDMSettings.forceEnabled.flow.first()
}
}
private fun initViewModels() {
vpnViewModel = ViewModelProvider(this, VpnViewModelFactory(this)).get(VpnViewModel::class.java)
}
fun setWantRunning(wantRunning: Boolean, onSuccess: (() -> Unit)? = null) {
val callback: (Result<Ipn.Prefs>) -> Unit = { result ->
result.fold(
onSuccess = { onSuccess?.invoke() },
onFailure = { error ->
TSLog.d("TAG", "Set want running: failed to update preferences: ${error.message}")
})
}
Client(applicationScope)
.editPrefs(Ipn.MaskedPrefs().apply { WantRunning = wantRunning }, callback)
}
// 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)
override fun encryptToPref(prefKey: String?, plaintext: String?) {
getEncryptedPrefs().edit().putString(prefKey, plaintext).commit()
}
// decryptFromPref decrypts a encrypted preference using the Jetpack Security
// library and returns the plaintext.
@Throws(IOException::class, GeneralSecurityException::class)
override fun decryptFromPref(prefKey: String?): String? {
return getEncryptedPrefs().getString(prefKey, null)
}
@Throws(IOException::class, GeneralSecurityException::class)
fun getEncryptedPrefs(): SharedPreferences {
val key = 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)
}
/*
* 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()
TSLog.d("App", "Set Tile Ready: $ableToStartVPN")
}
override fun getModelName(): String {
val manu = Build.MANUFACTURER
var model = Build.MODEL
// Strip manufacturer from model.
val idx = model.lowercase(Locale.getDefault()).indexOf(manu.lowercase(Locale.getDefault()))
if (idx != -1) {
model = model.substring(idx + manu.length).trim()
}
return "$manu $model"
}
override fun getOSVersion(): String = Build.VERSION.RELEASE
override fun isChromeOS(): Boolean {
return packageManager.hasSystemFeature("android.hardware.type.pc")
}
override fun getInterfacesAsString(): String {
val interfaces: ArrayList<NetworkInterface> =
java.util.Collections.list(NetworkInterface.getNetworkInterfaces())
val sb = StringBuilder()
for (nif in interfaces) {
try {
sb.append(
String.format(
Locale.ROOT,
"%s %d %d %b %b %b %b %b |",
nif.name,
nif.index,
nif.mtu,
nif.isUp,
nif.supportsMulticast(),
nif.isLoopback,
nif.isPointToPoint,
nif.supportsMulticast()))
for (ia in nif.interfaceAddresses) {
val parts = ia.toString().split("/", limit = 0)
if (parts.size > 1) {
sb.append(String.format(Locale.ROOT, "%s/%d ", parts[1], ia.networkPrefixLength))
}
}
} catch (e: Exception) {
continue
}
sb.append("\n")
}
return sb.toString()
}
private fun prepareDownloadsFolder(): File {
var downloads = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS)
try {
if (!downloads.exists()) {
downloads.mkdirs()
}
} catch (e: Exception) {
TSLog.e(TAG, "Failed to create downloads folder: $e")
downloads = File(this.filesDir, "Taildrop")
try {
if (!downloads.exists()) {
downloads.mkdirs()
}
} catch (e: Exception) {
TSLog.e(TAG, "Failed to create Taildrop folder: $e")
downloads = File("")
}
}
return downloads
}
@Throws(
IOException::class, GeneralSecurityException::class, MDMSettings.NoSuchKeyException::class)
override fun getSyspolicyBooleanValue(key: String): Boolean {
return getSyspolicyStringValue(key) == "true"
}
@Throws(
IOException::class, GeneralSecurityException::class, MDMSettings.NoSuchKeyException::class)
override fun getSyspolicyStringValue(key: String): String {
val setting = MDMSettings.allSettingsByKey[key]?.flow?.value
if (setting?.isSet != true) {
throw MDMSettings.NoSuchKeyException()
}
return setting.value?.toString() ?: ""
}
@Throws(
IOException::class, GeneralSecurityException::class, MDMSettings.NoSuchKeyException::class)
override fun getSyspolicyStringArrayJSONValue(key: String): String {
val setting = MDMSettings.allSettingsByKey[key]?.flow?.value
if (setting?.isSet != true) {
throw MDMSettings.NoSuchKeyException()
}
try {
val list = setting.value as? List<*>
return Json.encodeToString(list)
} catch (e: Exception) {
TSLog.d("MDM", "$key value cannot be serialized to JSON. Throwing NoSuchKeyException.")
throw MDMSettings.NoSuchKeyException()
}
}
fun notifyPolicyChanged() {
app.notifyPolicyChanged()
}
}
/**
* 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 TAG = "UninitializedApp"
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"
private const val DISALLOWED_APPS_KEY = "disallowedApps"
// File for shared preferences that are not encrypted.
private const val UNENCRYPTED_PREFERENCES = "unencrypted"
private lateinit var appInstance: UninitializedApp
lateinit var notificationManager: NotificationManagerCompat
lateinit var vpnViewModel: VpnViewModel
@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 }
// FLAG_UPDATE_CURRENT ensures that if the intent is already pending, the existing intent will
// be updated rather than creating multiple redundant instances.
val pendingIntent =
PendingIntent.getService(
this,
0,
intent,
PendingIntent.FLAG_UPDATE_CURRENT or
PendingIntent.FLAG_IMMUTABLE // FLAG_IMMUTABLE for Android 12+
)
try {
pendingIntent.send()
} catch (foregroundServiceStartException: IllegalStateException) {
TSLog.e(
TAG,
"startVPN hit ForegroundServiceStartNotAllowedException: $foregroundServiceStartException")
} catch (securityException: SecurityException) {
TSLog.e(TAG, "startVPN hit SecurityException: $securityException")
} catch (e: Exception) {
TSLog.e(TAG, "startVPN hit exception: $e")
}
}
fun stopVPN() {
val intent = Intent(this, IPNService::class.java).apply { action = IPNService.ACTION_STOP_VPN }
try {
startService(intent)
} catch (illegalStateException: IllegalStateException) {
TSLog.e(TAG, "stopVPN hit IllegalStateException in startService(): $illegalStateException")
} catch (e: Exception) {
TSLog.e(TAG, "stopVPN hit exception in startService(): $e")
}
}
fun restartVPN() {
// Register a receiver to listen for the completion of stopVPN
TSLog.d("KARI", "hi")
val stopReceiver =
object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
// Ensure stop intent is complete
if (intent?.action == IPNService.ACTION_STOP_VPN) {
// Unregister receiver after receiving the broadcast
context?.unregisterReceiver(this)
// Now start the VPN
startVPN()
}
}
}
// Register the receiver before stopping VPN
val intentFilter = IntentFilter(IPNService.ACTION_STOP_VPN)
this.registerReceiver(stopReceiver, intentFilter)
stopVPN()
}
fun createNotificationChannel(id: String, name: String, description: String, importance: Int) {
val channel = NotificationChannel(id, name, importance)
channel.description = description
notificationManager = NotificationManagerCompat.from(this)
notificationManager.createNotificationChannel(channel)
}
fun notifyStatus(vpnRunning: Boolean, hideDisconnectAction: Boolean) {
notifyStatus(buildStatusNotification(vpnRunning, hideDisconnectAction))
}
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, hideDisconnectAction: Boolean): Notification {
val message = getString(if (vpnRunning) R.string.connected else R.string.not_connected)
val icon = if (vpnRunning) R.drawable.ic_notification else R.drawable.ic_notification_disabled
val action =
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
}
val pendingIntent: PendingIntent =
PendingIntent.getActivity(
this, 1, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
val builder =
NotificationCompat.Builder(this, STATUS_CHANNEL_ID)
.setSmallIcon(icon)
.setContentTitle(getString(R.string.app_name))
.setContentText(message)
.setAutoCancel(!vpnRunning)
.setOnlyAlertOnce(!vpnRunning)
.setOngoing(vpnRunning)
.setSilent(true)
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
.setContentIntent(pendingIntent)
if (!vpnRunning || !hideDisconnectAction) {
builder.addAction(
NotificationCompat.Action.Builder(0, actionLabel, pendingButtonIntent).build())
}
return builder.build()
}
fun addUserDisallowedPackageName(packageName: String) {
if (packageName.isEmpty()) {
TSLog.e(TAG, "addUserDisallowedPackageName called with empty packageName")
return
}
getUnencryptedPrefs()
.edit()
.putStringSet(
DISALLOWED_APPS_KEY, disallowedPackageNames().toMutableSet().union(setOf(packageName)))
.apply()
this.restartVPN()
}
fun removeUserDisallowedPackageName(packageName: String) {
if (packageName.isEmpty()) {
TSLog.e(TAG, "removeUserDisallowedPackageName called with empty packageName")
return
}
getUnencryptedPrefs()
.edit()
.putStringSet(
DISALLOWED_APPS_KEY,
disallowedPackageNames().toMutableSet().subtract(setOf(packageName)))
.apply()
this.restartVPN()
}
fun disallowedPackageNames(): List<String> {
val mdmDisallowed =
MDMSettings.excludedPackages.flow.value.value?.split(",")?.map { it.trim() } ?: emptyList()
if (mdmDisallowed.isNotEmpty()) {
TSLog.d(TAG, "Excluded application packages were set via MDM: $mdmDisallowed")
return builtInDisallowedPackageNames + mdmDisallowed
}
val userDisallowed =
getUnencryptedPrefs().getStringSet(DISALLOWED_APPS_KEY, emptySet())?.toList() ?: emptyList()
return builtInDisallowedPackageNames + userDisallowed
}
fun getAppScopedViewModel(): VpnViewModel {
return vpnViewModel
}
val builtInDisallowedPackageNames: List<String> =
listOf(
// RCS/Jibe https://github.com/tailscale/tailscale/issues/2322
"com.google.android.apps.messaging",
// Android Auto https://github.com/tailscale/tailscale/issues/3828
"com.google.android.projection.gearhead",
// GoPro https://github.com/tailscale/tailscale/issues/2554
"com.gopro.smarty",
// Sonos https://github.com/tailscale/tailscale/issues/2548
"com.sonos.acr",
"com.sonos.acr2",
// Google Chromecast https://github.com/tailscale/tailscale/issues/3636
"com.google.android.apps.chromecast.app",
// Voicemail https://github.com/tailscale/tailscale/issues/13199
"com.samsung.attvvm",
"com.att.mobile.android.vvm",
"com.tmobile.vvm.application",
"com.metropcs.service.vvm",
"com.mizmowireless.vvm",
"com.vna.service.vvm",
"com.dish.vvm",
"com.comcast.modesto.vvm.client",
)
}

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

@ -1,8 +1,26 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
// Copyright (c) 2021 Tailscale Inc & AUTHORS All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package com.tailscale.ipn;
import android.content.Context;
import android.net.ConnectivityManager;
import android.net.DhcpInfo;
import android.net.LinkProperties;
import android.net.Network;
import android.net.NetworkCapabilities;
import android.net.NetworkInfo;
import android.net.wifi.WifiManager;
import java.lang.reflect.Method;
import java.net.InetAddress;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
// Tailscale DNS Config retrieval
//
// Tailscale's DNS support can either override the local DNS servers with a set of servers
@ -14,9 +32,65 @@ package com.tailscale.ipn;
// 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.
//
// --------------------- Android 7 and later -----------------------------------------
//
// ## getDnsConfigFromLinkProperties
// Android provides a getAllNetworks interface in the ConnectivityManager. We walk through
// each interface to pick the most appropriate one.
// - If there is an Ethernet interface active we use that.
// - If Wi-Fi is active we use that.
// - If LTE is active we use that.
// - We never use a VPN's DNS servers. That VPN is likely us. Even if not us, Android
// only allows one VPN at a time so a different VPN's DNS servers won't be available
// once Tailscale comes up.
//
// getAllNetworks() is used as the sole mechanism for retrieving the DNS config with
// Android 7 and later.
//
// --------------------- Releases older than Android 7 -------------------------------
//
// We support Tailscale back to Android 5. Android versions 5 and 6 supply a getAllNetworks()
// implementation but it always returns an empty list.
//
// ## getDnsConfigFromLinkProperties with getActiveNetwork
// ConnectivityManager also supports a getActiveNetwork() routine, which Android 5 and 6 do
// return a value for. If Tailscale isn't up yet and we can get the Wi-Fi/LTE/etc DNS
// config using getActiveNetwork(), we use that.
//
// Once Tailscale is up, getActiveNetwork() returns tailscale0 with DNS server 100.100.100.100
// and that isn't useful. So we try two other mechanisms:
//
// ## getDnsServersFromSystemProperties
// Android versions prior to 8 let us retrieve the actual system DNS servers from properties.
// Later Android versions removed the properties and only return an empty string.
//
// We check the net.dns1 - net.dns4 DNS servers. If Tailscale is up the DNS server will be
// 100.100.100.100, which isn't useful, but if we get something different we'll use that.
//
// getDnsServersFromSystemProperties can only retrieve the IPv4 or IPv6 addresses of the
// configured DNS servers. We also want to know the DNS Search Domains configured, but
// we have no way to retrieve this using these interfaces. We return an empty list of
// search domains. Sorry.
//
// ## getDnsServersFromNetworkInfo
// ConnectivityManager supports an older API called getActiveNetworkInfo to return the
// active network interface. It doesn't handle VPNs, so the interface will always be Wi-Fi
// or Cellular even if Tailscale is up.
//
// For Wi-Fi interfaces we retrieve the DHCP response from the WifiManager. For Cellular
// interfaces we check for properties populated by most of the radio drivers.
//
// getDnsServersFromNetworkInfo does not have a way to retrieve the DNS Search Domains,
// so we return an empty list. Additionally, these interfaces are so old that they only
// support IPv4. We can't retrieve IPv6 DNS server addresses this way.
public class DnsConfig {
private String dnsConfigs;
private Context ctx;
public DnsConfig(Context ctx) {
this.ctx = ctx;
}
// getDnsConfigAsString returns the current DNS configuration as a multiline string:
// line[0] DNS server addresses separated by spaces
@ -28,27 +102,260 @@ public class DnsConfig {
//
// an empty string means the current DNS configuration could not be retrieved.
String getDnsConfigAsString() {
String dnsConfig = getDnsConfigs();
if (dnsConfig != null) {
return getDnsConfigs().trim();
String s = getDnsConfigFromLinkProperties();
if (!s.trim().isEmpty()) {
return s;
}
if (android.os.Build.VERSION.SDK_INT >= 23) {
// If ConnectivityManager.getAllNetworks() works, it is the
// authoritative mechanism and we rely on it. The other methods
// which follow involve more compromises.
return "";
}
private String getDnsConfigs() {
synchronized (this) {
return this.dnsConfigs;
s = getDnsServersFromSystemProperties();
if (!s.trim().isEmpty()) {
return s;
}
return getDnsServersFromNetworkInfo();
}
boolean updateDNSFromNetwork(String dnsConfigs) {
synchronized (this) {
if (!dnsConfigs.equals(this.dnsConfigs)) {
this.dnsConfigs = dnsConfigs;
return true;
} else {
return false;
// getDnsConfigFromLinkProperties finds the DNS servers for each Network interface
// returned by ConnectivityManager getAllNetworks().LinkProperties, and return the
// one that (heuristically) would be the primary DNS servers.
//
// on a Nexus 4 with Android 5.1 on wifi: 2602:248:7b4a:ff60::1 10.1.10.1
// on a Nexus 7 with Android 6.0 on wifi: 2602:248:7b4a:ff60::1 10.1.10.1
// on a Pixel 3a with Android 12.0 on wifi: 2602:248:7b4a:ff60::1 10.1.10.1\nlocaldomain
// on a Pixel 3a with Android 12.0 on LTE: fd00:976a::9 fd00:976a::10
//
// One odd behavior noted on Pixel3a with Android 12:
// With Wi-Fi already connected, starting Tailscale returned DNS servers 2602:248:7b4a:ff60::1 10.1.10.1
// Turning off Wi-Fi and connecting LTE returned DNS servers fd00:976a::9 fd00:976a::10.
// Turning Wi-Fi back on return DNS servers: 10.1.10.1. The IPv6 DNS server is gone.
// This appears to be the ConnectivityManager behavior, not something we are doing.
//
// This implementation can work through Android 12 (SDK 30). In SDK 31 the
// getAllNetworks() method is deprecated and we'll need to implement a
// android.net.ConnectivityManager.NetworkCallback instead to monitor
// link changes and track which DNS server to use.
String getDnsConfigFromLinkProperties() {
ConnectivityManager cMgr = (ConnectivityManager) ctx.getSystemService(Context.CONNECTIVITY_SERVICE);
if (cMgr == null) {
return "";
}
Network[] networks = cMgr.getAllNetworks();
if (networks == null) {
// Android 6 and before often returns an empty list, but we
// can try again with just the active network.
//
// Once Tailscale is connected, the active network will be Tailscale
// which will have 100.100.100.100 for its DNS server. We reject
// TYPE_VPN in getPreferabilityForNetwork, so it won't be returned.
Network active = cMgr.getActiveNetwork();
if (active == null) {
return "";
}
networks = new Network[]{active};
}
// getPreferabilityForNetwork returns an index into dnsConfigs from 0-3.
String[] dnsConfigs = new String[]{"", "", "", ""};
for (Network network : networks) {
int idx = getPreferabilityForNetwork(cMgr, network);
if ((idx < 0) || (idx > 3)) {
continue;
}
LinkProperties linkProp = cMgr.getLinkProperties(network);
NetworkCapabilities nc = cMgr.getNetworkCapabilities(network);
List<InetAddress> dnsList = linkProp.getDnsServers();
StringBuilder sb = new StringBuilder("");
for (InetAddress ip : dnsList) {
sb.append(ip.getHostAddress() + " ");
}
String d = linkProp.getDomains();
if (d != null) {
sb.append("\n");
sb.append(d);
}
dnsConfigs[idx] = sb.toString();
}
// return the lowest index DNS config which exists. If an Ethernet config
// was found, return it. Otherwise if Wi-fi was found, return it. Etc.
for (String s : dnsConfigs) {
if (!s.trim().isEmpty()) {
return s;
}
}
return "";
}
// getDnsServersFromSystemProperties returns DNS servers found in system properties.
// On Android versions prior to Android 8, we can directly query the DNS
// servers the system is using. More recent Android releases return empty strings.
//
// Once Tailscale is connected these properties will return 100.100.100.100, which we
// suppress.
//
// on a Nexus 4 with Android 5.1 on wifi: 2602:248:7b4a:ff60::1 10.1.10.1
// on a Nexus 7 with Android 6.0 on wifi: 2602:248:7b4a:ff60::1 10.1.10.1
// on a Pixel 3a with Android 12.0 on wifi:
// on a Pixel 3a with Android 12.0 on LTE:
//
// The list of DNS search domains does not appear to be available in system properties.
String getDnsServersFromSystemProperties() {
try {
Class SystemProperties = Class.forName("android.os.SystemProperties");
Method method = SystemProperties.getMethod("get", String.class);
List<String> servers = new ArrayList<String>();
for (String name : new String[]{"net.dns1", "net.dns2", "net.dns3", "net.dns4"}) {
String value = (String) method.invoke(null, name);
if (value != null && !value.isEmpty() &&
!value.equals("100.100.100.100") &&
!servers.contains(value)) {
servers.add(value);
}
}
return String.join(" ", servers);
} catch (Exception e) {
return "";
}
}
public String intToInetString(int hostAddress) {
return String.format(java.util.Locale.ROOT, "%d.%d.%d.%d",
(0xff & hostAddress),
(0xff & (hostAddress >> 8)),
(0xff & (hostAddress >> 16)),
(0xff & (hostAddress >> 24)));
}
// getDnsServersFromNetworkInfo retrieves DNS servers using ConnectivityManager
// getActiveNetworkInfo() plus interface-specific mechanisms to retrieve the DNS servers.
// Only IPv4 DNS servers are supported by this mechanism, neither the WifiManager nor the
// interface-specific dns properties appear to populate IPv6 DNS server addresses.
//
// on a Nexus 4 with Android 5.1 on wifi: 10.1.10.1
// on a Nexus 7 with Android 6.0 on wifi: 10.1.10.1
// on a Pixel-3a with Android 12.0 on wifi: 10.1.10.1
// on a Pixel-3a with Android 12.0 on LTE:
//
// The list of DNS search domains is not available in this way.
String getDnsServersFromNetworkInfo() {
ConnectivityManager cMgr = (ConnectivityManager) ctx.getSystemService(Context.CONNECTIVITY_SERVICE);
if (cMgr == null) {
return "";
}
NetworkInfo info = cMgr.getActiveNetworkInfo();
if (info == null) {
return "";
}
Class SystemProperties;
Method method;
try {
SystemProperties = Class.forName("android.os.SystemProperties");
method = SystemProperties.getMethod("get", String.class);
} catch (Exception e) {
return "";
}
List<String> servers = new ArrayList<String>();
switch(info.getType()) {
case ConnectivityManager.TYPE_WIFI:
case ConnectivityManager.TYPE_WIMAX:
for (String name : new String[]{
"net.wifi0.dns1", "net.wifi0.dns2", "net.wifi0.dns3", "net.wifi0.dns4",
"net.wlan0.dns1", "net.wlan0.dns2", "net.wlan0.dns3", "net.wlan0.dns4",
"net.eth0.dns1", "net.eth0.dns2", "net.eth0.dns3", "net.eth0.dns4",
"dhcp.wlan0.dns1", "dhcp.wlan0.dns2", "dhcp.wlan0.dns3", "dhcp.wlan0.dns4",
"dhcp.tiwlan0.dns1", "dhcp.tiwlan0.dns2", "dhcp.tiwlan0.dns3", "dhcp.tiwlan0.dns4"}) {
try {
String value = (String) method.invoke(null, name);
if (value != null && !value.isEmpty() && !servers.contains(value)) {
servers.add(value);
}
} catch (Exception e) {
continue;
}
}
WifiManager wMgr = (WifiManager) ctx.getSystemService(Context.WIFI_SERVICE);
if (wMgr != null) {
DhcpInfo dhcp = wMgr.getDhcpInfo();
if (dhcp.dns1 != 0) {
String value = intToInetString(dhcp.dns1);
if (value != null && !value.isEmpty() && !servers.contains(value)) {
servers.add(value);
}
}
if (dhcp.dns2 != 0) {
String value = intToInetString(dhcp.dns2);
if (value != null && !value.isEmpty() && !servers.contains(value)) {
servers.add(value);
}
}
}
return String.join(" ", servers);
case ConnectivityManager.TYPE_MOBILE:
case ConnectivityManager.TYPE_MOBILE_HIPRI:
for (String name : new String[]{
"net.rmnet0.dns1", "net.rmnet0.dns2", "net.rmnet0.dns3", "net.rmnet0.dns4",
"net.rmnet1.dns1", "net.rmnet1.dns2", "net.rmnet1.dns3", "net.rmnet1.dns4",
"net.rmnet2.dns1", "net.rmnet2.dns2", "net.rmnet2.dns3", "net.rmnet2.dns4",
"net.rmnet3.dns1", "net.rmnet3.dns2", "net.rmnet3.dns3", "net.rmnet3.dns4",
"net.rmnet4.dns1", "net.rmnet4.dns2", "net.rmnet4.dns3", "net.rmnet4.dns4",
"net.rmnet5.dns1", "net.rmnet5.dns2", "net.rmnet5.dns3", "net.rmnet5.dns4",
"net.rmnet6.dns1", "net.rmnet6.dns2", "net.rmnet6.dns3", "net.rmnet6.dns4",
"net.rmnet7.dns1", "net.rmnet7.dns2", "net.rmnet7.dns3", "net.rmnet7.dns4",
"net.pdp0.dns1", "net.pdp0.dns2", "net.pdp0.dns3", "net.pdp0.dns4",
"net.pdpbr0.dns1", "net.pdpbr0.dns2", "net.pdpbr0.dns3", "net.pdpbr0.dns4"}) {
try {
String value = (String) method.invoke(null, name);
if (value != null && !value.isEmpty() && !servers.contains(value)) {
servers.add(value);
}
} catch (Exception e) {
continue;
}
}
}
return "";
}
// getPreferabilityForNetwork is a utility routine which implements a priority for
// different types of network transport, used in a heuristic to pick DNS servers to use.
int getPreferabilityForNetwork(ConnectivityManager cMgr, Network network) {
NetworkCapabilities nc = cMgr.getNetworkCapabilities(network);
if (nc == null) {
return -1;
}
if (nc.hasTransport(NetworkCapabilities.TRANSPORT_VPN)) {
// tun0 has both VPN and WIFI set, have to check VPN first and return.
return -1;
}
if (nc.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET)) {
return 0;
} else if (nc.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)) {
return 1;
} else if (nc.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR)) {
return 2;
} else {
return 3;
}
}
}

@ -0,0 +1,133 @@
// Copyright (c) 2021 Tailscale Inc & AUTHORS All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
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();
}
}

@ -1,45 +0,0 @@
// 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.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);
// On the relevant action, start the relevant worker, which can stay active for longer than this receiver can.
if (Objects.equals(intent.getAction(), INTENT_CONNECT_VPN)) {
workManager.enqueue(new OneTimeWorkRequest.Builder(StartVPNWorker.class).build());
} 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());
}
}
}

@ -0,0 +1,115 @@
// Copyright (c) 2020 Tailscale Inc & AUTHORS All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
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 org.gioui.GioActivity;
import androidx.core.app.NotificationCompat;
import androidx.core.app.NotificationManagerCompat;
public class IPNService extends VpnService {
public static final String ACTION_CONNECT = "com.tailscale.ipn.CONNECT";
public static final String ACTION_DISCONNECT = "com.tailscale.ipn.DISCONNECT";
@Override public int onStartCommand(Intent intent, int flags, int startId) {
if (intent != null && ACTION_DISCONNECT.equals(intent.getAction())) {
close();
return START_NOT_STICKY;
}
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.acr2");
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 connect();
private native void disconnect();
}

@ -1,181 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn
import android.app.PendingIntent
import android.content.Intent
import android.content.pm.PackageManager
import android.net.VpnService
import android.os.Build
import android.system.OsConstants
import com.tailscale.ipn.mdm.MDMSettings
import com.tailscale.ipn.ui.model.Ipn
import com.tailscale.ipn.ui.notifier.Notifier
import com.tailscale.ipn.util.TSLog
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import libtailscale.Libtailscale
import java.util.UUID
open class IPNService : VpnService(), libtailscale.IPNService {
private val TAG = "IPNService"
private val randomID: String = UUID.randomUUID().toString()
private lateinit var app: App
val scope = CoroutineScope(Dispatchers.IO)
override fun id(): String {
return randomID
}
override fun updateVpnStatus(status: Boolean) {
app.getAppScopedViewModel().setVpnActive(status)
}
override fun onCreate() {
super.onCreate()
// grab app to make sure it initializes
app = App.get()
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int =
when (intent?.action) {
ACTION_STOP_VPN -> {
app.setWantRunning(false)
close()
START_NOT_STICKY
}
ACTION_START_VPN -> {
scope.launch {
// Collect the first value of hideDisconnectAction asynchronously.
val hideDisconnectAction = MDMSettings.forceEnabled.flow.first()
showForegroundNotification(hideDisconnectAction.value)
}
app.setWantRunning(true)
Libtailscale.requestVPN(this)
START_STICKY
}
"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.
scope.launch {
// Collect the first value of hideDisconnectAction asynchronously.
val hideDisconnectAction = MDMSettings.forceEnabled.flow.first()
app.notifyStatus(true, hideDisconnectAction.value)
}
app.setWantRunning(true)
Libtailscale.requestVPN(this)
START_STICKY
}
else -> {
// This means that we were restarted after the service was killed
// (potentially due to OOM).
if (UninitializedApp.get().isAbleToStartVPN()) {
scope.launch {
// Collect the first value of hideDisconnectAction asynchronously.
val hideDisconnectAction = MDMSettings.forceEnabled.flow.first()
showForegroundNotification(hideDisconnectAction.value)
}
App.get()
Libtailscale.requestVPN(this)
START_STICKY
} else {
START_NOT_STICKY
}
}
}
override fun close() {
app.setWantRunning(false) {}
Notifier.setState(Ipn.State.Stopping)
disconnectVPN()
Libtailscale.serviceDisconnect(this)
}
override fun disconnectVPN() {
stopSelf()
}
override fun onDestroy() {
close()
updateVpnStatus(false)
super.onDestroy()
}
override fun onRevoke() {
close()
updateVpnStatus(false)
super.onRevoke()
}
private fun setVpnPrepared(isPrepared: Boolean) {
app.getAppScopedViewModel().setVpnPrepared(isPrepared)
}
private fun showForegroundNotification(hideDisconnectAction: Boolean) {
try {
startForeground(
UninitializedApp.STATUS_NOTIFICATION_ID,
UninitializedApp.get().buildStatusNotification(true, hideDisconnectAction))
} catch (e: Exception) {
TSLog.e(TAG, "Failed to start foreground service: $e")
}
}
private fun configIntent(): PendingIntent {
return PendingIntent.getActivity(
this,
0,
Intent(this, MainActivity::class.java),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
}
private fun disallowApp(b: Builder, name: String) {
try {
b.addDisallowedApplication(name)
} catch (e: PackageManager.NameNotFoundException) {
TSLog.d(TAG, "Failed to add disallowed application: $e")
}
}
override fun newBuilder(): VPNServiceBuilder {
val b: Builder =
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.
}
b.setUnderlyingNetworks(null) // Use all available networks.
val includedPackages: List<String> =
MDMSettings.includedPackages.flow.value.value?.split(",")?.map { it.trim() } ?: emptyList()
if (includedPackages.isNotEmpty()) {
// If an admin defined a list of packages that are exclusively allowed to be used via
// Tailscale,
// then only allow those apps.
for (packageName in includedPackages) {
TSLog.d(TAG, "Including app: $packageName")
b.addAllowedApplication(packageName)
}
} else {
// Otherwise, prevent certain apps from getting their traffic + DNS routed via Tailscale:
// - any app that the user manually disallowed in the GUI
// - any app that we disallowed via hard-coding
for (disallowedPackageName in UninitializedApp.get().disallowedPackageNames()) {
TSLog.d(TAG, "Disallowing app: $disallowedPackageName")
disallowApp(b, disallowedPackageName)
}
}
return VPNServiceBuilder(b)
}
companion object {
const val ACTION_START_VPN = "com.tailscale.ipn.START_VPN"
const val ACTION_STOP_VPN = "com.tailscale.ipn.STOP_VPN"
}
}

@ -1,426 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn
import android.annotation.SuppressLint
import android.app.Activity
import android.app.AlertDialog
import android.content.Context
import android.content.Intent
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.ConnectivityManager
import android.net.NetworkCapabilities
import android.os.Bundle
import android.provider.Settings
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContract
import androidx.browser.customtabs.CustomTabsIntent
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.runtime.collectAsState
import androidx.compose.ui.Modifier
import androidx.core.net.toUri
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.lifecycleScope
import androidx.navigation.NavHostController
import androidx.navigation.NavType
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument
import com.tailscale.ipn.mdm.MDMSettings
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.AndroidTVUtil
import com.tailscale.ipn.ui.util.set
import com.tailscale.ipn.ui.util.universalFit
import com.tailscale.ipn.ui.view.AboutView
import com.tailscale.ipn.ui.view.BugReportView
import com.tailscale.ipn.ui.view.DNSSettingsView
import com.tailscale.ipn.ui.view.ExitNodePicker
import com.tailscale.ipn.ui.view.HealthView
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
import com.tailscale.ipn.ui.view.ManagedByView
import com.tailscale.ipn.ui.view.MullvadExitNodePicker
import com.tailscale.ipn.ui.view.MullvadExitNodePickerList
import com.tailscale.ipn.ui.view.MullvadInfoView
import com.tailscale.ipn.ui.view.PeerDetails
import com.tailscale.ipn.ui.view.PermissionsView
import com.tailscale.ipn.ui.view.RunExitNodeView
import com.tailscale.ipn.ui.view.SettingsView
import com.tailscale.ipn.ui.view.SplitTunnelAppPickerView
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.MainViewModelFactory
import com.tailscale.ipn.ui.viewModel.PingViewModel
import com.tailscale.ipn.ui.viewModel.SettingsNav
import com.tailscale.ipn.ui.viewModel.VpnViewModel
import com.tailscale.ipn.util.TSLog
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
class MainActivity : ComponentActivity() {
private lateinit var navController: NavHostController
private lateinit var vpnPermissionLauncher: ActivityResultLauncher<Intent>
private val viewModel: MainViewModel by lazy {
val app = App.get()
vpnViewModel = app.getAppScopedViewModel()
ViewModelProvider(this, MainViewModelFactory(vpnViewModel)).get(MainViewModel::class.java)
}
private lateinit var vpnViewModel: VpnViewModel
companion object {
private const val TAG = "Main Activity"
private const val START_AT_ROOT = "startAtRoot"
}
private fun Context.isLandscapeCapable(): Boolean {
return (resources.configuration.screenLayout and SCREENLAYOUT_SIZE_MASK) >=
SCREENLAYOUT_SIZE_LARGE
}
// The loginQRCode is used to track whether or not we should be rendering a QR code
// to the user. This is used only on TV platforms with no browser in lieu of
// simply opening the URL. This should be consumed once it has been handled.
private val loginQRCode: StateFlow<String?> = MutableStateFlow(null)
@SuppressLint("SourceLockedOrientationActivity")
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// grab app to make sure it initializes
App.get()
vpnViewModel = ViewModelProvider(App.get()).get(VpnViewModel::class.java)
// (jonathan) TODO: Force the app to be portrait on small screens until we have
// proper landscape layout support
if (!isLandscapeCapable()) {
requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
}
installSplashScreen()
vpnPermissionLauncher =
registerForActivityResult(VpnPermissionContract()) { granted ->
if (granted) {
TSLog.d("VpnPermission", "VPN permission granted")
vpnViewModel.setVpnPrepared(true)
App.get().startVPN()
} else {
if (isAnotherVpnActive(this)) {
TSLog.d("VpnPermission", "Another VPN is likely active")
showOtherVPNConflictDialog()
} else {
TSLog.d("VpnPermission", "Permission was denied by the user")
vpnViewModel.setVpnPrepared(false)
}
}
}
viewModel.setVpnPermissionLauncher(vpnPermissionLauncher)
setContent {
AppTheme {
navController = rememberNavController()
Surface(color = MaterialTheme.colorScheme.inverseSurface) { // Background for the letterbox
Surface(modifier = Modifier.universalFit()) { // Letterbox for AndroidTV
NavHost(
navController = navController,
startDestination = "main",
enterTransition = {
slideInHorizontally(animationSpec = tween(150), initialOffsetX = { it })
},
exitTransition = {
slideOutHorizontally(animationSpec = tween(150), targetOffsetX = { -it })
},
popEnterTransition = {
slideInHorizontally(animationSpec = tween(150), initialOffsetX = { -it })
},
popExitTransition = {
slideOutHorizontally(animationSpec = tween(150), targetOffsetX = { it })
}) {
fun backTo(route: String): () -> Unit = {
navController.popBackStack(route = route, inclusive = false)
}
val mainViewNav =
MainViewNavigation(
onNavigateToSettings = { navController.navigate("settings") },
onNavigateToPeerDetails = {
navController.navigate("peerDetails/${it.StableID}")
},
onNavigateToExitNodes = { navController.navigate("exitNodes") },
onNavigateToHealth = { navController.navigate("health") })
val settingsNav =
SettingsNav(
onNavigateToBugReport = { navController.navigate("bugReport") },
onNavigateToAbout = { navController.navigate("about") },
onNavigateToDNSSettings = { navController.navigate("dnsSettings") },
onNavigateToSplitTunneling = { navController.navigate("splitTunneling") },
onNavigateToTailnetLock = { navController.navigate("tailnetLock") },
onNavigateToMDMSettings = { navController.navigate("mdmSettings") },
onNavigateToManagedBy = { navController.navigate("managedBy") },
onNavigateToUserSwitcher = { navController.navigate("userSwitcher") },
onNavigateToPermissions = { navController.navigate("permissions") },
onBackToSettings = backTo("settings"),
onNavigateBackHome = backTo("main"))
val exitNodePickerNav =
ExitNodePickerNav(
onNavigateBackHome = {
navController.popBackStack(route = "main", inclusive = false)
},
onNavigateBackToExitNodes = backTo("exitNodes"),
onNavigateToMullvad = { navController.navigate("mullvad") },
onNavigateToMullvadInfo = { navController.navigate("mullvad_info") },
onNavigateBackToMullvad = backTo("mullvad"),
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)
}
composable("settings") { SettingsView(settingsNav) }
composable("exitNodes") { ExitNodePicker(exitNodePickerNav) }
composable("health") { HealthView(backTo("main")) }
composable("mullvad") { MullvadExitNodePickerList(exitNodePickerNav) }
composable("mullvad_info") { MullvadInfoView(exitNodePickerNav) }
composable(
"mullvad/{countryCode}",
arguments =
listOf(navArgument("countryCode") { type = NavType.StringType })) {
MullvadExitNodePicker(
it.arguments!!.getString("countryCode")!!, exitNodePickerNav)
}
composable("runExitNode") { RunExitNodeView(exitNodePickerNav) }
composable(
"peerDetails/{nodeId}",
arguments = listOf(navArgument("nodeId") { type = NavType.StringType })) {
PeerDetails(
backTo("main"),
it.arguments?.getString("nodeId") ?: "",
PingViewModel())
}
composable("bugReport") { BugReportView(backTo("settings")) }
composable("dnsSettings") { DNSSettingsView(backTo("settings")) }
composable("splitTunneling") { SplitTunnelAppPickerView(backTo("settings")) }
composable("tailnetLock") { TailnetLockSetupView(backTo("settings")) }
composable("about") { AboutView(backTo("settings")) }
composable("mdmSettings") { MDMSettingsDebugView(backTo("settings")) }
composable("managedBy") { ManagedByView(backTo("settings")) }
composable("userSwitcher") { UserSwitcherView(userSwitcherNav) }
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
if (!introScreenViewed()) {
navController.navigate("intro")
setIntroScreenViewed(true)
}
}
}
// Login actions are app wide. If we are told about a browse-to-url, we should render it
// over whatever screen we happen to be on.
loginQRCode.collectAsState().value?.let {
LoginQRView(onDismiss = { loginQRCode.set(null) })
}
}
}
}
init {
// Watch the model's browseToURL and launch the browser when it changes or
// pop up a QR code to scan
lifecycleScope.launch {
Notifier.browseToURL.collect { url ->
url?.let {
when (useQRCodeLogin()) {
false -> Dispatchers.Main.run { login(it) }
true -> loginQRCode.set(it)
}
}
}
}
// Once we see a loginFinished event, clear the QR code which will dismiss the QR dialog.
lifecycleScope.launch { Notifier.loginFinished.collect { _ -> loginQRCode.set(null) } }
}
private fun showOtherVPNConflictDialog() {
AlertDialog.Builder(this)
.setTitle(R.string.vpn_permission_denied)
.setMessage(R.string.multiple_vpn_explainer)
.setPositiveButton(R.string.go_to_settings) { _, _ ->
// Intent to open the VPN settings
val intent = Intent(Settings.ACTION_VPN_SETTINGS)
startActivity(intent)
}
.setNegativeButton(R.string.cancel, null)
.show()
}
fun isAnotherVpnActive(context: Context): Boolean {
val connectivityManager =
context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
val activeNetwork = connectivityManager.activeNetwork
if (activeNetwork != null) {
val networkCapabilities = connectivityManager.getNetworkCapabilities(activeNetwork)
if (networkCapabilities != null &&
networkCapabilities.hasTransport(NetworkCapabilities.TRANSPORT_VPN)) {
return true
}
}
return false
}
// Returns true if we should render a QR code instead of launching a browser
// for login requests
private fun useQRCodeLogin(): Boolean {
return AndroidTVUtil.isAndroidTV()
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
if (intent.getBooleanExtra(START_AT_ROOT, false)) {
if (this::navController.isInitialized) {
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 {
try {
Notifier.state.collect { state ->
if (state > Ipn.State.NeedsMachineAuth) {
val intent =
Intent(applicationContext, MainActivity::class.java).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP)
action = Intent.ACTION_MAIN
addCategory(Intent.CATEGORY_LAUNCHER)
putExtra(START_AT_ROOT, true)
}
startActivity(intent)
// Cancel coroutine once we've logged in
this@launch.cancel()
}
}
} catch (e: Exception) {
TSLog.e(TAG, "Login: failed to start MainActivity: $e")
}
}
val url = urlString.toUri()
try {
val customTabsIntent = CustomTabsIntent.Builder().build()
customTabsIntent.launchUrl(this, url)
} catch (e: Exception) {
// Fallback to a regular browser if CustomTabsIntent fails
try {
val fallbackIntent = Intent(Intent.ACTION_VIEW, url)
startActivity(fallbackIntent)
} catch (e: Exception) {
TSLog.e(TAG, "Login: failed to open browser: $e")
}
}
}
override fun onResume() {
super.onResume()
val restrictionsManager =
this.getSystemService(Context.RESTRICTIONS_SERVICE) as RestrictionsManager
lifecycleScope.launch(Dispatchers.IO) { MDMSettings.update(App.get(), restrictionsManager) }
}
override fun onStart() {
super.onStart()
}
override fun onStop() {
super.onStop()
val restrictionsManager =
this.getSystemService(Context.RESTRICTIONS_SERVICE) as RestrictionsManager
lifecycleScope.launch(Dispatchers.IO) { MDMSettings.update(App.get(), restrictionsManager) }
}
private fun openApplicationSettings() {
val intent =
Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS).apply {
putExtra(Settings.EXTRA_APP_PACKAGE, packageName)
}
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
startActivity(intent)
}
private fun introScreenViewed(): Boolean {
return getSharedPreferences("introScreen", Context.MODE_PRIVATE).getBoolean("seen", false)
}
private fun setIntroScreenViewed(seen: Boolean) {
getSharedPreferences("introScreen", Context.MODE_PRIVATE)
.edit()
.putBoolean("seen", seen)
.apply()
}
}
class VpnPermissionContract : ActivityResultContract<Intent, Boolean>() {
override fun createIntent(context: Context, input: Intent): Intent {
return input
}
override fun parseResult(resultCode: Int, intent: Intent?): Boolean {
return resultCode == Activity.RESULT_OK
}
}

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

@ -0,0 +1,17 @@
// Copyright (c) 2020 Tailscale Inc & AUTHORS All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
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);
}

@ -1,99 +1,83 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
// Copyright (c) 2020 Tailscale Inc & AUTHORS All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package com.tailscale.ipn;
import android.app.PendingIntent;
import android.content.Context;
import android.content.ComponentName;
import android.content.Intent;
import android.os.Build;
import android.service.quicksettings.Tile;
import android.service.quicksettings.TileService;
import java.util.concurrent.atomic.AtomicReference;
import java.util.concurrent.atomic.AtomicBoolean;
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;
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;
public static void updateTile() {
var app = UninitializedApp.get();
Tile t;
boolean act;
synchronized (lock) {
t = currentTile;
act = isRunning && app.isAbleToStartVPN();
}
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) {
synchronized (lock) {
isRunning = running;
}
updateTile();
}
@Override
public void onStartListening() {
@Override public void onStartListening() {
synchronized (lock) {
currentTile = getQsTile();
}
updateTile();
}
@Override
public void onStopListening() {
@Override public void onStopListening() {
synchronized (lock) {
currentTile = null;
}
}
@SuppressWarnings("deprecation")
@Override
public void onClick() {
@Override 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 {
// Deprecated, but still required for older versions.
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();
}
private void onTileClick() {
UninitializedApp app = UninitializedApp.get();
boolean needsToStop;
static void setReady(Context ctx, boolean rdy) {
synchronized (lock) {
needsToStop = app.isAbleToStartVPN() && isRunning;
ready = rdy;
}
if (needsToStop) {
app.stopVPN();
} else {
app.startVPN();
updateTile();
}
static void setStatus(Context ctx, boolean act) {
synchronized (lock) {
active = act;
}
updateTile();
}
private static native void onTileClick();
}

@ -1,129 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.provider.OpenableColumns
import android.webkit.MimeTypeMap
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.ui.Modifier
import androidx.lifecycle.lifecycleScope
import com.tailscale.ipn.ui.model.Ipn
import com.tailscale.ipn.ui.theme.AppTheme
import com.tailscale.ipn.ui.util.set
import com.tailscale.ipn.ui.util.universalFit
import com.tailscale.ipn.ui.view.TaildropView
import com.tailscale.ipn.util.TSLog
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlin.random.Random
// ShareActivity is the entry point for Taildrop share intents
class ShareActivity : ComponentActivity() {
private val TAG = ShareActivity::class.simpleName
private val requestedTransfers: StateFlow<List<Ipn.OutgoingFile>> = MutableStateFlow(emptyList())
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
AppTheme {
Surface(color = MaterialTheme.colorScheme.inverseSurface) { // Background for the letterbox
Surface(modifier = Modifier.universalFit()) {
TaildropView(requestedTransfers, (application as App).applicationScope)
}
}
}
}
}
override fun onStart() {
super.onStart()
// Ensure our app instance is initialized
App.get()
lifecycleScope.launch { withContext(Dispatchers.IO) { loadFiles() } }
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
setIntent(intent)
loadFiles()
}
// Loads the files from the intent.
fun loadFiles() {
if (intent == null) {
TSLog.e(TAG, "Share failure - No intent found")
return
}
val act = intent.action
val uris: List<Uri?>? =
when (act) {
Intent.ACTION_SEND -> {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
listOf(intent.getParcelableExtra(Intent.EXTRA_STREAM, Uri::class.java))
} else {
@Suppress("DEPRECATION")
listOf(intent.getParcelableExtra(Intent.EXTRA_STREAM) as? Uri)
}
}
Intent.ACTION_SEND_MULTIPLE -> {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
intent.getParcelableArrayListExtra<Uri?>(Intent.EXTRA_STREAM, Uri::class.java)
} else {
@Suppress("DEPRECATION") intent.getParcelableArrayListExtra<Uri?>(Intent.EXTRA_STREAM)
}
}
else -> {
TSLog.e(TAG, "No extras found in intent - nothing to share")
null
}
}
val pendingFiles: List<Ipn.OutgoingFile> =
uris?.filterNotNull()?.mapNotNull {
contentResolver?.query(it, null, null, null, null)?.let { c ->
val nameCol = c.getColumnIndex(OpenableColumns.DISPLAY_NAME)
val sizeCol = c.getColumnIndex(OpenableColumns.SIZE)
c.moveToFirst()
val name: String =
c.getString(nameCol)
?: run {
// For some reason, some content resolvers don't return a name.
// Try to build a name from a random integer plus file extension
// (if type can be determined), else just a random integer.
val rand = Random.nextLong()
contentResolver.getType(it)?.let { mimeType ->
MimeTypeMap.getSingleton().getExtensionFromMimeType(mimeType)?.let {
extension ->
"$rand.$extension"
} ?: "$rand"
} ?: "$rand"
}
val size = c.getLong(sizeCol)
c.close()
val file = Ipn.OutgoingFile(Name = name, DeclaredSize = size)
file.uri = it
file
}
} ?: emptyList()
if (pendingFiles.isEmpty()) {
TSLog.e(TAG, "Share failure - no files extracted from intent")
}
requestedTransfers.set(pendingFiles)
}
}

@ -1,65 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn;
import android.app.Notification;
import android.app.NotificationManager;
import android.app.PendingIntent;
import android.content.Context;
import android.content.Intent;
import android.net.VpnService;
import android.os.Build;
import androidx.annotation.NonNull;
import androidx.work.Worker;
import androidx.work.WorkerParameters;
import com.tailscale.ipn.util.TSLog;
/**
* A worker that exists to support IPNReceiver.
*/
public final class StartVPNWorker extends Worker {
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();
}
}
// We aren't ready to start the VPN or don't have permission, open the Tailscale app.
TSLog.e("StartVPNWorker", "Tailscale isn't ready to start the VPN, notify the user.");
// 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 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);
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();
notificationManager.notify(1, notification);
return Result.failure();
}
}

@ -1,29 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
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(
Context appContext,
WorkerParameters workerParams) {
super(appContext, workerParams);
}
@NonNull
@Override
public Result doWork() {
UninitializedApp.get().stopVPN();
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"
}
}

@ -1,48 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
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) {
builder.addAddress(p0, p1)
}
override fun addDNSServer(p0: String) {
builder.addDnsServer(p0)
}
override fun addRoute(p0: String, p1: Int) {
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)
}
override fun establish(): ParcelFileDescriptor? {
return builder.establish()?.let { ParcelFileDescriptor(it) }
}
override fun setMTU(p0: Int) {
builder.setMtu(p0)
}
}
class ParcelFileDescriptor(private val fd: android.os.ParcelFileDescriptor) :
libtailscale.ParcelFileDescriptor {
override fun detach(): Int {
return fd.detachFd()
}
}

@ -1,115 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn.mdm
import android.content.RestrictionsManager
import com.tailscale.ipn.App
import kotlin.reflect.KVisibility
import kotlin.reflect.full.declaredMemberProperties
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. We compare against its exact text
// to determine whether the requested policy setting is not configured and
// an actual syspolicy.ErrNoSuchKey should be returned from syspolicyHandler
// to the backend.
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")
// Allows admins to define a list of packages that won't be routed via Tailscale.
val excludedPackages = StringMDMSetting("ExcludedPackageNames", "Excluded Package Names")
// Allows admins to define a list of packages that will be routed via Tailscale, letting all other
// apps skip the VPN tunnel.
val includedPackages = StringMDMSetting("IncludedPackageNames", "Included Package Names")
// Handled on the backend
val authKey = StringMDMSetting("AuthKey", "Auth Key for login")
val allSettings by lazy {
MDMSettings::class
.declaredMemberProperties
.filter {
it.visibility == KVisibility.PUBLIC &&
it.returnType.jvmErasure.isSubclassOf(MDMSetting::class)
}
.map { it.call(MDMSettings) as MDMSetting<*> }
}
val allSettingsByKey by lazy { allSettings.associateBy { it.key } }
fun update(app: App, restrictionsManager: RestrictionsManager?) {
val bundle = restrictionsManager?.applicationRestrictions
val preferences = lazy { app.getEncryptedPrefs() }
allSettings.forEach { it.setFrom(bundle, preferences) }
app.notifyPolicyChanged()
}
}

@ -1,121 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn.mdm
import android.content.SharedPreferences
import android.os.Bundle
import com.tailscale.ipn.App
import com.tailscale.ipn.ui.util.set
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
data class SettingState<T>(val value: T, val isSet: Boolean)
abstract class MDMSetting<T>(defaultValue: T, val key: String, val localizedTitle: String) {
val defaultValue = defaultValue
val flow = MutableStateFlow(SettingState(defaultValue, false))
fun setFrom(bundle: Bundle?, prefs: Lazy<SharedPreferences>) {
val v: T? = getFrom(bundle, prefs)
flow.set(SettingState(v ?: defaultValue, v != null))
}
fun getFrom(bundle: Bundle?, prefs: Lazy<SharedPreferences>): T? {
return when {
bundle != null -> bundle.takeIf { it.containsKey(key) }?.let { getFromBundle(it) }
else -> prefs.value.takeIf { it.contains(key) }?.let { getFromPrefs(it) }
}
}
protected abstract fun getFromBundle(bundle: Bundle): T
protected abstract fun getFromPrefs(prefs: SharedPreferences): T
}
class BooleanMDMSetting(key: String, localizedTitle: String) :
MDMSetting<Boolean>(false, key, localizedTitle) {
override fun getFromBundle(bundle: Bundle) = bundle.getBoolean(key)
override fun getFromPrefs(prefs: SharedPreferences) = prefs.getBoolean(key, false)
}
class StringMDMSetting(key: String, localizedTitle: String) :
MDMSetting<String?>(null, key, localizedTitle) {
override fun getFromBundle(bundle: Bundle) = bundle.getString(key)
override fun getFromPrefs(prefs: SharedPreferences) = prefs.getString(key, null)
}
class StringArrayListMDMSetting(key: String, localizedTitle: String) :
MDMSetting<List<String>?>(null, key, localizedTitle) {
override fun getFromBundle(bundle: Bundle): List<String>? {
// Try to retrieve the value as a String[] first
val stringArray = bundle.getStringArray(key)
if (stringArray != null) {
return stringArray.toList()
}
// Optionally, handle other types if necessary
val stringArrayList = bundle.getStringArrayList(key)
if (stringArrayList != null) {
return stringArrayList
}
// If neither String[] nor ArrayList<String> is found, return null
return null
}
override fun getFromPrefs(prefs: SharedPreferences): List<String>? {
return prefs.getStringSet(key, HashSet<String>())?.toList()
}
}
class AlwaysNeverUserDecidesMDMSetting(key: String, localizedTitle: String) :
MDMSetting<AlwaysNeverUserDecides>(AlwaysNeverUserDecides.UserDecides, key, localizedTitle) {
override fun getFromBundle(bundle: Bundle) =
AlwaysNeverUserDecides.fromString(bundle.getString(key))
override fun getFromPrefs(prefs: SharedPreferences) =
AlwaysNeverUserDecides.fromString(prefs.getString(key, null))
}
class ShowHideMDMSetting(key: String, localizedTitle: String) :
MDMSetting<ShowHide>(ShowHide.Show, key, localizedTitle) {
override fun getFromBundle(bundle: Bundle) =
ShowHide.fromString(bundle.getString(key))
override fun getFromPrefs(prefs: SharedPreferences) =
ShowHide.fromString(prefs.getString(key, null))
}
enum class AlwaysNeverUserDecides(val value: String) {
Always("always"),
Never("never"),
UserDecides("user-decides");
val hiddenFromUser: Boolean
get() {
return this != UserDecides
}
override fun toString(): String {
return value
}
companion object {
fun fromString(value: String?): AlwaysNeverUserDecides {
return values().find { it.value == value } ?: UserDecides
}
}
}
enum class ShowHide(val value: String) {
Show("show"),
Hide("hide");
override fun toString(): String {
return value
}
companion object {
fun fromString(value: String?): ShowHide {
return ShowHide.values().find { it.value == value } ?: Show
}
}
}

@ -1,27 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn.ui
object Links {
const val DEFAULT_CONTROL_URL = "https://controlplane.tailscale.com"
const val SERVER_URL = "https://login.tailscale.com"
const val ADMIN_URL = SERVER_URL + "/admin"
const val SIGNIN_URL = "https://tailscale.com/login"
const val PRIVACY_POLICY_URL = "https://tailscale.com/privacy-policy/"
const val TERMS_URL = "https://tailscale.com/terms"
const val DOCS_URL = "https://tailscale.com/kb/"
const val START_GUIDE_URL = "https://tailscale.com/kb/1017/install/"
const val LICENSES_URL = "https://tailscale.com/licenses/android"
const val DELETE_ACCOUNT_URL =
"https://login.tailscale.com/login?next_url=%2Fadmin%2Fsettings%2Fgeneral"
const val TAILNET_LOCK_KB_URL = "https://tailscale.com/kb/1226/tailnet-lock/"
const val KEY_EXPIRY_KB_URL = "https://tailscale.com/kb/1028/key-expiry/"
const val INSTALL_TAILSCALE_KB_URL = "https://tailscale.com/kb/installation/"
const val INSTALL_UNSTABLE_KB_URL = "https://tailscale.com/kb/1083/install-unstable"
const val MAGICDNS_KB_URL = "https://tailscale.com/kb/1081/magicdns"
const val TROUBLESHOOTING_KB_URL = "https://tailscale.com/kb/1023/troubleshooting"
const val SUPPORT_URL = "https://tailscale.com/contact/support#support-form"
const val TAILDROP_KB_URL = "https://tailscale.com/kb/1106/taildrop"
const val TAILFS_KB_URL = "https://tailscale.com/kb/1106/taildrop"
}

@ -1,368 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn.ui.localapi
import android.content.Context
import com.tailscale.ipn.ui.model.BugReportID
import com.tailscale.ipn.ui.model.Errors
import com.tailscale.ipn.ui.model.Ipn
import com.tailscale.ipn.ui.model.IpnLocal
import com.tailscale.ipn.ui.model.IpnState
import com.tailscale.ipn.ui.model.StableNodeID
import com.tailscale.ipn.ui.model.Tailcfg
import com.tailscale.ipn.ui.util.InputStreamAdapter
import com.tailscale.ipn.util.TSLog
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.decodeFromStream
import kotlinx.serialization.serializer
import libtailscale.FilePart
import java.nio.charset.Charset
import kotlin.reflect.KType
import kotlin.reflect.typeOf
private object Endpoint {
const val DEBUG = "debug"
const val DEBUG_LOG = "debug-log"
const val BUG_REPORT = "bugreport"
const val PREFS = "prefs"
const val FILE_TARGETS = "file-targets"
const val UPLOAD_METRICS = "upload-client-metrics"
const val START = "start"
const val LOGIN_INTERACTIVE = "login-interactive"
const val RESET_AUTH = "reset-auth"
const val LOGOUT = "logout"
const val PROFILES = "profiles/"
const val PROFILES_CURRENT = "profiles/current"
const val STATUS = "status"
const val TKA_STATUS = "tka/status"
const val TKA_SIGN = "tka/sign"
const val TKA_VERIFY_DEEP_LINK = "tka/verify-deeplink"
const val PING = "ping"
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
typealias TailnetLockStatusResponseHandler = (Result<IpnState.NetworkLockStatus>) -> Unit
typealias BugReportIdHandler = (Result<BugReportID>) -> Unit
typealias PrefsHandler = (Result<Ipn.Prefs>) -> Unit
typealias PingResultHandler = (Result<IpnState.PingResult>) -> Unit
/**
* Client provides a mechanism for calling Go's LocalAPIClient. Every LocalAPI endpoint has a
* corresponding method on this Client.
*/
class Client(private val scope: CoroutineScope) {
private val TAG = Client::class.simpleName
fun start(options: Ipn.Options, responseHandler: (Result<Unit>) -> Unit) {
val body = Json.encodeToString(options).toByteArray()
return post(Endpoint.START, body, responseHandler = responseHandler)
}
fun status(responseHandler: StatusResponseHandler) {
get(Endpoint.STATUS, responseHandler = responseHandler)
}
fun ping(peer: Tailcfg.Node, responseHandler: PingResultHandler) {
val ip = peer.primaryIPv4Address.orEmpty()
if (ip.isEmpty()) {
responseHandler(Result.failure(Exception("No IP address for peer $peer")))
return
}
val path = "${Endpoint.PING}?ip=${ip}&type=disco"
post(path, timeoutMillis = 2000L, responseHandler = responseHandler)
}
fun bugReportId(responseHandler: BugReportIdHandler) {
post(Endpoint.BUG_REPORT, responseHandler = responseHandler)
}
fun prefs(responseHandler: PrefsHandler) {
get(Endpoint.PREFS, responseHandler = responseHandler)
}
fun editPrefs(prefs: Ipn.MaskedPrefs, responseHandler: (Result<Ipn.Prefs>) -> Unit) {
val body = Json.encodeToString(prefs).toByteArray()
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)
}
fun currentProfile(responseHandler: (Result<IpnLocal.LoginProfile>) -> Unit) {
return get(Endpoint.PROFILES_CURRENT, responseHandler = responseHandler)
}
fun addProfile(responseHandler: (Result<String>) -> Unit = {}) {
return put(Endpoint.PROFILES, responseHandler = responseHandler)
}
fun deleteProfile(
profile: IpnLocal.LoginProfile,
responseHandler: (Result<String>) -> Unit = {}
) {
return delete(Endpoint.PROFILES + profile.ID, responseHandler = responseHandler)
}
fun switchProfile(
profile: IpnLocal.LoginProfile,
responseHandler: (Result<String>) -> Unit = {}
) {
return post(Endpoint.PROFILES + profile.ID, responseHandler = responseHandler)
}
fun startLoginInteractive(responseHandler: (Result<Unit>) -> Unit) {
return post(Endpoint.LOGIN_INTERACTIVE, responseHandler = responseHandler)
}
fun logout(responseHandler: (Result<String>) -> Unit) {
return post(Endpoint.LOGOUT, responseHandler = responseHandler)
}
fun tailnetLockStatus(responseHandler: TailnetLockStatusResponseHandler) {
get(Endpoint.TKA_STATUS, responseHandler = responseHandler)
}
fun fileTargets(responseHandler: (Result<List<Ipn.FileTarget>>) -> Unit) {
get(Endpoint.FILE_TARGETS, responseHandler = responseHandler)
}
fun putTaildropFiles(
context: Context,
peerId: StableNodeID,
files: Collection<Ipn.OutgoingFile>,
responseHandler: (Result<String>) -> Unit
) {
val manifest = Json.encodeToString(files)
val manifestPart = FilePart()
manifestPart.body = InputStreamAdapter(manifest.byteInputStream(Charset.defaultCharset()))
manifestPart.filename = "manifest.json"
manifestPart.contentType = "application/json"
val parts = mutableListOf(manifestPart)
try {
parts.addAll(
files.map { file ->
val stream =
context.contentResolver.openInputStream(file.uri)
?: throw Exception("Error opening file stream")
val part = FilePart()
part.filename = file.Name
part.contentLength = file.DeclaredSize
part.body = InputStreamAdapter(stream)
part
})
} catch (e: Exception) {
parts.forEach { it.body.close() }
TSLog.e(TAG, "Error creating file upload body: $e")
responseHandler(Result.failure(e))
return
}
return postMultipart(
"${Endpoint.FILE_PUT}/${peerId}",
FileParts(parts),
responseHandler,
)
}
private inline fun <reified T> get(
path: String,
body: ByteArray? = null,
noinline responseHandler: (Result<T>) -> Unit
) {
Request(
scope = scope,
method = "GET",
path = path,
body = body,
responseType = typeOf<T>(),
responseHandler = responseHandler)
.execute()
}
private inline fun <reified T> put(
path: String,
body: ByteArray? = null,
noinline responseHandler: (Result<T>) -> Unit
) {
Request(
scope = scope,
method = "PUT",
path = path,
body = body,
responseType = typeOf<T>(),
responseHandler = responseHandler)
.execute()
}
private inline fun <reified T> post(
path: String,
body: ByteArray? = null,
timeoutMillis: Long = 30000,
noinline responseHandler: (Result<T>) -> Unit
) {
Request(
scope = scope,
method = "POST",
path = path,
body = body,
timeoutMillis = timeoutMillis,
responseType = typeOf<T>(),
responseHandler = responseHandler)
.execute()
}
private inline fun <reified T> postMultipart(
path: String,
parts: FileParts,
noinline responseHandler: (Result<T>) -> Unit
) {
Request(
scope = scope,
method = "POST",
path = path,
parts = parts,
timeoutMillis = 24 * 60 * 60 * 1000, // 24 hours
responseType = typeOf<T>(),
responseHandler = responseHandler)
.execute()
}
private inline fun <reified T> patch(
path: String,
body: ByteArray? = null,
noinline responseHandler: (Result<T>) -> Unit
) {
Request(
scope = scope,
method = "PATCH",
path = path,
body = body,
responseType = typeOf<T>(),
responseHandler = responseHandler)
.execute()
}
private inline fun <reified T> delete(
path: String,
noinline responseHandler: (Result<T>) -> Unit
) {
Request(
scope = scope,
method = "DELETE",
path = path,
responseType = typeOf<T>(),
responseHandler = responseHandler)
.execute()
}
}
class Request<T>(
private val scope: CoroutineScope,
private val method: String,
path: String,
private val body: ByteArray? = null,
private val parts: FileParts? = null,
private val timeoutMillis: Long = 30000,
private val responseType: KType,
private val responseHandler: (Result<T>) -> Unit
) {
private val fullPath = "/localapi/v0/$path"
companion object {
private const val TAG = "LocalAPIRequest"
private val jsonDecoder = Json { ignoreUnknownKeys = true }
private lateinit var app: libtailscale.Application
@JvmStatic
fun setApp(newApp: libtailscale.Application) {
app = newApp
}
}
@OptIn(ExperimentalSerializationApi::class)
fun execute() {
scope.launch(Dispatchers.IO) {
TSLog.d(TAG, "Executing request:${method}:${fullPath} on app $app")
try {
val resp =
if (parts != null) app.callLocalAPIMultipart(timeoutMillis, method, fullPath, parts)
else
app.callLocalAPI(
timeoutMillis,
method,
fullPath,
body?.let { InputStreamAdapter(it.inputStream()) })
// TODO: use the streaming body for performance
// An empty body is a perfectly valid response and indicates success
val respData = resp.bodyBytes() ?: ByteArray(0)
@Suppress("UNCHECKED_CAST")
val response: Result<T> =
when (responseType) {
typeOf<String>() -> Result.success(respData.decodeToString() as T)
typeOf<Unit>() -> Result.success(Unit as T)
else ->
try {
Result.success(
jsonDecoder.decodeFromStream(
Json.serializersModule.serializer(responseType), respData.inputStream())
as T)
} catch (t: Throwable) {
// If we couldn't parse the response body, assume it's an error response
try {
val error =
jsonDecoder.decodeFromStream<Errors.GenericError>(respData.inputStream())
throw Exception(error.error)
} catch (t: Throwable) {
Result.failure(t)
}
}
}
if (resp.statusCode() >= 400) {
throw Exception(
"Request failed with status ${resp.statusCode()}: ${respData.toString(Charset.defaultCharset())}")
}
// The response handler will invoked internally by the request parser
scope.launch { responseHandler(response) }
} catch (e: Exception) {
TSLog.e(TAG, "Error executing request:${method}:${fullPath}: $e")
scope.launch { responseHandler(Result.failure(e)) }
}
}
}
}
class FileParts(private val parts: List<FilePart>) : libtailscale.FileParts {
override fun get(i: Int): FilePart {
return parts[i]
}
override fun len(): Int {
return parts.size
}
}

@ -1,30 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn.ui.model
import kotlinx.serialization.Serializable
class Dns {
@Serializable data class HostEntry(val addr: Addr?, val hosts: List<String>?)
@Serializable
data class OSConfig(
val hosts: List<HostEntry>? = null,
val nameservers: List<Addr>? = null,
val searchDomains: List<String>? = null,
val matchDomains: List<String>? = null,
) {
val isEmpty: Boolean
get() =
(hosts.isNullOrEmpty()) &&
(nameservers.isNullOrEmpty()) &&
(searchDomains.isNullOrEmpty()) &&
(matchDomains.isNullOrEmpty())
}
}
class DnsType {
@Serializable
data class Resolver(var Addr: String? = null, var BootstrapResolution: List<Addr>? = null)
}

@ -1,85 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn.ui.model
import androidx.compose.material3.ListItemColors
import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import com.tailscale.ipn.ui.theme.warning
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 ImpactsConnectivity: Boolean? = false,
var DependsOn: List<String>? = null, // an array of WarnableCodes this depends on
) : Comparable<UnhealthyState> {
fun hiddenByDependencies(currentWarnableCodes: Set<String>): Boolean {
return this.DependsOn?.let {
it.any { depWarnableCode -> currentWarnableCodes.contains(depWarnableCode) }
} == true
}
override fun compareTo(other: UnhealthyState): Int {
// Compare by severity first
val severityComparison = Severity.compareTo(other.Severity)
if (severityComparison != 0) {
return severityComparison
}
// If severities are equal, compare by warnableCode
return WarnableCode.compareTo(other.WarnableCode)
}
}
@Serializable
enum class Severity : Comparable<Severity> {
low,
medium,
high;
@Composable
fun listItemColors(): ListItemColors {
val default = ListItemDefaults.colors()
return when (this) {
Severity.low ->
ListItemColors(
containerColor = MaterialTheme.colorScheme.surface,
headlineColor = MaterialTheme.colorScheme.secondary,
leadingIconColor = MaterialTheme.colorScheme.secondary,
overlineColor = MaterialTheme.colorScheme.secondary.copy(alpha = 0.8f),
supportingTextColor = MaterialTheme.colorScheme.secondary,
trailingIconColor = MaterialTheme.colorScheme.secondary,
disabledHeadlineColor = default.disabledHeadlineColor,
disabledLeadingIconColor = default.disabledLeadingIconColor,
disabledTrailingIconColor = default.disabledTrailingIconColor)
Severity.medium,
Severity.high ->
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)
}
}
}
}

@ -1,240 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn.ui.model
import android.net.Uri
import kotlinx.serialization.Serializable
import kotlinx.serialization.Transient
import java.util.UUID
class Ipn {
// Represents the overall state of the Tailscale engine.
enum class State(val value: Int) {
NoState(0),
InUseOtherUser(1),
NeedsLogin(2),
NeedsMachineAuth(3),
Stopped(4),
Starting(5),
Running(6),
// Stopping represents a state where a request to stop Tailscale has been issue but has not
// completed. This state allows UI to optimistically reflect a stopped state, and to fallback if
// necessary.
Stopping(7);
companion object {
fun fromInt(value: Int): State {
return State.values().firstOrNull { it.value == value } ?: NoState
}
}
}
// A nofitication message recieved on the Notify bus. Fields will be populated based
// on which NotifyWatchOpts were set when the Notifier was created.
@Serializable
data class Notify(
val Version: String? = null,
val ErrMessage: String? = null,
val LoginFinished: Empty.Message? = null,
val FilesWaiting: Empty.Message? = null,
val OutgoingFiles: List<OutgoingFile>? = null,
val State: Int? = null,
var Prefs: Prefs? = null,
var NetMap: Netmap.NetworkMap? = null,
var Engine: EngineStatus? = null,
var BrowseToURL: String? = null,
var BackendLogId: String? = null,
var LocalTCPPort: Int? = null,
var IncomingFiles: List<PartialFile>? = null,
var ClientVersion: Tailcfg.ClientVersion? = null,
var TailFSShares: List<String>? = null,
var Health: Health.State? = null,
)
@Serializable
data class Prefs(
var ControlURL: String = "",
var RouteAll: Boolean = false,
var AllowsSingleHosts: Boolean = false,
var CorpDNS: Boolean = false,
var WantRunning: Boolean = false,
var LoggedOut: Boolean = false,
var ShieldsUp: Boolean = false,
var AdvertiseRoutes: List<String>? = null,
var AdvertiseTags: List<String>? = null,
var ExitNodeID: StableNodeID? = null,
var ExitNodeAllowLANAccess: Boolean = false,
var Config: Persist.Persist? = null,
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(
var ControlURLSet: Boolean? = null,
var RouteAllSet: Boolean? = null,
var CorpDNSSet: Boolean? = null,
var ExitNodeIDSet: Boolean? = null,
var ExitNodeAllowLANAccessSet: Boolean? = null,
var WantRunningSet: Boolean? = null,
var ShieldsUpSet: Boolean? = null,
var AdvertiseRoutesSet: Boolean? = null,
var ForceDaemonSet: Boolean? = null,
var HostnameSet: Boolean? = null,
var InternalExitNodePriorSet: Boolean? = null,
) {
var ControlURL: String? = null
set(value) {
field = value
ControlURLSet = true
}
var RouteAll: Boolean? = null
set(value) {
field = value
RouteAllSet = true
}
var CorpDNS: Boolean? = null
set(value) {
field = value
CorpDNSSet = true
}
var ExitNodeID: StableNodeID? = null
set(value) {
field = value
ExitNodeIDSet = true
}
var InternalExitNodePrior: String? = null
set(value) {
field = value
InternalExitNodePriorSet = true
}
var ExitNodeAllowLANAccess: Boolean? = null
set(value) {
field = value
ExitNodeAllowLANAccessSet = true
}
var WantRunning: Boolean? = null
set(value) {
field = value
WantRunningSet = true
}
var ShieldsUp: Boolean? = null
set(value) {
field = value
ShieldsUpSet = true
}
var AdvertiseRoutes: List<String>? = null
set(value) {
field = value
AdvertiseRoutesSet = true
}
var ForceDaemon: Boolean? = null
set(value) {
field = value
ForceDaemonSet = true
}
var Hostname: String? = null
set(value) {
field = value
HostnameSet = true
}
}
@Serializable
data class AutoUpdatePrefs(
var Check: Boolean? = null,
var Apply: Boolean? = null,
)
@Serializable
data class EngineStatus(
val RBytes: Long,
val WBytes: Long,
val NumLive: Int,
val LivePeers: Map<String, IpnState.PeerStatusLite>,
)
@Serializable
data class PartialFile(
val Name: String,
val Started: String,
val DeclaredSize: Long,
val Received: Long,
val PartialPath: String? = null,
var FinalPath: String? = null,
val Done: Boolean? = null,
)
@Serializable
data class OutgoingFile(
val ID: String = "",
val Name: String,
val PeerID: StableNodeID = "",
val Started: String = "",
val DeclaredSize: Long,
val Sent: Long = 0L,
val PartialPath: String? = null,
var FinalPath: String? = null,
val Finished: Boolean = false,
val Succeeded: Boolean = false,
) {
@Transient lateinit var uri: Uri // only used on client
fun prepare(peerId: StableNodeID): OutgoingFile {
val f = copy(ID = UUID.randomUUID().toString(), PeerID = peerId)
f.uri = uri
return f
}
}
@Serializable data class FileTarget(var Node: Tailcfg.Node, var PeerAPIURL: String)
@Serializable
data class Options(
var FrontendLogID: String? = null,
var UpdatePrefs: Prefs? = null,
var AuthKey: String? = null,
)
}
class Persist {
@Serializable
data class Persist(
var PrivateMachineKey: String =
"privkey:0000000000000000000000000000000000000000000000000000000000000000",
var PrivateNodeKey: String =
"privkey:0000000000000000000000000000000000000000000000000000000000000000",
var OldPrivateNodeKey: String =
"privkey:0000000000000000000000000000000000000000000000000000000000000000",
var Provider: String = "",
)
}

@ -1,152 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn.ui.model
import kotlinx.serialization.Serializable
import java.net.URL
class IpnState {
@Serializable
data class PeerStatusLite(
val RxBytes: Long,
val TxBytes: Long,
val LastHandshake: String,
val NodeKey: String,
)
@Serializable
data class PeerStatus(
val ID: StableNodeID,
val HostName: String,
val DNSName: String,
val TailscaleIPs: List<Addr>? = null,
val Tags: List<String>? = null,
val PrimaryRoutes: List<String>? = null,
val Addrs: List<String>? = null,
val CurAddr: String? = null,
val Relay: String? = null,
val Online: Boolean,
val ExitNode: Boolean,
val ExitNodeOption: Boolean,
val Active: Boolean,
val PeerAPIURL: List<String>? = null,
val Capabilities: List<String>? = null,
val SSH_HostKeys: List<String>? = null,
val ShareeNode: Boolean? = null,
val Expired: Boolean? = null,
val Location: Tailcfg.Location? = null,
) {
fun computedName(status: Status): String {
val name = DNSName
val suffix = status.CurrentTailnet?.MagicDNSSuffix
suffix ?: return name
if (!(name.endsWith("." + suffix + "."))) {
return name
}
return name.dropLast(suffix.count() + 2)
}
}
@Serializable
data class ExitNodeStatus(
val ID: StableNodeID,
val Online: Boolean,
val TailscaleIPs: List<Prefix>? = null,
)
@Serializable
data class TailnetStatus(
val Name: String,
val MagicDNSSuffix: String,
val MagicDNSEnabled: Boolean,
)
@Serializable
data class Status(
val Version: String,
val TUN: Boolean,
val BackendState: String,
val AuthURL: String,
val TailscaleIPs: List<Addr>? = null,
val Self: PeerStatus? = null,
val ExitNodeStatus: ExitNodeStatus? = null,
val Health: List<String>? = null,
val CurrentTailnet: TailnetStatus? = null,
val CertDomains: List<String>? = null,
val Peer: Map<String, PeerStatus>? = null,
val User: Map<String, Tailcfg.UserProfile>? = null,
val ClientVersion: Tailcfg.ClientVersion? = null,
)
@Serializable
data class NetworkLockStatus(
var Enabled: Boolean? = null,
var PublicKey: String? = null,
var NodeKey: String? = null,
var NodeKeySigned: Boolean? = null,
var FilteredPeers: List<TKAFilteredPeer>? = null,
var StateID: ULong? = null,
var TrustedKeys: List<TKAKey>? = null
) {
fun IsPublicKeyTrusted(): Boolean {
return TrustedKeys?.any { it.Key == PublicKey } == true
}
}
@Serializable
data class TKAFilteredPeer(
var Name: String,
var TailscaleIPs: List<Addr>,
var NodeKey: String,
)
@Serializable data class TKAKey(var Key: String)
@Serializable
data class PingResult(
var IP: Addr,
var Err: String,
var LatencySeconds: Double,
)
}
class IpnLocal {
@Serializable
data class LoginProfile(
var ID: String,
val Name: String,
val Key: String,
val UserProfile: Tailcfg.UserProfile,
val NetworkProfile: Tailcfg.NetworkProfile? = null,
val LocalUserID: String,
var ControlURL: String? = null,
) {
fun isEmpty(): Boolean {
return ID.isEmpty()
}
// Returns true if the profile uses a custom control server (not Tailscale SaaS).
private fun isUsingCustomControlServer(): Boolean {
return ControlURL != null && ControlURL != "https://controlplane.tailscale.com"
}
// Returns the hostname of the custom control server, if any was set.
//
// Returns null if the ControlURL provided by the backend is an invalid URL, and
// a hostname cannot be extracted.
fun customControlServerHostname(): String? {
if (!isUsingCustomControlServer()) return null
return try {
URL(ControlURL).host
} catch (e: Exception) {
null
}
}
}
}

@ -1,55 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn.ui.model
import kotlinx.serialization.Serializable
class Netmap {
@Serializable
data class NetworkMap(
var SelfNode: Tailcfg.Node,
var NodeKey: KeyNodePublic,
var Peers: List<Tailcfg.Node>? = null,
var Expiry: Time,
var Domain: String,
var UserProfiles: Map<String, Tailcfg.UserProfile>,
var TKAEnabled: Boolean,
var DNS: Tailcfg.DNSConfig? = null
) {
// Keys are tailcfg.UserIDs thet get stringified
// Helpers
fun currentUserProfile(): Tailcfg.UserProfile? {
return userProfile(User())
}
fun User(): UserID {
return SelfNode.User
}
fun userProfile(id: Long): Tailcfg.UserProfile? {
return UserProfiles[id.toString()]
}
fun getPeer(id: StableNodeID): Tailcfg.Node? {
if (id == SelfNode.StableID) {
return SelfNode
}
return Peers?.find { it.StableID == id }
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is NetworkMap) return false
return SelfNode == other.SelfNode &&
NodeKey == other.NodeKey &&
Peers == other.Peers &&
Expiry == other.Expiry &&
User() == other.User() &&
Domain == other.Domain &&
UserProfiles == other.UserProfiles &&
TKAEnabled == other.TKAEnabled
}
}
}

@ -1,90 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn.ui.model
import android.Manifest
import android.os.Build
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
import androidx.core.app.NotificationManagerCompat
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.PermissionState
import com.google.accompanist.permissions.isGranted
import com.google.accompanist.permissions.rememberMultiplePermissionsState
import com.google.accompanist.permissions.shouldShowRationale
import com.tailscale.ipn.R
object Permissions {
/** Permissions to prompt for on MainView. */
@OptIn(ExperimentalPermissionsApi::class)
val prompt: List<Pair<Permission, PermissionState>>
@Composable
get() {
val permissionStates = rememberMultiplePermissionsState(permissions = all.map { it.name })
return all.zip(permissionStates.permissions).filter { (_, state) ->
!state.status.isGranted && !state.status.shouldShowRationale
}
}
/** All permissions with granted status. */
@OptIn(ExperimentalPermissionsApi::class)
val withGrantedStatus: List<Pair<Permission, Boolean>>
@Composable
get() {
val permissionStates = rememberMultiplePermissionsState(permissions = all.map { it.name })
val result = mutableListOf<Pair<Permission, Boolean>>()
result.addAll(
all.zip(permissionStates.permissions).map { (permission, state) ->
Pair(permission, state.status.isGranted)
})
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
// On Android versions prior to 13, we have to programmatically check if notifications are
// being allowed.
val notificationsEnabled =
NotificationManagerCompat.from(LocalContext.current).areNotificationsEnabled()
result.add(
Pair(
Permission(
"",
R.string.permission_post_notifications,
R.string.permission_post_notifications_needed),
notificationsEnabled))
}
return result
}
/**
* All permissions that Tailscale requires. MainView takes care of prompting for permissions, and
* PermissionsView provides a list of permissions with corresponding statuses and a link to the
* application settings.
*
* When new permissions are needed, just add them to this list and the necessary strings to
* strings.xml and the rest should take care of itself.
*/
private val all: List<Permission> by lazy {
val result = mutableListOf<Permission>()
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) {
result.add(
Permission(
Manifest.permission.WRITE_EXTERNAL_STORAGE,
R.string.permission_write_external_storage,
R.string.permission_write_external_storage_needed,
))
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
result.add(
Permission(
Manifest.permission.POST_NOTIFICATIONS,
R.string.permission_post_notifications,
R.string.permission_post_notifications_needed))
}
result
}
}
data class Permission(
val name: String,
val title: Int,
val description: Int,
)

@ -1,205 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn.ui.model
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
import java.util.Date
class Tailcfg {
@Serializable
data class ClientVersion(
var RunningLatest: Boolean? = null,
var LatestVersion: String? = null,
var UrgentSecurityUpdate: Boolean? = null,
var Notify: Boolean? = null,
var NotifyURL: String? = null,
var NotifyText: String? = null
)
@Serializable
data class UserProfile(
val ID: Long,
val DisplayName: String,
val LoginName: String,
val ProfilePicURL: String? = null,
) {
fun isTaggedDevice(): Boolean {
return LoginName == "tagged-devices"
}
}
@Serializable
data class Hostinfo(
var IPNVersion: String? = null,
var FrontendLogID: String? = null,
var BackendLogID: String? = null,
var OS: String? = null,
var OSVersion: String? = null,
var Env: String? = null,
var Distro: String? = null,
var DistroVersion: String? = null,
var DistroCodeName: String? = null,
var Desktop: Boolean? = null,
var Package: String? = null,
var DeviceModel: String? = null,
var ShareeNode: Boolean? = null,
var Hostname: String? = null,
var ShieldsUp: Boolean? = null,
var NoLogsNoSupport: Boolean? = null,
var Machine: String? = null,
var RoutableIPs: List<Prefix>? = null,
var Services: List<Service>? = null,
var Location: Location? = null,
)
@Serializable
data class Node(
var ID: NodeID,
var StableID: StableNodeID,
var Name: String,
var User: UserID,
var Sharer: UserID? = null,
var Key: KeyNodePublic,
var KeyExpiry: String,
var Machine: MachineKey,
var Addresses: List<Prefix>? = null,
var AllowedIPs: List<Prefix>? = null,
var Endpoints: List<String>? = null,
var Hostinfo: Hostinfo,
var Created: Time,
var LastSeen: Time? = null,
var Online: Boolean? = null,
var Capabilities: List<String>? = null,
var CapMap: Map<String, JsonElement?>? = null,
var ComputedName: String?,
var ComputedNameWithHost: String?
) {
val isAdmin: Boolean
get() =
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
val isMullvadNode: Boolean
get() = Name.endsWith(".mullvad.ts.net.")
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?) =
Online == true || StableID == nm?.SelfNode?.StableID
fun connectedStrRes(nm: Netmap.NetworkMap?) =
if (connectedOrSelfNode(nm)) R.string.connected else R.string.not_connected
@Composable
fun connectedColor(nm: Netmap.NetworkMap?) =
if (connectedOrSelfNode(nm)) MaterialTheme.colorScheme.on else MaterialTheme.colorScheme.off
val nameWithoutTrailingDot = Name.trimEnd('.')
val displayAddresses: List<DisplayAddress>
get() {
var addresses = mutableListOf<DisplayAddress>()
addresses.add(DisplayAddress(nameWithoutTrailingDot))
Addresses?.let { addresses.addAll(it.map { addr -> DisplayAddress(addr) }) }
return addresses
}
val info: List<PeerSettingInfo>
get() {
val result = mutableListOf<PeerSettingInfo>()
if (Hostinfo.OS?.isNotEmpty() == true) {
result.add(
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)))
}
return result
}
@Composable
fun expiryLabel(): String {
if (KeyExpiry == GoZeroTimeString) {
return stringResource(R.string.deviceKeyNeverExpires)
}
val expDate = TimeUtil.dateFromGoString(KeyExpiry)
val template = if (expDate > Date()) R.string.deviceKeyExpires else R.string.deviceKeyExpired
return stringResource(template, TimeUtil.keyExpiryFromGoTime(KeyExpiry).getString())
}
}
@Serializable
data class Service(var Proto: String, var Port: Int, var Description: String? = null)
@Serializable
data class NetworkProfile(var MagicDNSName: String? = null, var DomainName: String? = null)
@Serializable
data class Location(
var Country: String? = null,
var CountryCode: String? = null,
var City: String? = null,
var CityCode: String? = null,
var Priority: Int? = null
)
@Serializable
data class DNSConfig(
var Resolvers: List<DnsType.Resolver>? = null,
var Routes: Map<String, List<DnsType.Resolver>?>? = null,
var FallbackResolvers: List<DnsType.Resolver>? = null,
var Domains: List<String>? = null,
var Nameservers: List<Addr>? = null
)
}

@ -1,36 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn.ui.model
import kotlinx.serialization.Serializable
typealias Addr = String
typealias Prefix = String
typealias NodeID = Long
typealias KeyNodePublic = String
typealias MachineKey = String
typealias UserID = Long
typealias Time = String
typealias StableNodeID = String
typealias BugReportID = String
val GoZeroTimeString = "0001-01-01T00:00:00Z"
// Represents and empty message with a single 'property' field.
class Empty {
@Serializable data class Message(val property: String = "")
}
// Parsable errors returned by localApiService
class Errors {
@Serializable data class GenericError(val error: String)
}

@ -1,140 +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 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 com.tailscale.ipn.ui.util.set
import com.tailscale.ipn.util.TSLog
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.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 ->
TSLog.d(TAG, "Health updated: ${health?.Warnings?.keys?.sorted()}")
health?.Warnings?.let {
notifyHealthUpdated(it.values.mapNotNull { it }.toTypedArray())
}
}
}
}
val currentWarnings: StateFlow<Set<UnhealthyState>> = MutableStateFlow(setOf())
val currentIcon: StateFlow<Int?> = MutableStateFlow(null)
private fun notifyHealthUpdated(warnings: Array<UnhealthyState>) {
val warningsBeforeAdd = currentWarnings.value
val currentWarnableCodes = warnings.map { it.WarnableCode }.toSet()
val addedWarnings: MutableSet<UnhealthyState> = mutableSetOf()
val isWarmingUp = warnings.any { it.WarnableCode == "warming-up" }
for (warning in warnings) {
if (ignoredWarnableCodes.contains(warning.WarnableCode)) {
continue
}
addedWarnings.add(warning)
if (this.currentWarnings.value.contains(warning)) {
// Already notified, skip
continue
} else if (warning.hiddenByDependencies(currentWarnableCodes)) {
// Ignore this warning because a dependency is also unhealthy
TSLog.d(TAG, "Ignoring ${warning.WarnableCode} because of dependency")
continue
} else if (!isWarmingUp) {
TSLog.d(TAG, "Adding health warning: ${warning.WarnableCode}")
this.currentWarnings.set(this.currentWarnings.value + warning)
if (warning.Severity == Health.Severity.high) {
this.sendNotification(warning.Title, warning.Text, warning.WarnableCode)
}
} else {
TSLog.d(TAG, "Ignoring ${warning.WarnableCode} because warming up")
}
}
val warningsToDrop = warningsBeforeAdd.minus(addedWarnings)
if (warningsToDrop.isNotEmpty()) {
TSLog.d(TAG, "Dropping health warnings with codes $warningsToDrop")
this.removeNotifications(warningsToDrop)
}
currentWarnings.set(this.currentWarnings.value.subtract(warningsToDrop))
this.updateIcon()
}
private fun updateIcon() {
if (currentWarnings.value.isEmpty()) {
this.currentIcon.set(null)
return
}
if (currentWarnings.value.any {
(it.Severity == Health.Severity.high || it.ImpactsConnectivity == true)
}) {
this.currentIcon.set(R.drawable.warning_rounded)
} else {
this.currentIcon.set(R.drawable.info)
}
}
private fun sendNotification(title: String, text: String, code: String) {
TSLog.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) {
TSLog.d(TAG, "Notification permission not granted")
return
}
notificationManager.notify(code.hashCode(), notification)
}
private fun removeNotifications(warnings: Set<UnhealthyState>) {
TSLog.d(TAG, "Removing notifications for $warnings")
for (warning in warnings) {
notificationManager.cancel(warning.WarnableCode.hashCode())
}
}
}

@ -1,116 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
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
import com.tailscale.ipn.ui.util.set
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.decodeFromStream
import com.tailscale.ipn.util.TSLog
// Notifier is a wrapper around the IPN Bus notifier. It provides a way to watch
// for changes in various parts of the Tailscale engine. You will typically only use
// a single Notifier per instance of your application which lasts for the lifetime of
// the process.
//
// The primary entry point here is watchIPNBus which will start a watcher on the IPN bus
// and return you the session Id. When you are done with your watcher, you must call
// unwatchIPNBus with the sessionId.
object Notifier {
private val TAG = Notifier::class.simpleName
private val decoder = Json { ignoreUnknownKeys = true }
// General IPN Bus State
private val _state = MutableStateFlow(Ipn.State.NoState)
val state: StateFlow<Ipn.State> = _state
val netmap: StateFlow<Netmap.NetworkMap?> = MutableStateFlow(null)
val prefs: StateFlow<Ipn.Prefs?> = MutableStateFlow(null)
val engineStatus: StateFlow<Ipn.EngineStatus?> = MutableStateFlow(null)
val tailFSShares: StateFlow<Map<String, String>?> = MutableStateFlow(null)
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)
val incomingFiles: StateFlow<List<Ipn.PartialFile>?> = MutableStateFlow(null)
val filesWaiting: StateFlow<Empty.Message?> = MutableStateFlow(null)
private lateinit var app: libtailscale.Application
private var manager: libtailscale.NotificationManager? = null
@JvmStatic
fun setApp(newApp: libtailscale.Application) {
app = newApp
}
@OptIn(ExperimentalSerializationApi::class)
fun start(scope: CoroutineScope) {
TSLog.d(TAG, "Starting Notifier")
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
manager =
app.watchNotifications(mask.toLong()) { notification ->
val notify = decoder.decodeFromStream<Notify>(notification.inputStream())
notify.State?.let { state.set(Ipn.State.fromInt(it)) }
notify.NetMap?.let(netmap::set)
notify.Prefs?.let(prefs::set)
notify.Engine?.let(engineStatus::set)
notify.TailFSShares?.let(tailFSShares::set)
notify.BrowseToURL?.let(browseToURL::set)
notify.LoginFinished?.let { loginFinished.set(it.property) }
notify.Version?.let(version::set)
notify.OutgoingFiles?.let(outgoingFiles::set)
notify.FilesWaiting?.let(filesWaiting::set)
notify.IncomingFiles?.let(incomingFiles::set)
notify.Health?.let(health::set)
}
}
}
fun stop() {
TSLog.d(TAG, "Stopping Notifier")
manager?.let {
it.stop()
manager = null
}
}
// NotifyWatchOpt is a bitmask of options supplied to the notifier to specify which
// what we want to see on the Notify bus
private enum class NotifyWatchOpt(val value: Int) {
EngineUpdates(1),
InitialState(2),
Prefs(4),
Netmap(8),
NoPrivateKey(16),
InitialTailFSShares(32),
InitialOutgoingFiles(64),
InitialHealthState(128),
}
fun setState(newState: Ipn.State) {
_state.value = newState
}
}

@ -1,9 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn.ui.theme
import androidx.compose.ui.graphics.Color
// TODO: replace references to these with references to material theme
val ts_color_light_blue = Color(0xFF4B70CC)

@ -1,467 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn.ui.theme
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.ButtonColors
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ColorScheme
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ListItemColors
import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextFieldDefaults
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
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.sp
import com.google.accompanist.systemuicontroller.rememberSystemUiController
@Composable
fun AppTheme(useDarkTheme: Boolean = isSystemInDarkTheme(), content: @Composable() () -> Unit) {
val colors =
if (useDarkTheme) {
DarkColors
} else {
LightColors
}
val typography =
Typography(
// titleMedium is styled to be slightly larger than bodyMedium for emphasis
titleMedium =
MaterialTheme.typography.titleMedium.copy(fontSize = 18.sp, lineHeight = 26.sp),
// bodyMedium is styled to use same line height as titleMedium to ensure even vertical
// margins in list items.
bodyMedium = MaterialTheme.typography.bodyMedium.copy(fontSize = 16.sp))
// TODO: Migrate to Activity.enableEdgeToEdge
@Suppress("deprecation") val systemUiController = rememberSystemUiController()
DisposableEffect(systemUiController, useDarkTheme) {
systemUiController.setStatusBarColor(color = colors.surfaceContainer)
systemUiController.setNavigationBarColor(color = Color.Black)
onDispose {}
}
MaterialTheme(colorScheme = colors, typography = typography, content = content)
}
private val LightColors =
lightColorScheme(
primary = Color(0xFF4B70CC), // blue-500
onPrimary = Color(0xFFFFFFFF), // white
primaryContainer = Color(0xFFF0F5FF), // blue-0
onPrimaryContainer = Color(0xFF3E5DB3), // blue-600
error = Color(0xFFB22C30), // red-500
onError = Color(0xFFFFFFFF), // white
errorContainer = Color(0xFFFEF6F3), // red-0
onErrorContainer = Color(0xFF930921), // red-600
surfaceDim = Color(0xFFF7F5F4), // gray-100
surface = Color(0xFFFFFFFF), // white,
background = Color(0xFFF7F5F4), // gray-100
surfaceBright = Color(0xFFFFFFFF), // white
surfaceContainerLowest = Color(0xFFFFFFFF), // white
surfaceContainerLow = Color(0xFFF7F5F4), // gray-100
surfaceContainer = Color(0xFFF7F5F4), // gray-100
surfaceContainerHigh = Color(0xFFF7F5F4), // gray-100
surfaceContainerHighest = Color(0xFFF7F5F4), // gray-100
surfaceVariant = Color(0xFFF7F5F4), // gray-100,
onSurface = Color(0xFF232222), // gray-800
onSurfaceVariant = Color(0xFF706E6D), // gray-500
outline = Color(0xFF706E6D), // gray-500
outlineVariant = Color(0xFFEDEBEA), // gray-200
inverseSurface = Color(0xFF232222), // gray-800
inverseOnSurface = Color(0xFFFFFFFF), // white
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
}
val ColorScheme.onWarning: Color
get() = Color(0xFFFFFFFF) // white
val ColorScheme.warningContainer: Color
get() = Color(0xFFFFFAEE) // orange-0
val ColorScheme.onWarningContainer: Color
get() = Color(0xFF7E1E22) // orange-600
val ColorScheme.success: Color
get() = Color(0xFF0A825D) // green-400
val ColorScheme.onSuccess: Color
get() = Color(0xFFFFFFFF) // white
val ColorScheme.successContainer: Color
get() = Color(0xFFEFFEEC) // green-0
val ColorScheme.onSuccessContainer: Color
get() = Color(0xFF0E4B3B) // green-600
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
}
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.
*/
val ColorScheme.listItem: ListItemColors
@Composable
get() {
val default = ListItemDefaults.colors()
return ListItemColors(
containerColor = default.containerColor,
headlineColor = default.headlineColor,
leadingIconColor = MaterialTheme.colorScheme.onPrimaryContainer,
overlineColor = default.overlineColor,
supportingTextColor = default.supportingTextColor,
trailingIconColor = MaterialTheme.colorScheme.onPrimaryContainer,
disabledHeadlineColor = default.disabledHeadlineColor,
disabledLeadingIconColor = default.disabledLeadingIconColor,
disabledTrailingIconColor = default.disabledTrailingIconColor)
}
/** Like listItem, but with the overline content using the onSurface color. */
val ColorScheme.titledListItem: ListItemColors
@Composable
get() {
val default = listItem
return ListItemColors(
containerColor = default.containerColor,
headlineColor = default.headlineColor,
leadingIconColor = default.leadingIconColor,
overlineColor = MaterialTheme.colorScheme.onSurface,
supportingTextColor = default.supportingTextColor,
trailingIconColor = default.trailingIconColor,
disabledHeadlineColor = default.disabledHeadlineColor,
disabledLeadingIconColor = default.disabledLeadingIconColor,
disabledTrailingIconColor = default.disabledTrailingIconColor)
}
/** Color scheme for disabled list items. */
val ColorScheme.disabledListItem: ListItemColors
@Composable
get() {
val default = ListItemDefaults.colors()
return ListItemColors(
containerColor = default.containerColor,
headlineColor = MaterialTheme.colorScheme.disabled,
leadingIconColor = default.leadingIconColor,
overlineColor = default.overlineColor,
supportingTextColor = MaterialTheme.colorScheme.onSurfaceVariant,
trailingIconColor = default.trailingIconColor,
disabledHeadlineColor = default.disabledHeadlineColor,
disabledLeadingIconColor = default.disabledLeadingIconColor,
disabledTrailingIconColor = default.disabledTrailingIconColor)
}
/** Color scheme for list items that should be styled as a surface container. */
val ColorScheme.surfaceContainerListItem: ListItemColors
@Composable
get() {
val default = ListItemDefaults.colors()
return ListItemColors(
containerColor = MaterialTheme.colorScheme.surfaceContainer,
headlineColor = MaterialTheme.colorScheme.onSurface,
leadingIconColor = MaterialTheme.colorScheme.onSurface,
overlineColor = MaterialTheme.colorScheme.onSurfaceVariant,
supportingTextColor = MaterialTheme.colorScheme.onSurfaceVariant,
trailingIconColor = MaterialTheme.colorScheme.onSurface,
disabledHeadlineColor = default.disabledHeadlineColor,
disabledLeadingIconColor = default.disabledLeadingIconColor,
disabledTrailingIconColor = default.disabledTrailingIconColor)
}
/** Color scheme for list items that should be styled as a primary item. */
val ColorScheme.primaryListItem: ListItemColors
@Composable
get() {
val default = ListItemDefaults.colors()
return ListItemColors(
containerColor = MaterialTheme.colorScheme.primary,
headlineColor = MaterialTheme.colorScheme.onPrimary,
leadingIconColor = MaterialTheme.colorScheme.onPrimary,
overlineColor = MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.7f),
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 a warning item. */
val ColorScheme.warningListItem: ListItemColors
@Composable
get() {
val default = ListItemDefaults.colors()
return 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),
supportingTextColor = MaterialTheme.colorScheme.onPrimary,
trailingIconColor = MaterialTheme.colorScheme.onPrimary,
disabledHeadlineColor = default.disabledHeadlineColor,
disabledLeadingIconColor = default.disabledLeadingIconColor,
disabledTrailingIconColor = default.disabledTrailingIconColor)
}
/** Main color scheme for top app bar, styles it as a surface container. */
@OptIn(ExperimentalMaterial3Api::class)
val ColorScheme.topAppBar: TopAppBarColors
@Composable
get() =
TopAppBarDefaults.topAppBarColors()
.copy(
containerColor = MaterialTheme.colorScheme.surfaceContainer,
navigationIconContentColor = MaterialTheme.colorScheme.onSurface,
titleContentColor = MaterialTheme.colorScheme.onSurface,
)
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)
}
}
val ColorScheme.disabled: Color
get() = Color(0xFFAFACAB) // gray-400
@OptIn(ExperimentalMaterial3Api::class)
val ColorScheme.searchBarColors: TextFieldColors
@Composable
get() {
return OutlinedTextFieldDefaults.colors(
focusedLeadingIconColor = MaterialTheme.colorScheme.onSurface,
unfocusedLeadingIconColor = MaterialTheme.colorScheme.onSurface,
focusedTextColor = MaterialTheme.colorScheme.onSurface,
unfocusedTextColor = MaterialTheme.colorScheme.onSurfaceVariant,
disabledTextColor = MaterialTheme.colorScheme.onSurfaceVariant,
focusedContainerColor = MaterialTheme.colorScheme.surfaceContainer,
unfocusedContainerColor = MaterialTheme.colorScheme.surfaceContainer,
disabledContainerColor = MaterialTheme.colorScheme.surfaceContainer,
focusedBorderColor = Color.Transparent,
unfocusedBorderColor = Color.Transparent)
}
val TextStyle.short: TextStyle
get() = copy(lineHeight = 20.sp)
val Typography.minTextSize: TextUnit
get() = 10.sp

@ -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
}
}
}

@ -1,30 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn.ui.util
import android.content.pm.PackageManager
import androidx.compose.foundation.layout.padding
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.ui.util.AndroidTVUtil.isAndroidTV
object AndroidTVUtil {
fun isAndroidTV(): Boolean {
val pm = UninitializedApp.get().packageManager
return (pm.hasSystemFeature(@Suppress("deprecation") PackageManager.FEATURE_TELEVISION) ||
pm.hasSystemFeature(PackageManager.FEATURE_LEANBACK))
}
}
// Applies a letterbox effect iff we're running on Android TV to reduce the overall width
// of the UI.
fun Modifier.universalFit(): Modifier {
return when (isAndroidTV()) {
true -> this.padding(horizontal = 150.dp, vertical = 10.dp).clip(RoundedCornerShape(10.dp))
false -> this
}
}

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

@ -1,78 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn.ui.util
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.TextLayoutResult
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.TextUnit
// AutoResizingText automatically resizes text up to the specified minFontSize in order to avoid
// overflowing. It is based on https://stackoverflow.com/a/66090448 licensed under CC BY-SA 4.0.
@Composable
fun AutoResizingText(
text: String,
minFontSize: TextUnit,
modifier: Modifier = Modifier,
color: Color = Color.Unspecified,
fontSize: TextUnit = TextUnit.Unspecified,
fontStyle: FontStyle? = null,
fontWeight: FontWeight? = null,
fontFamily: FontFamily? = null,
letterSpacing: TextUnit = TextUnit.Unspecified,
textDecoration: TextDecoration? = null,
textAlign: TextAlign? = null,
lineHeight: TextUnit = TextUnit.Unspecified,
overflow: TextOverflow = TextOverflow.Clip,
maxLines: Int = 1,
onTextLayout: ((TextLayoutResult) -> Unit)? = null,
style: TextStyle = LocalTextStyle.current
) {
var textStyle = remember { mutableStateOf(style) }
var textOverflow = remember { mutableStateOf(TextOverflow.Clip) }
var readyToDraw = remember { mutableStateOf(false) }
Text(
text = text,
modifier = modifier.drawWithContent { if (readyToDraw.value) drawContent() },
color = color,
fontSize = fontSize,
fontStyle = fontStyle,
fontWeight = fontWeight,
fontFamily = fontFamily,
letterSpacing = letterSpacing,
textDecoration = textDecoration,
textAlign = textAlign,
lineHeight = lineHeight,
overflow = textOverflow.value,
maxLines = maxLines,
softWrap = false,
style = textStyle.value,
onTextLayout = { result ->
if (result.didOverflowWidth) {
var newSize = textStyle.value.fontSize * 0.9
if (newSize < minFontSize) {
newSize = minFontSize
textOverflow.value = overflow
}
textStyle.value = textStyle.value.copy(fontSize = newSize)
} else {
readyToDraw.value = true
}
onTextLayout?.let { it(result) }
})
}

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

@ -1,22 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn.ui.util
import androidx.annotation.StringRes
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import com.tailscale.ipn.R
// Convenience wrapper for passing formatted strings to Composables
class ComposableStringFormatter(
@StringRes val stringRes: Int = R.string.template,
private vararg val params: Any
) {
// Convenience constructor for passing a non-formatted string directly
constructor(string: String) : this(stringRes = R.string.template, string)
// Returns the fully formatted string
@Composable fun getString(): String = stringResource(id = stringRes, *params)
}

@ -1,53 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn.ui.util
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import com.tailscale.ipn.R
import com.tailscale.ipn.ui.theme.on
sealed class ConnectionMode {
class NotConnected : ConnectionMode()
class Derp(val relayName: String) : ConnectionMode()
class Direct : ConnectionMode()
@Composable
fun titleString(): String {
return when (this) {
is NotConnected -> stringResource(id = R.string.not_connected)
is Derp -> stringResource(R.string.relayed_connection, relayName)
is Direct -> stringResource(R.string.direct_connection)
}
}
fun contentKey(): String {
return when (this) {
is NotConnected -> "NotConnected"
is Derp -> "Derp($relayName)"
is Direct -> "Direct"
}
}
fun iconDrawable(): Int {
return when (this) {
is NotConnected -> R.drawable.xmark_circle
is Derp -> R.drawable.link_off
is Direct -> R.drawable.link
}
}
@Composable
fun color(): Color {
return when (this) {
is NotConnected -> MaterialTheme.colorScheme.onPrimary
is Derp -> MaterialTheme.colorScheme.error
is Direct -> MaterialTheme.colorScheme.on
}
}
}

@ -1,46 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn.ui.util
class DisplayAddress(ip: String) {
enum class addrType {
V4,
V6,
MagicDNS
}
val type: addrType =
when {
ip.isIPV6() -> addrType.V6
ip.isIPV4() -> addrType.V4
else -> addrType.MagicDNS
}
val typeString: String =
when (type) {
addrType.V4 -> "IPv4"
addrType.V6 -> "IPv6"
addrType.MagicDNS -> "MagicDNS"
}
val address: String =
when (type) {
addrType.MagicDNS -> ip
else -> ip.split("/").first()
}
}
fun String.isIPV6(): Boolean {
return this.contains(":")
}
fun String.isIPV4(): Boolean {
val parts = this.split("/").first().split(".")
if (parts.size != 4) return false
for (part in parts) {
val value = part.toIntOrNull() ?: return false
if (value !in 0..255) return false
}
return true
}

@ -1,33 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn.ui.util
/**
* Code adapted from
* https://github.com/piashcse/Compose-museum/blob/master/app/src/main/java/com/piashcse/compose_museum/screens/CountryList.kt#L75
*/
// Copyright 2023 piashcse (Mehedi Hassan Piash)
//
// 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
//
// http://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.
/** Flag turns an ISO3166 country code into a flag emoji. */
fun String.flag(): String {
val caps = this.uppercase()
val flagOffset = 0x1F1E6
val asciiOffset = 0x41
val firstChar = Character.codePointAt(caps, 0) - asciiOffset + flagOffset
val secondChar = Character.codePointAt(caps, 1) - asciiOffset + flagOffset
return String(Character.toChars(firstChar)) + String(Character.toChars(secondChar))
}

@ -1,21 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn.ui.util
import java.io.InputStream
class InputStreamAdapter(private val inputStream: InputStream) : libtailscale.InputStream {
override fun read(): ByteArray? {
val b = ByteArray(4096)
val i = inputStream.read(b)
if (i == -1) {
return null
}
return b.sliceArray(0 ..< i)
}
override fun close() {
inputStream.close()
}
}

@ -1,34 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn.ui.util
import android.Manifest
import android.content.pm.ApplicationInfo
import android.content.pm.PackageManager
data class InstalledApp(val name: String, val packageName: String)
class InstalledAppsManager(
val packageManager: PackageManager,
) {
fun fetchInstalledApps(): List<InstalledApp> {
return packageManager
.getInstalledApplications(PackageManager.GET_META_DATA)
.filter(appIsIncluded)
.map {
InstalledApp(
name = it.loadLabel(packageManager).toString(),
packageName = it.packageName,
)
}
.sortedBy { it.name }
}
private val appIsIncluded: (ApplicationInfo) -> Boolean = { app ->
app.packageName != "com.tailscale.ipn" &&
// Only show apps that can access the Internet
packageManager.checkPermission(Manifest.permission.INTERNET, app.packageName) ==
PackageManager.PERMISSION_GRANTED
}
}

@ -1,125 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
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
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyItemScope
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.text.ClickableText
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
object Lists {
@Composable
fun SectionDivider(title: String? = null) {
Box(Modifier.size(0.dp, 16.dp))
title?.let { LargeTitle(title) }
}
@Composable
fun ItemDivider() {
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant)
}
@Composable
fun LargeTitle(
title: String,
bottomPadding: Dp = 0.dp,
style: TextStyle = MaterialTheme.typography.titleMedium,
fontWeight: FontWeight? = null,
focusable: Boolean = false
) {
Box(
modifier =
Modifier.fillMaxWidth()
.background(color = MaterialTheme.colorScheme.surface, shape = RectangleShape)) {
Text(
title,
modifier =
Modifier.padding(start = 16.dp, end = 16.dp, top = 8.dp, bottom = bottomPadding)
.focusable(focusable),
style = style,
fontWeight = fontWeight)
}
}
@Composable
fun MutedHeader(text: String) {
Box(
modifier =
Modifier.fillMaxWidth()
.background(color = MaterialTheme.colorScheme.surface, shape = RectangleShape)) {
Text(
modifier = Modifier.padding(start = 16.dp, top = 16.dp),
text = text,
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
@Composable
fun InfoItem(text: CharSequence, onClick: (() -> Unit)? = null) {
val style =
MaterialTheme.typography.bodyMedium.copy(color = MaterialTheme.colorScheme.onSurfaceVariant)
ListItem(
headlineContent = {
Box(modifier = Modifier.padding(vertical = 4.dp)) {
onClick?.let {
ClickableText(text = text as AnnotatedString, style = style, onClick = { onClick() })
} ?: run { Text(text as String, style = style) }
}
})
}
@Composable
fun MultilineDescription(headlineContent: @Composable () -> Unit) {
ListItem(
headlineContent = {
Box(modifier = Modifier.padding(vertical = 8.dp)) { headlineContent() }
})
}
}
/** Similar to items() but includes a horizontal divider between items. */
/** Similar to items() but includes a horizontal divider between items. */
inline fun <T> LazyListScope.itemsWithDividers(
items: List<T>,
noinline key: ((item: T) -> Any)? = null,
forceLeading: Boolean = false,
crossinline contentType: (item: T) -> Any? = { _ -> null },
crossinline itemContent: @Composable LazyItemScope.(item: T) -> Unit
) =
items(
count = items.size,
key = if (key != null) { index: Int -> key(items[index]) } else null,
contentType = { index -> contentType(items[index]) }) {
if (forceLeading && it == 0 || it > 0 && it < items.size) {
Lists.ItemDivider()
}
itemContent(items[it])
}
inline fun <T> LazyListScope.itemsWithDividers(
items: Array<T>,
noinline key: ((item: T) -> Any)? = null,
forceLeading: Boolean = false,
crossinline contentType: (item: T) -> Any? = { _ -> null },
crossinline itemContent: @Composable LazyItemScope.(item: T) -> Unit
) = itemsWithDividers(items.toList(), key, forceLeading, contentType, itemContent)

@ -1,65 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn.ui.util
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
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
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
object LoadingIndicator {
private val loading = MutableStateFlow(false)
fun start() {
loading.value = true
}
fun stop() {
loading.value = false
}
@Composable
fun Wrap(content: @Composable () -> Unit) {
Box(
modifier = Modifier.fillMaxWidth(),
contentAlignment = Alignment.Center,
) {
content()
val isLoading by loading.collectAsState()
if (isLoading) {
Box(Modifier.clickable {}.matchParentSize().background(Color.Gray.copy(alpha = 0.0f)))
val showSpinner: State<Boolean> =
produceState(initialValue = false) {
delay(300)
value = true
}
if (showSpinner.value) {
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally) {
TailscaleLogoView(
true, usesOnBackgroundColors = false, Modifier.size(72.dp).alpha(0.4f))
}
}
}
}
}
}

@ -1,129 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
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
data class PeerSet(val user: Tailcfg.UserProfile?, val peers: List<Tailcfg.Node>)
class PeerCategorizer {
var peerSets: List<PeerSet> = emptyList()
var lastSearchResult: List<PeerSet> = emptyList()
var lastSearchTerm: String = ""
fun regenerateGroupedPeers(netmap: Netmap.NetworkMap) {
val peers: List<Tailcfg.Node> = netmap.Peers ?: return
val selfNode = netmap.SelfNode
var grouped = mutableMapOf<UserID, MutableList<Tailcfg.Node>>()
val mdm = MDMSettings.hiddenNetworkDevices.flow.value.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)) {
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
}
if (!grouped.containsKey(userId)) {
grouped[userId] = mutableListOf()
}
grouped[userId]?.add(peer)
}
peerSets =
grouped
.map { (userId, peers) ->
val profile = netmap.userProfile(userId)
PeerSet(
profile,
peers.sortedWith { a, b ->
when {
a.StableID == b.StableID -> 0
a.isSelfNode(netmap) -> -1
b.isSelfNode(netmap) -> 1
else ->
(a.ComputedName?.lowercase() ?: "").compareTo(
b.ComputedName?.lowercase() ?: "")
}
})
}
.sortedBy {
if (it.user?.ID == me?.ID) {
""
} else {
it.user?.DisplayName?.lowercase() ?: "unknown user"
}
}
}
fun groupedAndFilteredPeers(searchTerm: String = ""): List<PeerSet> {
if (searchTerm.isEmpty()) {
return peerSets
}
if (searchTerm == this.lastSearchTerm) {
return lastSearchResult
}
// We can optimize out typing... If the search term starts with the last search term, we can
// just search the last result
val setsToSearch =
if (this.lastSearchTerm.isNotEmpty() && searchTerm.startsWith(this.lastSearchTerm))
lastSearchResult
else peerSets
this.lastSearchTerm = searchTerm
val matchingSets =
setsToSearch
.map { peerSet ->
val user = peerSet.user
val peers = peerSet.peers
val userMatches = user?.DisplayName?.contains(searchTerm, ignoreCase = true) ?: false
if (userMatches) {
return@map peerSet
}
val matchingPeers =
peers.filter {
it.displayName.contains(searchTerm, ignoreCase = true) ||
(it.Addresses ?: emptyList()).fastAny { addr -> addr.contains(searchTerm) }
}
if (matchingPeers.isNotEmpty()) {
PeerSet(user, matchingPeers)
} else {
null
}
}
.filterNotNull()
lastSearchResult = matchingSets
return matchingSets
}
}

@ -1,12 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn.ui.util
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
/** Provides a way to expose a MutableStateFlow as an immutable StateFlow. */
fun <T> StateFlow<T>.set(v: T) {
(this as MutableStateFlow<T>).value = v
}

@ -1,124 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn.ui.util
import com.tailscale.ipn.R
import com.tailscale.ipn.util.TSLog
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)
val expTime = epochMillisFromGoTime(time)
val now = Instant.now().toEpochMilli()
var diff = (expTime - now) / 1000
// Rather than use plurals here, we'll just use the singular form for everything and
// double the minimum. "in 70 minutes" instead of "in 1 hour". 121 minutes becomes
// 2 hours, as does 179 minutes... Close enough for what this is used for.
// Key is already expired (x minutes ago)
if (diff < 0) {
diff = -diff
return when (diff) {
in 0..60 -> ComposableStringFormatter(R.string.under_a_minute)
in 61..7200 ->
ComposableStringFormatter(R.string.ago_x_minutes, diff / 60) // 1 minute to 1 hour
in 7201..172800 ->
ComposableStringFormatter(R.string.ago_x_hours, diff / 3600) // 2 hours to 24 hours
in 172801..5184000 ->
ComposableStringFormatter(R.string.ago_x_days, diff / 86400) // 2 Days to 60 days
in 5184001..124416000 ->
ComposableStringFormatter(R.string.ago_x_months, diff / 2592000) // ~2 months to 2 years
else ->
ComposableStringFormatter(
R.string.ago_x_years,
diff.toDouble() / 31536000.0) // 2 years to n years (in decimal)
}
}
// Key is not expired (in x minutes)
return when (diff) {
in 0..60 -> ComposableStringFormatter(R.string.under_a_minute)
in 61..7200 ->
ComposableStringFormatter(R.string.in_x_minutes, diff / 60) // 1 minute to 1 hour
in 7201..172800 ->
ComposableStringFormatter(R.string.in_x_hours, diff / 3600) // 2 hours to 24 hours
in 172801..5184000 ->
ComposableStringFormatter(R.string.in_x_days, diff / 86400) // 2 Days to 60 days
in 5184001..124416000 ->
ComposableStringFormatter(R.string.in_x_months, diff / 2592000) // ~2 months to 2 years
else ->
ComposableStringFormatter(
R.string.in_x_years, diff.toDouble() / 31536000.0) // 2 years to n years (in decimal)
}
}
fun epochMillisFromGoTime(goTime: String): Long {
val ta = DateTimeFormatter.ISO_OFFSET_DATE_TIME.parse(goTime)
val i = Instant.from(ta)
return i.toEpochMilli()
}
fun dateFromGoString(goTime: String): Date {
val ta = DateTimeFormatter.ISO_OFFSET_DATE_TIME.parse(goTime)
val i = Instant.from(ta)
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 {
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 -> {
TSLog.e(TAG, "Invalid duration string: $goDuration")
return null
}
}
} catch (e: NumberFormatException) {
TSLog.e(TAG, "Invalid duration string: $goDuration")
return null
}
valStr = ""
}
}
return Duration.ofSeconds(duration.toLong())
}
}

@ -1,104 +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.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
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.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
import com.tailscale.ipn.ui.util.AppVersion
@Composable
fun AboutView(backToSettings: BackNavigation) {
val localClipboardManager = LocalClipboardManager.current
Scaffold(topBar = { Header(R.string.about_view_header, 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.width(100.dp)
.height(100.dp)
.clip(RoundedCornerShape(50))
.background(MaterialTheme.colorScheme.logoBackground)
.padding(25.dp))
Column(
verticalArrangement =
Arrangement.spacedBy(space = 2.dp, alignment = Alignment.CenterVertically),
horizontalAlignment = Alignment.CenterHorizontally) {
Text(
stringResource(R.string.about_view_title),
fontWeight = FontWeight.SemiBold,
fontSize = MaterialTheme.typography.titleLarge.fontSize)
Text(
modifier =
Modifier.clickable {
// When users tap on the version number, the extended version string
// (including commit hashes) is copied to the clipboard.
// This may be useful for debugging purposes...
localClipboardManager.setText(AnnotatedString(BuildConfig.VERSION_NAME))
},
// ... but we always display the short version in the UI to avoid user
// confusion.
text = "${stringResource(R.string.version)} ${AppVersion.Short()}",
fontWeight = MaterialTheme.typography.bodyMedium.fontWeight,
fontSize = MaterialTheme.typography.bodyMedium.fontSize)
}
Column(horizontalAlignment = Alignment.CenterHorizontally) {
OpenURLButton(stringResource(R.string.acknowledgements), Links.LICENSES_URL)
OpenURLButton(stringResource(R.string.privacy_policy), Links.PRIVACY_POLICY_URL)
OpenURLButton(stringResource(R.string.terms_of_service), Links.TERMS_URL)
}
Text(
stringResource(R.string.about_view_footnotes),
fontWeight = FontWeight.Normal,
fontSize = MaterialTheme.typography.labelMedium.fontSize,
textAlign = TextAlign.Center)
}
}
}
@Preview
@Composable
fun AboutPreview() {
AboutView({})
}

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

@ -1,94 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn.ui.view
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.ClickableText
import androidx.compose.foundation.verticalScroll
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
import androidx.compose.ui.text.AnnotatedString
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()
Scaffold(topBar = { Header(R.string.bug_report_title, onBack = backToSettings) }) { innerPadding
->
Column(
modifier =
Modifier.padding(innerPadding)
.fillMaxWidth()
.fillMaxHeight()
.verticalScroll(rememberScrollState())) {
Lists.MultilineDescription {
ClickableText(
text = contactText(),
style = MaterialTheme.typography.bodyMedium,
onClick = { handler.openUri(Links.SUPPORT_URL) })
}
ClipboardValueView(bugReportID, title = stringResource(R.string.bug_report_id))
Lists.InfoItem(stringResource(id = R.string.bug_report_id_desc))
}
}
}
@Composable
fun contactText(): AnnotatedString {
val annotatedString = buildAnnotatedString {
withStyle(SpanStyle(color = MaterialTheme.colorScheme.defaultTextColor)) {
append(stringResource(id = R.string.bug_report_instructions_prefix))
}
pushStringAnnotation(tag = "reportLink", annotation = Links.SUPPORT_URL)
withStyle(
style =
SpanStyle(
color = MaterialTheme.colorScheme.link,
textDecoration = TextDecoration.Underline)) {
append(stringResource(id = R.string.bug_report_instructions_linktext))
}
pop()
withStyle(SpanStyle(color = MaterialTheme.colorScheme.defaultTextColor)) {
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)
}

@ -1,41 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
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.material3.Button
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.LocalUriHandler
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.dp
import com.tailscale.ipn.ui.theme.link
@Composable
fun PrimaryActionButton(onClick: () -> Unit, content: @Composable RowScope.() -> Unit) {
Button(
onClick = onClick,
contentPadding = PaddingValues(vertical = 12.dp),
modifier = Modifier.fillMaxWidth(),
content = content)
}
@Composable
fun OpenURLButton(title: String, url: String) {
val handler = LocalUriHandler.current
TextButton(onClick = { handler.openUri(url) }) {
Text(
title,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.link,
textDecoration = TextDecoration.Underline,
)
}
}

@ -1,157 +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.foundation.text.KeyboardOptions
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.text.input.KeyboardCapitalization
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)
},
keyboardOptions = KeyboardOptions(capitalization = KeyboardCapitalization.None)
)
})
ListItem(
colors = MaterialTheme.colorScheme.listItem,
headlineContent = {
Box(modifier = Modifier.fillMaxWidth()) {
Button(
onClick = { onSubmitAction(textVal) },
content = { Text(stringResource(id = R.string.add_account_short)) })
}
})
}
}

@ -1,116 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn.ui.view
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material3.Icon
import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
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
data class ViewableRoute(val name: String, val resolvers: List<DnsType.Resolver>)
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun DNSSettingsView(
backToSettings: BackNavigation,
model: DNSSettingsViewModel = viewModel(factory = DNSSettingsViewModelFactory())
) {
val state: DNSEnablementState by model.enablementState.collectAsState()
val resolvers = model.dnsConfig.collectAsState().value?.Resolvers ?: emptyList()
val domains = model.dnsConfig.collectAsState().value?.Domains ?: emptyList()
val routes: List<ViewableRoute> =
model.dnsConfig.collectAsState().value?.Routes?.mapNotNull { entry ->
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 {
LazyColumn(Modifier.padding(innerPadding)) {
item("state") {
ListItem(
leadingContent = {
Icon(
painter = painterResource(state.symbolDrawable),
contentDescription = null,
tint = state.tint(),
modifier = Modifier.size(36.dp))
},
headlineContent = {
Text(stringResource(state.title), style = MaterialTheme.typography.titleMedium)
},
supportingContent = { Text(stringResource(state.caption)) })
if (!dnsSettingsMDMDisposition.value.hiddenFromUser) {
Lists.ItemDivider()
Setting.Switch(
R.string.use_ts_dns,
isOn = useCorpDNS,
onToggle = {
LoadingIndicator.start()
model.toggleCorpDNS { LoadingIndicator.stop() }
})
}
}
if (resolvers.isNotEmpty()) {
item("resolversHeader") { Lists.SectionDivider(stringResource(R.string.resolvers)) }
itemsWithDividers(resolvers) { resolver -> ClipboardValueView(resolver.Addr.orEmpty()) }
}
if (domains.isNotEmpty()) {
item("domainsHeader") { Lists.SectionDivider(stringResource(R.string.search_domains)) }
itemsWithDividers(domains) { domain -> ClipboardValueView(domain) }
}
if (routes.isNotEmpty()) {
routes.forEach { route ->
item { Lists.SectionDivider("Route: ${route.name}") }
itemsWithDividers(route.resolvers) { resolver ->
ClipboardValueView(resolver.Addr.orEmpty())
}
}
}
}
}
}
}
@Preview
@Composable
fun DNSSettingsViewPreview() {
val vm = DNSSettingsViewModel()
vm.enablementState.set(DNSEnablementState.ENABLED)
DNSSettingsView(backToSettings = {}, vm)
}

@ -1,82 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn.ui.view
import androidx.annotation.StringRes
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 {
INVALID_CUSTOM_URL,
LOGOUT_FAILED,
SWITCH_USER_FAILED,
ADD_PROFILE_FAILED,
SHARE_DEVICE_NOT_CONNECTED,
SHARE_FAILED,
INVALID_AUTH_KEY;
val message: Int
get() {
return when (this) {
INVALID_CUSTOM_URL -> R.string.invalidCustomUrl
LOGOUT_FAILED -> R.string.logout_failed
SWITCH_USER_FAILED -> R.string.switch_user_failed
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
}
}
val title: Int
get() {
return when (this) {
INVALID_CUSTOM_URL -> R.string.invalidCustomURLTitle
LOGOUT_FAILED -> R.string.logout_failed_title
SWITCH_USER_FAILED -> R.string.switch_user_failed_title
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
}
}
val buttonText: Int = R.string.ok
}
@Composable
fun ErrorDialog(type: ErrorDialogType, action: () -> Unit = {}) {
ErrorDialog(
title = type.title, message = type.message, buttonText = type.buttonText, onDismiss = action)
}
@Composable
fun ErrorDialog(
@StringRes title: Int = R.string.error,
@StringRes message: Int,
@StringRes buttonText: Int = R.string.ok,
onDismiss: () -> Unit = {}
) {
AppTheme {
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)
}

@ -1,225 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn.ui.view
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Check
import androidx.compose.material3.Icon
import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
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
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.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(
nav: ExitNodePickerNav,
model: ExitNodePickerViewModel = viewModel(factory = ExitNodePickerViewModelFactory(nav))
) {
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 shouldShowMullvadInfo by model.shouldShowMullvadInfo.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.value
LazyColumn(modifier = Modifier.padding(innerPadding)) {
item(key = "header") {
if (forcedExitNodeId != null) {
Text(
text =
managedByOrganization.value?.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.value == ShowHide.Show) {
Lists.ItemDivider()
RunAsExitNodeItem(nav = nav, viewModel = model, anyActive)
}
}
item(key = "divider1") { Lists.SectionDivider() }
itemsWithDividers(tailnetExitNodes, key = { it.id!! }) { node -> ExitNodeItem(model, node) }
if (mullvadExitNodeCount > 0) {
item(key = "mullvad") {
Lists.SectionDivider()
MullvadItem(
nav, mullvadExitNodesByCountryCode.size, mullvadExitNodesByCountryCode.selected)
}
} else if (shouldShowMullvadInfo) {
item(key = "mullvad_info") {
Lists.SectionDivider()
MullvadInfoItem(nav)
}
}
if (!allowLanAccessMDMDisposition.value.hiddenFromUser) {
item(key = "allowLANAccess") {
Lists.SectionDivider()
Setting.Switch(R.string.allow_lan_access, isOn = allowLANAccess) {
LoadingIndicator.start()
model.toggleAllowLANAccess { LoadingIndicator.stop() }
}
}
}
}
}
}
}
@Composable
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.value
Box {
var modifier: Modifier = Modifier
if (online && !isRunningExitNode && forcedExitNodeId == null) {
modifier = modifier.clickable { viewModel.setExitNode(node) }
}
ListItem(
modifier = modifier,
colors =
if (online && !isRunningExitNode) MaterialTheme.colorScheme.listItem
else MaterialTheme.colorScheme.disabledListItem,
headlineContent = {
Text(node.city.ifEmpty { node.label }, style = MaterialTheme.typography.bodyMedium)
},
supportingContent = {
if (!online)
Text(stringResource(R.string.offline), style = MaterialTheme.typography.bodyMedium)
},
trailingContent = {
Row {
if (node.selected) {
Icon(Icons.Outlined.Check, null)
}
}
})
}
}
@Composable
fun MullvadItem(nav: ExitNodePickerNav, count: Int, selected: Boolean) {
Box {
ListItem(
modifier = Modifier.clickable { nav.onNavigateToMullvad() },
headlineContent = {
Text(
stringResource(R.string.mullvad_exit_nodes),
style = MaterialTheme.typography.bodyMedium)
},
supportingContent = {
Text(
"$count ${stringResource(R.string.countries)}",
style = MaterialTheme.typography.bodyMedium)
},
trailingContent = {
if (selected) {
Icon(Icons.Outlined.Check, null)
}
})
}
}
@Composable
fun MullvadInfoItem(nav: ExitNodePickerNav) {
Box {
ListItem(
modifier = Modifier.clickable { nav.onNavigateToMullvadInfo() },
headlineContent = {
Text(
stringResource(R.string.mullvad_exit_nodes),
style = MaterialTheme.typography.bodyMedium)
},
supportingContent = {
Text(
stringResource(R.string.enable_in_the_admin_console),
style = MaterialTheme.typography.bodyMedium)
})
}
}
@Composable
fun RunAsExitNodeItem(
nav: ExitNodePickerNav,
viewModel: ExitNodePickerViewModel,
anyActive: Boolean
) {
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,
headlineContent = {
Text(
stringResource(id = R.string.run_as_exit_node),
style = MaterialTheme.typography.bodyMedium)
},
supportingContent = {
if (isRunningExitNode) {
Text(stringResource(R.string.enabled))
} else {
Text(stringResource(R.string.disabled))
}
})
}
}

@ -1,99 +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.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon
import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
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.draw.clip
import androidx.compose.ui.res.painterResource
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.ui.model.Health
import com.tailscale.ipn.ui.theme.success
import com.tailscale.ipn.ui.viewModel.HealthViewModel
@Composable
fun HealthView(backToSettings: BackNavigation, model: HealthViewModel = viewModel()) {
val warnings by model.warnings.collectAsState()
Scaffold(topBar = { Header(titleRes = R.string.health_warnings, onBack = backToSettings) }) {
innerPadding ->
LazyColumn(modifier = Modifier.padding(innerPadding)) {
if (warnings.isEmpty()) {
item("allGood") {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp, alignment = Alignment.Top),
modifier = Modifier.fillMaxWidth().padding(vertical = 16.dp)) {
Icon(
painter = painterResource(id = R.drawable.check_circle),
modifier = Modifier.size(48.dp),
contentDescription = "A green checkmark",
tint = MaterialTheme.colorScheme.success)
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement =
Arrangement.spacedBy(2.dp, alignment = Alignment.CenterVertically),
modifier = Modifier.fillMaxWidth()) {
Text(
text = stringResource(R.string.no_issues_found),
fontSize = MaterialTheme.typography.titleMedium.fontSize,
fontWeight = MaterialTheme.typography.titleMedium.fontWeight)
Text(
text = stringResource(R.string.tailscale_is_operating_normally),
color = MaterialTheme.colorScheme.secondary)
}
}
}
}
items(warnings) { HealthWarningView(it) }
}
}
}
@Composable
fun HealthWarningView(warning: Health.UnhealthyState) {
Box(modifier = Modifier.background(color = MaterialTheme.colorScheme.surfaceContainerLow)) {
Box(
modifier =
Modifier.padding(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 8.dp)
.clip(shape = RoundedCornerShape(10.dp, 10.dp, 10.dp, 10.dp))
.fillMaxWidth()) {
ListItem(
colors = warning.Severity.listItemColors(),
headlineContent = {
if (warning.Title.isNotEmpty()) {
Text(
warning.Title,
style = MaterialTheme.typography.titleMedium,
)
}
},
supportingContent = {
Text(warning.Text, style = MaterialTheme.typography.bodyMedium)
})
}
}
}

@ -1,68 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn.ui.view
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
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.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) {
Column(
modifier = Modifier.fillMaxHeight().fillMaxWidth().verticalScroll(rememberScrollState()),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center) {
TailscaleLogoView(modifier = Modifier.width(60.dp).height(60.dp))
Spacer(modifier = Modifier.height(40.dp))
Text(
modifier = Modifier.padding(start = 40.dp, end = 40.dp, bottom = 40.dp),
text = stringResource(R.string.welcome1),
style = MaterialTheme.typography.bodyLarge,
textAlign = TextAlign.Center)
Button(onClick = onContinue) {
Text(
text = stringResource(id = R.string.getStarted),
fontSize = MaterialTheme.typography.titleMedium.fontSize)
}
Spacer(modifier = Modifier.height(40.dp))
Box(
modifier = Modifier.fillMaxHeight().padding(start = 20.dp, end = 20.dp, bottom = 40.dp),
contentAlignment = Alignment.BottomCenter) {
Text(
text = stringResource(R.string.welcome2),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center)
}
}
}
@Composable
@Preview
fun IntroViewPreview() {
AppTheme { Surface { IntroView({}) } }
}

@ -1,88 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn.ui.view
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
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)
@Composable
fun LoginQRView(onDismiss: () -> Unit = {}, model: LoginQRViewModel = viewModel()) {
Surface(color = MaterialTheme.colorScheme.scrim, modifier = Modifier.fillMaxSize()) {
Dialog(onDismissRequest = onDismiss) {
val image by model.qrCode.collectAsState()
val numCode by model.numCode.collectAsState()
Column(
modifier =
Modifier.clip(RoundedCornerShape(10.dp))
.background(MaterialTheme.colorScheme.surfaceContainer)
.padding(20.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
horizontalAlignment = Alignment.CenterHorizontally) {
Text(
text = stringResource(R.string.scan_to_connect_to_your_tailnet),
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface)
Box(
modifier =
Modifier.size(200.dp)
.background(MaterialTheme.colorScheme.onSurface)
.fillMaxWidth(),
contentAlignment = Alignment.Center) {
image?.let {
Image(
bitmap = it,
contentDescription = "Scan to login",
modifier = Modifier.fillMaxSize())
}
}
numCode?.let { it ->
Text(
text = stringResource(R.string.enter_code_to_connect_to_tailnet, it),
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface)
}
Button(onClick = onDismiss) { Text(text = stringResource(R.string.dismiss)) }
}
}
}
}
@Composable
@Preview
fun LoginQRViewPreview() {
val vm = LoginQRViewModel()
vm.qrCode.set(vm.generateQRCode("https://tailscale.com", 200, 0))
vm.numCode.set("123456789")
AppTheme { LoginQRView({}, vm) }
}

@ -1,61 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn.ui.view
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
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
import androidx.lifecycle.viewmodel.compose.viewModel
import com.tailscale.ipn.R
import com.tailscale.ipn.mdm.MDMSetting
import com.tailscale.ipn.mdm.MDMSettings
import com.tailscale.ipn.ui.util.itemsWithDividers
import com.tailscale.ipn.ui.viewModel.IpnViewModel
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MDMSettingsDebugView(
backToSettings: BackNavigation,
@Suppress("UNUSED_PARAMETER") model: IpnViewModel = viewModel()
) {
Scaffold(topBar = { Header(R.string.current_mdm_settings, onBack = backToSettings) }) {
innerPadding ->
LazyColumn(modifier = Modifier.padding(innerPadding)) {
itemsWithDividers(MDMSettings.allSettings.sortedBy { "${it::class.java.name}|${it.key}" }) {
setting ->
MDMSettingView(setting)
}
}
}
}
@Composable
fun MDMSettingView(setting: MDMSetting<*>) {
val value by setting.flow.collectAsState()
ListItem(
headlineContent = { Text(setting.localizedTitle, maxLines = 3) },
supportingContent = {
Text(
setting.key,
fontSize = MaterialTheme.typography.labelSmall.fontSize,
fontFamily = FontFamily.Monospace)
},
trailingContent = {
Text(
if (value.isSet) value.value.toString() else "[not set]",
fontFamily = FontFamily.Monospace,
maxLines = 1,
fontWeight = FontWeight.SemiBold)
})
}

@ -1,813 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
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.combinedClickable
import androidx.compose.foundation.focusable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Clear
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.outlined.ArrowDropDown
import androidx.compose.material.icons.outlined.Lock
import androidx.compose.material.icons.outlined.Settings
import androidx.compose.material3.Button
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.ListItem
import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SearchBar
import androidx.compose.material3.SearchBarDefaults
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
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.SpanStyle
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 com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.tailscale.ipn.App
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
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.itemsWithDividers
import com.tailscale.ipn.ui.util.set
import com.tailscale.ipn.ui.viewModel.IpnViewModel.NodeState
import com.tailscale.ipn.ui.viewModel.MainViewModel
import com.tailscale.ipn.ui.viewModel.VpnViewModel
// Navigation actions for the MainView
data class MainViewNavigation(
val onNavigateToSettings: () -> Unit,
val onNavigateToPeerDetails: (Tailcfg.Node) -> Unit,
val onNavigateToExitNodes: () -> Unit,
val onNavigateToHealth: () -> Unit
)
@OptIn(ExperimentalPermissionsApi::class, ExperimentalMaterial3Api::class)
@Composable
fun MainView(
loginAtUrl: (String) -> Unit,
navigation: MainViewNavigation,
viewModel: MainViewModel
) {
val currentPingDevice by viewModel.pingViewModel.peer.collectAsState()
val healthIcon by viewModel.healthIcon.collectAsState()
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.isVpnPrepared.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 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()
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)
ListItem(
colors = MaterialTheme.colorScheme.surfaceContainerListItem,
leadingContent = {
if (!hideHeader) {
TintedSwitch(
onCheckedChange = {
if (!disableToggle.value) {
viewModel.toggleVpn()
}
},
enabled = !disableToggle.value,
checked = isOn)
}
},
headlineContent = {
user?.NetworkProfile?.DomainName?.let { domain ->
AutoResizingText(
text = domain,
style = MaterialTheme.typography.titleMedium.short,
minFontSize = MaterialTheme.typography.minTextSize,
overflow = TextOverflow.Ellipsis)
}
},
supportingContent = {
if (!hideHeader) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(text = stateStr, style = MaterialTheme.typography.bodyMedium.short)
healthIcon?.let {
Spacer(modifier = Modifier.size(4.dp))
IconButton(
onClick = { navigation.onNavigateToHealth() },
modifier = Modifier.size(16.dp)) {
Icon(
painterResource(id = it),
contentDescription = null,
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.error)
}
}
}
}
},
trailingContent = {
Box(modifier = Modifier.padding(8.dp), contentAlignment = Alignment.CenterEnd) {
when (user) {
null -> SettingsButton { navigation.onNavigateToSettings() }
else -> {
Avatar(profile = user, size = 36, { navigation.onNavigateToSettings() }, isFocusable=true)
}
}
}
})
when (state) {
Ipn.State.Running -> {
PromptPermissionsIfNecessary()
viewModel.showVPNPermissionLauncherIfUnauthorized()
if (showKeyExpiry) {
ExpiryNotification(netmap = netmap, action = { viewModel.login() })
}
if (showExitNodePicker.value == ShowHide.Show) {
ExitNodeStatus(
navAction = navigation.onNavigateToExitNodes, viewModel = viewModel)
}
PeerList(
viewModel = viewModel,
onNavigateToPeerDetails = navigation.onNavigateToPeerDetails,
onSearch = { viewModel.searchPeers(it) })
}
Ipn.State.NoState,
Ipn.State.Starting -> StartingView()
else -> {
ConnectView(
state,
isPrepared,
// If Tailscale is stopping, don't automatically restart; wait for user to take
// action (eg, if the user connected to another VPN).
state != Ipn.State.Stopping,
user,
{ viewModel.toggleVpn() },
{ viewModel.login() },
loginAtUrl,
netmap?.SelfNode,
{ viewModel.showVPNPermissionLauncherIfUnauthorized() })
}
}
}
currentPingDevice?.let { _ ->
ModalBottomSheet(onDismissRequest = { viewModel.onPingDismissal() }) {
PingView(model = viewModel.pingViewModel)
}
}
}
}
}
@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()
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.value?.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.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
fun SettingsButton(action: () -> Unit) {
IconButton(modifier = Modifier.size(24.dp), onClick = { action() }) {
Icon(
Icons.Outlined.Settings,
contentDescription = "Open settings",
tint = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
@Composable
fun StartingView() {
Column(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally) {
TailscaleLogoView(
animated = true, usesOnBackgroundColors = false, Modifier.size(40.dp).alpha(0.3f))
}
}
@Composable
fun ConnectView(
state: Ipn.State,
isPrepared: Boolean,
shouldStartAutomatically: Boolean,
user: IpnLocal.LoginProfile?,
connectAction: () -> Unit,
loginAction: () -> Unit,
loginAtUrlAction: (String) -> Unit,
selfNode: Tailcfg.Node?,
showVPNPermissionLauncherIfUnauthorized: () -> Unit
) {
LaunchedEffect(isPrepared) {
if (!isPrepared && shouldStartAutomatically) {
showVPNPermissionLauncherIfUnauthorized()
}
}
Row(horizontalArrangement = Arrangement.Center, modifier = Modifier.fillMaxWidth()) {
Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier.padding(8.dp).fillMaxWidth(0.7f).fillMaxHeight(),
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()) {
Icon(
painter = painterResource(id = R.drawable.power),
contentDescription = null,
modifier = Modifier.size(40.dp),
tint = MaterialTheme.colorScheme.disabled)
Text(
text = stringResource(id = R.string.not_connected),
fontSize = MaterialTheme.typography.titleMedium.fontSize,
fontWeight = FontWeight.SemiBold,
textAlign = TextAlign.Center,
fontFamily = MaterialTheme.typography.titleMedium.fontFamily)
val tailnetName = user.NetworkProfile?.DomainName ?: ""
Text(
buildAnnotatedString {
append(stringResource(id = R.string.connect_to_tailnet_prefix))
pushStyle(SpanStyle(fontWeight = FontWeight.Bold))
append(tailnetName)
pop()
append(stringResource(id = R.string.connect_to_tailnet_suffix))
},
fontSize = MaterialTheme.typography.titleMedium.fontSize,
fontWeight = FontWeight.Normal,
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 {
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.login_to_join_your_tailnet),
style = MaterialTheme.typography.titleSmall,
textAlign = TextAlign.Center)
Spacer(modifier = Modifier.size(1.dp))
PrimaryActionButton(onClick = loginAction) {
Text(
text = stringResource(id = R.string.log_in),
fontSize = MaterialTheme.typography.titleMedium.fontSize)
}
}
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
@Composable
fun PeerList(
viewModel: MainViewModel,
onNavigateToPeerDetails: (Tailcfg.Node) -> Unit,
onSearch: (String) -> Unit
) {
val peerList by viewModel.peers.collectAsState(initial = emptyList<PeerSet>())
val searchTermStr by viewModel.searchTerm.collectAsState(initial = "")
val showNoResults =
remember { derivedStateOf { searchTermStr.isNotEmpty() && peerList.isEmpty() } }.value
val netmap = viewModel.netmap.collectAsState()
val focusManager = LocalFocusManager.current
var isFocussed by remember { mutableStateOf(false) }
var isListFocussed by remember { mutableStateOf(false) }
val expandedPeer = viewModel.expandedMenuPeer.collectAsState()
val localClipboardManager = LocalClipboardManager.current
val enableSearch = !isAndroidTV()
Column(modifier = Modifier.fillMaxSize()) {
if (enableSearch) {
SearchWithDynamicSuggestions(viewModel, onSearch)
Spacer(modifier = Modifier.height(if (showNoResults) 0.dp else 8.dp))
}
// Peers display
LazyColumn(
modifier =
Modifier.fillMaxWidth()
.weight(1f) // LazyColumn gets the remaining vertical space
.onFocusChanged { isListFocussed = it.isFocused }
.background(color = MaterialTheme.colorScheme.surface)) {
// Handle case when no results are found
if (showNoResults) {
item {
Spacer(
Modifier.height(16.dp)
.fillMaxSize()
.focusable(false)
.background(color = MaterialTheme.colorScheme.surface))
Lists.LargeTitle(
stringResource(id = R.string.no_results),
bottomPadding = 8.dp,
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Light)
}
}
// Iterate over peer sets to display them
var first = true
peerList.forEach { peerSet ->
if (!first) {
item(key = "user_divider_${peerSet.user?.ID ?: 0L}") { Lists.ItemDivider() }
}
first = false
if (isAndroidTV()) {
item { NodesSectionHeader(peerSet = peerSet) }
} else {
stickyHeader { NodesSectionHeader(peerSet = peerSet) }
}
itemsWithDividers(peerSet.peers, key = { it.StableID }) { peer ->
ListItem(
modifier =
Modifier.combinedClickable(
onClick = { onNavigateToPeerDetails(peer) },
onLongClick = { viewModel.expandedMenuPeer.set(peer) }),
colors = MaterialTheme.colorScheme.listItem,
headlineContent = {
Row(verticalAlignment = Alignment.CenterVertically) {
Box(
modifier =
Modifier.padding(top = 2.dp)
.size(10.dp)
.background(
color = peer.connectedColor(netmap.value),
shape = RoundedCornerShape(percent = 50))) {}
Spacer(modifier = Modifier.size(8.dp))
Text(text = peer.displayName, style = MaterialTheme.typography.titleMedium)
DropdownMenu(
expanded = expandedPeer.value?.StableID == peer.StableID,
onDismissRequest = { viewModel.hidePeerDropdownMenu() }) {
DropdownMenuItem(
leadingIcon = {
Icon(
painter = painterResource(R.drawable.clipboard),
contentDescription = null)
},
text = { Text(text = stringResource(R.string.copy_ip_address)) },
onClick = {
viewModel.copyIpAddress(peer, localClipboardManager)
viewModel.hidePeerDropdownMenu()
})
netmap.value?.let { netMap ->
if (!peer.isSelfNode(netMap)) {
DropdownMenuItem(
leadingIcon = {
Icon(
painter = painterResource(R.drawable.timer),
contentDescription = null)
},
text = { Text(text = stringResource(R.string.ping)) },
onClick = {
viewModel.hidePeerDropdownMenu()
viewModel.startPing(peer)
})
}
}
}
}
},
supportingContent = {
Text(
text = peer.Addresses?.first()?.split("/")?.first() ?: "",
style =
MaterialTheme.typography.bodyMedium.copy(
lineHeight = MaterialTheme.typography.titleMedium.lineHeight))
})
}
}
}
}
}
@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
Box(modifier = Modifier.background(color = MaterialTheme.colorScheme.surfaceContainer)) {
Box(
modifier =
Modifier.padding(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 8.dp)
.clip(shape = RoundedCornerShape(10.dp, 10.dp, 10.dp, 10.dp))
.fillMaxWidth()) {
ListItem(
modifier = Modifier.clickable { action() },
colors = MaterialTheme.colorScheme.warningListItem,
headlineContent = {
Text(
netmap.SelfNode.expiryLabel(),
style = MaterialTheme.typography.titleMedium,
)
},
supportingContent = {
Text(
stringResource(id = R.string.keyExpiryExplainer),
style = MaterialTheme.typography.bodyMedium)
})
}
}
}
@OptIn(ExperimentalPermissionsApi::class)
@Composable
fun PromptPermissionsIfNecessary() {
Permissions.prompt.forEach { (permission, state) ->
ErrorDialog(
title = permission.title,
message = permission.description,
buttonText = R.string._continue) {
state.launchPermissionRequest()
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SearchWithDynamicSuggestions(viewModel: MainViewModel, onSearch: (String) -> Unit) {
val searchTerm by viewModel.searchTerm.collectAsState()
val filteredPeers by viewModel.peers.collectAsState()
var expanded by rememberSaveable { mutableStateOf(false) }
val netmap by viewModel.netmap.collectAsState()
val keyboardController = LocalSoftwareKeyboardController.current
val focusRequester = remember { FocusRequester() }
val focusManager = LocalFocusManager.current
Column(
modifier =
Modifier.fillMaxWidth().focusRequester(focusRequester).clickable {
focusRequester.requestFocus()
keyboardController?.show()
}) {
SearchBar(
modifier = Modifier.fillMaxWidth().align(Alignment.CenterHorizontally),
inputField = {
SearchBarDefaults.InputField(
query = searchTerm,
onQueryChange = { query ->
viewModel.updateSearchTerm(query)
onSearch(query)
expanded = query.isNotEmpty()
},
onSearch = { query ->
viewModel.updateSearchTerm(query)
onSearch(query)
expanded = false
},
expanded = expanded,
onExpandedChange = { expanded = it },
placeholder = { Text("Search") },
leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) },
trailingIcon = {
if (expanded) {
IconButton(
onClick = {
viewModel.updateSearchTerm("")
onSearch("")
expanded = false
focusManager.clearFocus()
keyboardController?.hide()
}) {
Icon(Icons.Default.Clear, contentDescription = "Clear search")
}
}
})
},
expanded = expanded,
onExpandedChange = { expanded = it },
content = {
// Search results or suggestions
Column(Modifier.verticalScroll(rememberScrollState()).fillMaxSize()) {
filteredPeers.forEach { peerSet ->
val userName = peerSet.user?.DisplayName ?: "Unknown User"
peerSet.peers.forEach { peer ->
val deviceName = peer.displayName ?: "Unknown Device"
val ipAddress = peer.Addresses?.firstOrNull()?.split("/")?.first() ?: "No IP"
ListItem(
headlineContent = { Text(userName) },
supportingContent = {
Column {
Row(verticalAlignment = Alignment.CenterVertically) {
val onlineColor = peer.connectedColor(netmap)
Box(
modifier =
Modifier.size(10.dp)
.background(onlineColor, shape = RoundedCornerShape(50)))
Spacer(modifier = Modifier.size(8.dp))
Text(deviceName)
}
Text(ipAddress)
}
},
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
modifier =
Modifier.clickable {
viewModel.updateSearchTerm(userName)
onSearch(userName)
expanded = false
focusManager.clearFocus()
keyboardController?.hide()
}
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 4.dp))
}
}
}
})
}
}
@Preview
@Composable
fun MainViewPreview() {
val vpnViewModel = VpnViewModel(App.get())
val vm = MainViewModel(vpnViewModel)
MainView(
{},
MainViewNavigation(
onNavigateToSettings = {},
onNavigateToPeerDetails = {},
onNavigateToExitNodes = {},
onNavigateToHealth = {}),
vm)
}

@ -1,58 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn.ui.view
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.safeContentPadding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
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
import com.tailscale.ipn.mdm.MDMSettings
import com.tailscale.ipn.ui.viewModel.IpnViewModel
@Suppress("UNUSED_PARAMETER")
@Composable
fun ManagedByView(backToSettings: BackNavigation, model: IpnViewModel = viewModel()) {
Scaffold(topBar = { Header(R.string.managed_by, onBack = backToSettings) }) { _ ->
Column(
verticalArrangement =
Arrangement.spacedBy(space = 20.dp, alignment = Alignment.CenterVertically),
horizontalAlignment = Alignment.Start,
modifier =
Modifier.fillMaxWidth().safeContentPadding().verticalScroll(rememberScrollState())) {
val managedByOrganization =
MDMSettings.managedByOrganizationName.flow.collectAsState().value.value
val managedByCaption = MDMSettings.managedByCaption.flow.collectAsState().value.value
val managedByURL = MDMSettings.managedByURL.flow.collectAsState().value.value
managedByOrganization?.let {
Text(stringResource(R.string.managed_by_explainer_orgName, it))
} ?: run { Text(stringResource(R.string.managed_by_explainer)) }
managedByCaption?.let {
if (it.isNotEmpty()) {
Text(it)
}
}
managedByURL?.let { OpenURLButton(stringResource(R.string.open_support), it) }
}
}
}
@Preview
@Composable
fun ManagedByViewPreview() {
val vm = IpnViewModel()
ManagedByView(backToSettings = {}, vm)
}

@ -1,67 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn.ui.view
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material3.ExperimentalMaterial3Api
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
import com.tailscale.ipn.R
import com.tailscale.ipn.ui.util.Lists
import com.tailscale.ipn.ui.util.LoadingIndicator
import com.tailscale.ipn.ui.util.flag
import com.tailscale.ipn.ui.util.itemsWithDividers
import com.tailscale.ipn.ui.viewModel.ExitNodePickerNav
import com.tailscale.ipn.ui.viewModel.ExitNodePickerViewModel
import com.tailscale.ipn.ui.viewModel.ExitNodePickerViewModelFactory
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MullvadExitNodePicker(
countryCode: String,
nav: ExitNodePickerNav,
model: ExitNodePickerViewModel = viewModel(factory = ExitNodePickerViewModelFactory(nav))
) {
val mullvadExitNodes by model.mullvadExitNodesByCountryCode.collectAsState()
val bestAvailableByCountry by model.mullvadBestAvailableByCountry.collectAsState()
mullvadExitNodes[countryCode]?.toList()?.let { nodes ->
val any = nodes.first()
LoadingIndicator.Wrap {
Scaffold(
topBar = {
Header(
title = { Text("${countryCode.flag()} ${any.country}") },
onBack = nav.onNavigateBackToMullvad)
}) { innerPadding ->
LazyColumn(modifier = Modifier.padding(innerPadding)) {
if (nodes.size > 1) {
val bestAvailableNode = bestAvailableByCountry[countryCode]!!
item {
ExitNodeItem(
model,
ExitNodePickerViewModel.ExitNode(
id = bestAvailableNode.id,
label = stringResource(R.string.best_available),
online = bestAvailableNode.online,
selected = false,
))
Lists.SectionDivider()
}
}
itemsWithDividers(nodes) { node -> ExitNodeItem(model, node) }
}
}
}
}
}

@ -1,99 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn.ui.view
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Check
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
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
import com.tailscale.ipn.R
import com.tailscale.ipn.ui.util.LoadingIndicator
import com.tailscale.ipn.ui.util.flag
import com.tailscale.ipn.ui.util.itemsWithDividers
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
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MullvadExitNodePickerList(
nav: ExitNodePickerNav,
model: ExitNodePickerViewModel = viewModel(factory = ExitNodePickerViewModelFactory(nav))
) {
LoadingIndicator.Wrap {
Scaffold(
topBar = {
Header(R.string.choose_mullvad_exit_node, onBack = nav.onNavigateBackToExitNodes)
}) { innerPadding ->
val mullvadExitNodes by model.mullvadExitNodesByCountryCode.collectAsState()
LazyColumn(modifier = Modifier.padding(innerPadding)) {
val sortedCountries =
mullvadExitNodes.entries.toList().sortedBy {
it.value.first().country.lowercase()
}
itemsWithDividers(sortedCountries) { (countryCode, nodes) ->
val first = nodes.first()
// TODO(oxtoacart): the modifier on the ListItem occasionally causes a crash
// with java.lang.ClassCastException: androidx.compose.ui.ComposedModifier cannot be
// cast
// to androidx.compose.runtime.RecomposeScopeImpl
// Wrapping it in a Box eliminates this. It appears to be some kind of
// interaction between the LazyList and the modifier.
Box {
ListItem(
modifier =
Modifier.clickable {
if (nodes.size > 1) {
nav.onNavigateToMullvadCountry(countryCode)
} else {
model.setExitNode(first)
}
},
leadingContent = {
Text(
countryCode.flag(),
style = MaterialTheme.typography.titleLarge,
)
},
headlineContent = {
Text(first.country, style = MaterialTheme.typography.bodyMedium)
},
supportingContent = {
Text(
if (nodes.size == 1) first.city
else "${nodes.size} ${stringResource(R.string.cities_available)}",
style = MaterialTheme.typography.bodyMedium)
},
trailingContent = {
if (nodes.size > 1 && nodes.selected || first.selected) {
if (nodes.selected) {
Icon(
Icons.Outlined.Check,
contentDescription = stringResource(R.string.selected))
}
}
})
}
}
}
}
}
}

@ -1,56 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn.ui.view
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
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.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.tailscale.ipn.R
import com.tailscale.ipn.ui.viewModel.ExitNodePickerNav
@Composable
fun MullvadInfoView(nav: ExitNodePickerNav) {
Scaffold(
topBar = {
Header(R.string.choose_mullvad_exit_node, onBack = nav.onNavigateBackToExitNodes)
}) { innerPadding ->
LazyColumn(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(20.dp),
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 48.dp),
modifier = Modifier.padding(innerPadding)) {
item {
Image(
painter = painterResource(id = R.drawable.mullvad_logo),
contentDescription = stringResource(R.string.the_mullvad_vpn_logo))
}
item {
Text(
stringResource(R.string.mullvad_info_title),
fontFamily = MaterialTheme.typography.titleLarge.fontFamily,
fontSize = MaterialTheme.typography.titleLarge.fontSize,
fontWeight = FontWeight.SemiBold)
}
item {
Text(
stringResource(R.string.mullvad_info_explainer),
color = MaterialTheme.colorScheme.secondary,
textAlign = TextAlign.Center)
}
}
}
}

@ -1,154 +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.clickable
import androidx.compose.foundation.focusable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
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.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.unit.dp
import 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
import com.tailscale.ipn.ui.viewModel.PeerDetailsViewModelFactory
import com.tailscale.ipn.ui.viewModel.PingViewModel
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun PeerDetails(
backToHome: BackNavigation,
nodeId: String,
pingViewModel: PingViewModel,
model: PeerDetailsViewModel =
viewModel(
factory =
PeerDetailsViewModelFactory(nodeId, LocalContext.current.filesDir, pingViewModel))
) {
val isPinging by model.isPinging.collectAsState()
model.netmap.collectAsState().value?.let { netmap ->
model.node.collectAsState().value?.let { node ->
Scaffold(
topBar = {
Header(
title = {
Column {
Text(
text = node.displayName,
style = MaterialTheme.typography.titleMedium.short,
color = MaterialTheme.colorScheme.onSurface)
Row(verticalAlignment = Alignment.CenterVertically) {
Box(
modifier =
Modifier.size(8.dp)
.background(
color = node.connectedColor(netmap),
shape = RoundedCornerShape(percent = 50))) {}
Spacer(modifier = Modifier.size(8.dp))
Text(
text = stringResource(id = node.connectedStrRes(netmap)),
style = MaterialTheme.typography.bodyMedium.short,
color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
},
actions = {
IconButton(onClick = { model.startPing() }) {
Icon(
painter = painterResource(R.drawable.timer),
contentDescription = "Ping device")
}
},
onBack = backToHome)
},
) { innerPadding ->
LazyColumn(
modifier = Modifier.padding(innerPadding),
) {
item(key = "tailscaleAddresses") {
Lists.MutedHeader(stringResource(R.string.tailscale_addresses))
}
itemsWithDividers(node.displayAddresses, key = { it.address }) {
AddressRow(address = it.address, type = it.typeString)
}
item(key = "infoDivider") { Lists.SectionDivider() }
itemsWithDividers(node.info, key = { "info_${it.titleRes}" }) {
ValueRow(title = stringResource(id = it.titleRes), value = it.value.getString())
}
}
if (isPinging) {
ModalBottomSheet(onDismissRequest = { model.onPingDismissal() }) {
PingView(model = model.pingViewModel)
}
}
}
}
}
}
@Composable
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,
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)
}
})
}
@Composable
fun ValueRow(title: String, value: String) {
ListItem(
colors = MaterialTheme.colorScheme.listItem,
headlineContent = { Text(text = title) },
supportingContent = { Text(text = value) })
}

@ -1,66 +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.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import com.tailscale.ipn.ui.model.Ipn
import com.tailscale.ipn.ui.model.Tailcfg
import com.tailscale.ipn.ui.theme.off
import com.tailscale.ipn.ui.theme.on
@Composable
fun PeerView(
peer: Tailcfg.Node,
selfPeer: String? = null,
stateVal: Ipn.State? = null,
subtitle: () -> String = { peer.primaryIPv4Address ?: peer.primaryIPv6Address ?: "" },
onClick: (Tailcfg.Node) -> Unit = {},
trailingContent: @Composable () -> Unit = {}
) {
val disabled = !(peer.Online ?: false)
val textColor = if (disabled) MaterialTheme.colorScheme.onSurfaceVariant else Color.Unspecified
ListItem(
modifier = Modifier.clickable { onClick(peer) },
headlineContent = {
Row(verticalAlignment = Alignment.CenterVertically) {
// By definition, SelfPeer is online since we will not show the peer list
// unless you're connected.
val isSelfAndRunning = (peer.StableID == selfPeer && stateVal == Ipn.State.Running)
val color: Color =
if ((peer.Online == true) || isSelfAndRunning) {
MaterialTheme.colorScheme.on
} else {
MaterialTheme.colorScheme.off
}
Box(
modifier =
Modifier.size(8.dp)
.background(color = color, shape = RoundedCornerShape(percent = 50))) {}
Spacer(modifier = Modifier.size(8.dp))
Text(
text = peer.displayName,
style = MaterialTheme.typography.titleMedium,
color = textColor)
}
},
supportingContent = {
Text(text = subtitle(), style = MaterialTheme.typography.bodyMedium, color = textColor)
},
trailingContent = trailingContent)
}

@ -1,55 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn.ui.view
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material3.Icon
import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.tailscale.ipn.R
import com.tailscale.ipn.ui.model.Permissions
import com.tailscale.ipn.ui.theme.success
import com.tailscale.ipn.ui.util.itemsWithDividers
@OptIn(ExperimentalPermissionsApi::class)
@Composable
fun PermissionsView(backToSettings: BackNavigation, openApplicationSettings: () -> Unit) {
val permissions = Permissions.withGrantedStatus
Scaffold(topBar = { Header(titleRes = R.string.permissions, onBack = backToSettings) }) {
innerPadding ->
LazyColumn(modifier = Modifier.padding(innerPadding)) {
itemsWithDividers(permissions) { (permission, granted) ->
ListItem(
modifier = Modifier.clickable { openApplicationSettings() },
leadingContent = {
Icon(
if (granted) painterResource(R.drawable.check_circle)
else painterResource(R.drawable.xmark_circle),
tint =
if (granted) MaterialTheme.colorScheme.success
else MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(24.dp),
contentDescription =
stringResource(if (granted) R.string.ok else R.string.warning))
},
headlineContent = {
Text(stringResource(permission.title), style = MaterialTheme.typography.titleMedium)
},
supportingContent = { Text(stringResource(permission.description)) },
)
}
}
}
}

@ -1,204 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn.ui.view
// TODO(angott): must mention usage of com.patrykandpatrick.vico library in LICENSES
import android.graphics.Typeface
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.SizeTransform
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.patrykandpatrick.vico.compose.axis.axisGuidelineComponent
import com.patrykandpatrick.vico.compose.axis.horizontal.rememberBottomAxis
import com.patrykandpatrick.vico.compose.axis.vertical.rememberStartAxis
import com.patrykandpatrick.vico.compose.chart.Chart
import com.patrykandpatrick.vico.compose.chart.line.lineChart
import com.patrykandpatrick.vico.compose.component.shapeComponent
import com.patrykandpatrick.vico.compose.component.textComponent
import com.patrykandpatrick.vico.compose.dimensions.dimensionsOf
import com.patrykandpatrick.vico.compose.m3.style.m3ChartStyle
import com.patrykandpatrick.vico.compose.style.ProvideChartStyle
import com.patrykandpatrick.vico.compose.style.currentChartStyle
import com.patrykandpatrick.vico.core.axis.AxisItemPlacer
import com.patrykandpatrick.vico.core.chart.copy
import com.patrykandpatrick.vico.core.entry.FloatEntry
import com.patrykandpatrick.vico.core.entry.entryModelOf
import com.tailscale.ipn.R
import com.tailscale.ipn.ui.model.Tailcfg
import com.tailscale.ipn.ui.util.ConnectionMode
import com.tailscale.ipn.ui.viewModel.PingViewModel
import java.text.DecimalFormat
@Composable
fun PingView(model: PingViewModel = viewModel()) {
val connectionMode: ConnectionMode by
model.connectionMode.collectAsState(initial = ConnectionMode.NotConnected())
val peer: Tailcfg.Node? by model.peer.collectAsState()
val lastLatencyValue: String by model.lastLatencyValue.collectAsState()
val pingValues: List<Double> by model.latencyValues.collectAsState()
val chartEntryModel =
entryModelOf(
pingValues.withIndex().map { FloatEntry((it.index + 1).toFloat(), it.value.toFloat()) })
val errorMessage: String? by model.errorMessage.collectAsState()
Column(
verticalArrangement = Arrangement.spacedBy(16.dp),
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp).padding(bottom = 36.dp)) {
Row {
Column {
Text(
stringResource(R.string.pinging_node_name, peer?.ComputedName ?: "???"),
fontStyle = MaterialTheme.typography.titleLarge.fontStyle,
fontWeight = FontWeight.Bold)
if (pingValues.isNotEmpty()) {
AnimatedContent(targetState = connectionMode, contentKey = { it.contentKey() }) {
targetConnectionMode ->
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
Icon(
painter = painterResource(id = targetConnectionMode.iconDrawable()),
contentDescription = null,
tint = targetConnectionMode.color())
Text(
targetConnectionMode.titleString(),
fontSize = MaterialTheme.typography.bodySmall.fontSize,
color = targetConnectionMode.color())
}
}
}
}
AnimatedContent(
targetState = lastLatencyValue,
transitionSpec = {
// The new value slides down and fades in, while the previous value slides down
// and fades out.
(slideInVertically { height -> -height } + fadeIn())
.togetherWith(slideOutVertically { height -> height } + fadeOut())
.using(SizeTransform(clip = false))
}) { latency ->
Text(
latency,
fontFamily = FontFamily.Monospace,
fontSize = MaterialTheme.typography.titleLarge.fontSize,
textAlign = TextAlign.Right,
modifier = Modifier.fillMaxWidth())
}
}
if (pingValues.isNotEmpty()) {
ProvideChartStyle(chartStyle = m3ChartStyle()) {
val defaultLines = currentChartStyle.lineChart.lines
val circlePoint =
shapeComponent(
shape = CircleShape,
color = MaterialTheme.colorScheme.background,
strokeColor = MaterialTheme.colorScheme.surfaceTint,
strokeWidth = 2.dp)
Chart(
chart =
lineChart(
remember(defaultLines) {
defaultLines.map { defaultLine ->
defaultLine.copy(point = circlePoint, pointSizeDp = 10.0F)
}
},
spacing = 0.dp,
),
model = chartEntryModel,
startAxis =
rememberStartAxis(
valueFormatter = { value, _ ->
DecimalFormat("#;#").format(value) + " ms"
},
itemPlacer = remember { AxisItemPlacer.Vertical.default(maxItemCount = 5) },
label =
textComponent(
color = MaterialTheme.colorScheme.secondary,
typeface = Typeface.MONOSPACE,
padding = dimensionsOf(end = 8.dp)),
guideline =
axisGuidelineComponent(
color = MaterialTheme.colorScheme.secondaryContainer)),
bottomAxis =
rememberBottomAxis(
itemPlacer = remember { AxisItemPlacer.Horizontal.default(spacing = 1) },
label =
textComponent(
color = MaterialTheme.colorScheme.secondary,
typeface = Typeface.MONOSPACE,
),
guideline =
axisGuidelineComponent(
color = MaterialTheme.colorScheme.secondaryContainer)),
)
}
} else {
errorMessage?.also { error ->
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement =
Arrangement.spacedBy(6.dp, alignment = Alignment.CenterVertically),
modifier = Modifier.fillMaxWidth().height(200.dp)) {
Icon(
painter = painterResource(id = R.drawable.warning),
modifier = Modifier.size(48.dp),
contentDescription = null,
tint = Color.Red)
Text(
stringResource(id = R.string.pingFailed),
fontSize = MaterialTheme.typography.titleMedium.fontSize,
fontFamily = MaterialTheme.typography.titleMedium.fontFamily,
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center,
color = Color.Red)
Text(
error,
textAlign = TextAlign.Center,
color = Color.Red,
)
}
}
?: run {
Column(
modifier = Modifier.fillMaxWidth().height(200.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally) {
TailscaleLogoView(
true, usesOnBackgroundColors = false, Modifier.size(36.dp).alpha(0.4f))
}
}
}
}
}
fun Double.roundedString(decimals: Int): String = "%.${decimals}f".format(this)

@ -1,119 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn.ui.view
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.fillMaxHeight
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.outlined.ArrowForward
import androidx.compose.material3.Button
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
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
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
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
@Composable
fun RunExitNodeView(nav: ExitNodePickerNav, model: IpnViewModel = viewModel()) {
val isRunningExitNode by model.isRunningExitNode.collectAsState()
Scaffold(
topBar = { Header(R.string.run_as_exit_node, onBack = nav.onNavigateBackToExitNodes) }) {
innerPadding ->
LoadingIndicator.Wrap {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement =
Arrangement.spacedBy(24.dp, alignment = Alignment.CenterVertically),
modifier =
Modifier.padding(innerPadding)
.padding(24.dp)
.fillMaxHeight()
.verticalScroll(rememberScrollState())) {
RunExitNodeGraphic()
if (isRunningExitNode) {
Text(
stringResource(R.string.running_as_exit_node),
fontFamily = MaterialTheme.typography.titleLarge.fontFamily,
fontSize = MaterialTheme.typography.titleLarge.fontSize,
fontWeight = FontWeight.SemiBold)
Text(stringResource(R.string.run_exit_node_explainer_running))
} else {
Text(
stringResource(R.string.run_this_device_as_an_exit_node),
fontFamily = MaterialTheme.typography.titleLarge.fontFamily,
fontSize = MaterialTheme.typography.titleLarge.fontSize,
fontWeight = FontWeight.SemiBold)
Text(stringResource(R.string.run_exit_node_explainer))
}
Text(stringResource(R.string.run_exit_node_caution))
Button(onClick = { model.setRunningExitNode(!isRunningExitNode) }) {
if (isRunningExitNode) {
Text(stringResource(R.string.stop_running_as_exit_node))
} else {
Text(stringResource(R.string.start_running_as_exit_node))
}
}
Spacer(modifier = Modifier.size(24.dp))
}
}
}
}
@Composable
fun RunExitNodeGraphic() {
@Composable
fun ArrowForward() {
Icon(
Icons.AutoMirrored.Outlined.ArrowForward,
"Arrow Forward",
modifier = Modifier.size(24.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant)
}
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(vertical = 18.dp)) {
Icon(
painter = painterResource(id = R.drawable.computer),
"Computer icon",
tint = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.size(36.dp))
ArrowForward()
Icon(
painter = painterResource(id = R.drawable.android),
"Android icon",
tint = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.size(36.dp))
ArrowForward()
Icon(
painter = painterResource(id = R.drawable.globe),
"Globe icon",
tint = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.size(36.dp))
}
}

@ -1,214 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn.ui.view
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
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
import androidx.compose.ui.res.stringResource
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
import com.tailscale.ipn.ui.viewModel.VpnViewModel
import com.tailscale.ipn.ui.notifier.Notifier
import com.tailscale.ipn.ui.util.AndroidTVUtil
import com.tailscale.ipn.ui.util.AppVersion
@Composable
fun SettingsView(settingsNav: SettingsNav, viewModel: SettingsViewModel = viewModel(), vpnViewModel: VpnViewModel = viewModel()) {
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 vpnViewModel.vpnPrepared.collectAsState()
val showTailnetLock by MDMSettings.manageTailnetLock.flow.collectAsState()
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)
}
if (isAdmin && !isAndroidTV()) {
Lists.ItemDivider()
AdminTextView { handler.openUri(Links.ADMIN_URL) }
}
Lists.SectionDivider()
Setting.Text(
R.string.dns_settings,
subtitle =
corpDNSEnabled?.let {
stringResource(
if (it) R.string.using_tailscale_dns else R.string.not_using_tailscale_dns)
},
onClick = settingsNav.onNavigateToDNSSettings)
Lists.ItemDivider()
Setting.Text(
R.string.split_tunneling,
subtitle = stringResource(R.string.exclude_certain_apps_from_using_tailscale),
onClick = settingsNav.onNavigateToSplitTunneling)
if (showTailnetLock.value == 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)
}
if (!AndroidTVUtil.isAndroidTV()){
Lists.ItemDivider()
Setting.Text(R.string.permissions, onClick = settingsNav.onNavigateToPermissions)
}
managedByOrganization.value?.let {
Lists.ItemDivider()
Setting.Text(
title = stringResource(R.string.managed_by_orgName, it),
onClick = settingsNav.onNavigateToManagedBy)
}
Lists.SectionDivider()
Setting.Text(R.string.bug_report, onClick = settingsNav.onNavigateToBugReport)
Lists.ItemDivider()
Setting.Text(
R.string.about_tailscale,
subtitle = "${stringResource(id = R.string.version)} ${AppVersion.Short()}",
onClick = settingsNav.onNavigateToAbout)
// TODO: put a heading for the debug section
if (BuildConfig.DEBUG) {
Lists.SectionDivider()
Lists.MutedHeader(text = stringResource(R.string.internal_debug_options))
Setting.Text(R.string.mdm_settings, onClick = settingsNav.onNavigateToMDMSettings)
}
}
}
}
object Setting {
@Composable
fun Text(
titleRes: Int = 0,
title: String? = null,
subtitle: String? = null,
destructive: Boolean = false,
enabled: Boolean = true,
onClick: (() -> Unit)? = null
) {
var modifier: Modifier = Modifier
if (enabled) {
onClick?.let { modifier = modifier.clickable(onClick = it) }
}
ListItem(
modifier = modifier,
colors = MaterialTheme.colorScheme.listItem,
headlineContent = {
Text(
title ?: stringResource(titleRes),
style = MaterialTheme.typography.bodyMedium,
color = if (destructive) MaterialTheme.colorScheme.error else Color.Unspecified)
},
supportingContent =
subtitle?.let {
{
Text(
it,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant)
}
})
}
@Composable
fun Switch(
titleRes: Int = 0,
title: String? = null,
isOn: Boolean,
enabled: Boolean = true,
onToggle: (Boolean) -> Unit = {}
) {
ListItem(
colors = MaterialTheme.colorScheme.listItem,
headlineContent = {
Text(
title ?: stringResource(titleRes),
style = MaterialTheme.typography.bodyMedium,
)
},
trailingContent = {
TintedSwitch(checked = isOn, onCheckedChange = onToggle, enabled = enabled)
})
}
}
@Composable
fun AdminTextView(onNavigateToAdminConsole: () -> Unit) {
val adminStr = buildAnnotatedString {
append(stringResource(id = R.string.settings_admin_prefix))
pushStringAnnotation(tag = "link", annotation = Links.ADMIN_URL)
withStyle(
style =
SpanStyle(
color = MaterialTheme.colorScheme.link,
textDecoration = TextDecoration.Underline)) {
append(stringResource(id = R.string.settings_admin_link))
}
}
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)
}

@ -1,104 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn.ui.view
import androidx.annotation.StringRes
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.ripple
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
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
// Header view for all secondary screens
// @see TopAppBar actions for additional actions (usually a row of icons)
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun Header(
@StringRes titleRes: Int = 0,
title: (@Composable () -> Unit)? = null,
actions: @Composable RowScope.() -> Unit = {},
onBack: (() -> Unit)? = null
) {
val f = FocusRequester()
if (isAndroidTV()) {
LaunchedEffect(Unit) { f.requestFocus() }
}
TopAppBar(
title = {
title?.let { title() }
?: Text(
stringResource(titleRes),
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurface)
},
colors = MaterialTheme.colorScheme.topAppBar,
actions = actions,
navigationIcon = { onBack?.let { BackArrow(action = it, focusRequester = f) } },
)
}
@Composable
fun BackArrow(action: () -> Unit, focusRequester: FocusRequester) {
Box(modifier = Modifier.padding(start = 8.dp, end = 8.dp)) {
Icon(
Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Go back to the previous screen",
modifier =
Modifier.focusRequester(focusRequester)
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = ripple(bounded = false),
onClick = { action() }))
}
}
@Composable
fun CheckedIndicator() {
Icon(Icons.Default.CheckCircle, null, tint = ts_color_light_blue)
}
@Composable
fun SimpleActivityIndicator(size: Int = 32) {
CircularProgressIndicator(
modifier = Modifier.width(size.dp),
)
}
@Composable
fun ActivityIndicator(progress: Double, size: Int = 32) {
LinearProgressIndicator(
progress = { progress.toFloat() },
modifier = Modifier.width(size.dp),
color = ts_color_light_blue,
trackColor = MaterialTheme.colorScheme.secondary,
)
}

@ -1,111 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn.ui.view
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.Checkbox
import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
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.asImageBitmap
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.core.graphics.drawable.toBitmap
import androidx.lifecycle.viewmodel.compose.viewModel
import com.tailscale.ipn.App
import com.tailscale.ipn.R
import com.tailscale.ipn.ui.util.Lists
import com.tailscale.ipn.ui.viewModel.SplitTunnelAppPickerViewModel
@Composable
fun SplitTunnelAppPickerView(
backToSettings: BackNavigation,
model: SplitTunnelAppPickerViewModel = viewModel()
) {
val installedApps by model.installedApps.collectAsState()
val excludedPackageNames by model.excludedPackageNames.collectAsState()
val builtInDisallowedPackageNames: List<String> = App.get().builtInDisallowedPackageNames
val mdmIncludedPackages by model.mdmIncludedPackages.collectAsState()
val mdmExcludedPackages by model.mdmExcludedPackages.collectAsState()
Scaffold(topBar = { Header(titleRes = R.string.split_tunneling, onBack = backToSettings) }) {
innerPadding ->
LazyColumn(modifier = Modifier.padding(innerPadding)) {
item(key = "header") {
ListItem(
headlineContent = {
Text(
stringResource(
R.string
.selected_apps_will_access_the_internet_directly_without_using_tailscale))
})
}
if (mdmExcludedPackages.value?.isNotEmpty() == true) {
item("mdmExcludedNotice") {
ListItem(
headlineContent = {
Text(stringResource(R.string.certain_apps_are_not_routed_via_tailscale))
})
}
} else if (mdmIncludedPackages.value?.isNotEmpty() == true) {
item("mdmIncludedNotice") {
ListItem(
headlineContent = {
Text(stringResource(R.string.only_specific_apps_are_routed_via_tailscale))
})
}
} else {
item("resolversHeader") {
Lists.SectionDivider(
stringResource(R.string.count_excluded_apps, excludedPackageNames.count()))
}
items(installedApps) { app ->
ListItem(
headlineContent = { Text(app.name, fontWeight = FontWeight.SemiBold) },
leadingContent = {
Image(
bitmap =
model.installedAppsManager.packageManager
.getApplicationIcon(app.packageName)
.toBitmap()
.asImageBitmap(),
contentDescription = null,
modifier = Modifier.width(40.dp).height(40.dp))
},
supportingContent = {
Text(
app.packageName,
color = MaterialTheme.colorScheme.secondary,
fontSize = MaterialTheme.typography.bodySmall.fontSize,
letterSpacing = MaterialTheme.typography.bodySmall.letterSpacing)
},
trailingContent = {
Checkbox(
checked = excludedPackageNames.contains(app.packageName),
enabled = !builtInDisallowedPackageNames.contains(app.packageName),
onCheckedChange = { checked ->
if (checked) {
model.exclude(packageName = app.packageName)
} else {
model.unexclude(packageName = app.packageName)
}
})
})
Lists.ItemDivider()
}
}
}
}
}

@ -1,199 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn.ui.view
import android.text.format.Formatter
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.WindowInsets
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
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
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import coil.compose.AsyncImage
import com.tailscale.ipn.R
import com.tailscale.ipn.ui.model.Ipn
import com.tailscale.ipn.ui.model.Tailcfg
import com.tailscale.ipn.ui.util.Lists.SectionDivider
import com.tailscale.ipn.ui.util.set
import com.tailscale.ipn.ui.viewModel.TaildropViewModel
import com.tailscale.ipn.ui.viewModel.TaildropViewModelFactory
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.StateFlow
@Composable
fun TaildropView(
requestedTransfers: StateFlow<List<Ipn.OutgoingFile>>,
applicationScope: CoroutineScope,
viewModel: TaildropViewModel =
viewModel(factory = TaildropViewModelFactory(requestedTransfers, applicationScope))
) {
Scaffold(
contentWindowInsets = WindowInsets.Companion.statusBars,
topBar = { Header(R.string.share) }) { paddingInsets ->
val showDialog = viewModel.showDialog.collectAsState().value
// Show the error overlay
showDialog?.let { ErrorDialog(type = it, action = { viewModel.showDialog.set(null) }) }
Column(modifier = Modifier.padding(paddingInsets)) {
FileShareHeader(
fileTransfers = requestedTransfers.collectAsState().value,
totalSize = viewModel.totalSize)
when (viewModel.state.collectAsState().value) {
Ipn.State.Running -> {
val peers by viewModel.myPeers.collectAsState()
val context = LocalContext.current
FileSharePeerList(
peers = peers,
stateViewGenerator = { peerId ->
viewModel.TrailingContentForPeer(peerId = peerId)
},
onShare = { viewModel.share(context, it) })
}
else -> {
FileShareConnectView { viewModel.startVPN() }
}
}
}
}
}
@Composable
fun FileSharePeerList(
peers: List<Tailcfg.Node>,
stateViewGenerator: @Composable (String) -> Unit,
onShare: (Tailcfg.Node) -> Unit
) {
SectionDivider(stringResource(R.string.my_devices))
when (peers.isEmpty()) {
true -> {
Column(
modifier = Modifier.padding(horizontal = 8.dp).fillMaxHeight(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally) {
Text(
stringResource(R.string.no_devices_to_share_with),
style = MaterialTheme.typography.titleMedium)
}
}
false -> {
LazyColumn {
peers.forEach { peer ->
item {
PeerView(
peer = peer,
onClick = { onShare(peer) },
subtitle = { peer.Hostinfo.OS ?: "" },
trailingContent = { stateViewGenerator(peer.StableID) })
}
}
}
}
}
}
@Composable
fun FileShareConnectView(onToggle: () -> Unit) {
Column(
modifier = Modifier.padding(horizontal = 16.dp).fillMaxHeight(),
verticalArrangement = Arrangement.spacedBy(6.dp, alignment = Alignment.CenterVertically),
horizontalAlignment = Alignment.CenterHorizontally) {
Text(
stringResource(R.string.connect_to_your_tailnet_to_share_files),
style = MaterialTheme.typography.titleMedium)
Spacer(modifier = Modifier.size(1.dp))
PrimaryActionButton(onClick = onToggle) {
Text(
text = stringResource(id = R.string.connect),
fontSize = MaterialTheme.typography.titleMedium.fontSize)
}
}
}
@Composable
fun FileShareHeader(fileTransfers: List<Ipn.OutgoingFile>, totalSize: Long) {
Column(modifier = Modifier.padding(horizontal = 12.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
IconForTransfer(fileTransfers)
Column(modifier = Modifier.padding(horizontal = 8.dp)) {
when (fileTransfers.isEmpty()) {
true ->
Text(
stringResource(R.string.no_files_to_share),
style = MaterialTheme.typography.titleMedium)
false -> {
when (fileTransfers.size) {
1 -> Text(fileTransfers[0].Name, style = MaterialTheme.typography.titleMedium)
else ->
Text(
stringResource(R.string.file_count, fileTransfers.size),
style = MaterialTheme.typography.titleMedium)
}
}
}
val size = Formatter.formatFileSize(LocalContext.current, totalSize.toLong())
Text(
size,
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.secondary)
}
}
}
}
@Composable
fun IconForTransfer(transfers: List<Ipn.OutgoingFile>) {
// (jonathan) TODO: Thumbnails?
when (transfers.size) {
0 ->
Icon(
painter = painterResource(R.drawable.warning),
contentDescription = "no files",
modifier = Modifier.size(32.dp))
1 -> {
// Show a thumbnail for single image shares.
val context = LocalContext.current
context.contentResolver.getType(transfers[0].uri)?.let {
if (it.startsWith("image/")) {
AsyncImage(
model = transfers[0].uri,
contentDescription = "one file",
modifier = Modifier.size(40.dp))
return
}
Icon(
painter = painterResource(R.drawable.single_file),
contentDescription = "files",
modifier = Modifier.size(40.dp))
}
}
else ->
Icon(
painter = painterResource(R.drawable.single_file),
contentDescription = "files",
modifier = Modifier.size(40.dp))
}
}

@ -1,140 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn.ui.view
import androidx.compose.foundation.LocalIndication
import androidx.compose.foundation.clickable
import androidx.compose.foundation.focusable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.text.ClickableText
import androidx.compose.material3.Icon
import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
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.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
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
@Composable
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")
Scaffold(topBar = { Header(R.string.tailnet_lock, onBack = backToSettings) }) { innerPadding ->
LoadingIndicator.Wrap {
LazyColumn(modifier = Modifier.padding(innerPadding).fillMaxSize()) {
item { ExplainerView() }
items(statusItems) { statusItem ->
val interactionSource = remember { MutableInteractionSource() }
ListItem(
modifier =
Modifier.focusable(
interactionSource = interactionSource)
.clickable(
interactionSource = interactionSource,
indication = LocalIndication.current
) {},
leadingContent = {
Icon(
painter = painterResource(id = statusItem.icon),
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant)
},
headlineContent = { Text(stringResource(statusItem.title)) })
}
item {
// Node key section
Lists.SectionDivider()
ClipboardValueView(
value = nodeKey,
title = stringResource(R.string.node_key),
subtitle = stringResource(R.string.node_key_explainer))
// Tailnet lock key section
Lists.SectionDivider()
ClipboardValueView(
value = tailnetLockTlPubKey,
title = stringResource(R.string.tailnet_lock_key),
subtitle = stringResource(R.string.tailnet_lock_key_explainer))
}
}
}
}
}
@Composable
private fun ExplainerView() {
val handler = LocalUriHandler.current
Lists.MultilineDescription {
ClickableText(
explainerText(),
onClick = { handler.openUri(Links.TAILNET_LOCK_KB_URL) },
style = MaterialTheme.typography.bodyMedium)
}
}
@Composable
fun explainerText(): AnnotatedString {
return buildAnnotatedString {
withStyle(SpanStyle(color = MaterialTheme.colorScheme.defaultTextColor)) {
append(stringResource(id = R.string.tailnet_lock_explainer))
}
pushStringAnnotation(tag = "tailnetLockSupportURL", annotation = Links.TAILNET_LOCK_KB_URL)
withStyle(
style =
SpanStyle(
color = MaterialTheme.colorScheme.link,
textDecoration = TextDecoration.Underline)) {
append(stringResource(id = R.string.learn_more))
}
pop()
}
}
@Composable
@Preview
fun TailnetLockSetupViewPreview() {
val vm = TailnetLockSetupViewModel()
vm.nodeKey.set("8BADF00D-EA7-1337-DEAD-BEEF")
vm.tailnetLockKey.set("C0FFEE-CAFE-50DA")
TailnetLockSetupView(backToSettings = {}, vm)
}

@ -1,154 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn.ui.view
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.size
import androidx.compose.material3.MaterialTheme
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
import kotlin.concurrent.timer
// DotsMatrix represents the state of the progress indicator.
typealias DotsMatrix = List<List<Boolean>>
// The initial DotsMatrix that represents the Tailscale logo (T-shaped).
val logoDotsMatrix: DotsMatrix =
listOf(
listOf(false, false, false),
listOf(true, true, true),
listOf(false, true, false),
)
@Composable
fun TailscaleLogoView(
animated: Boolean = false,
usesOnBackgroundColors: 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 currentDotsMatrix: StateFlow<DotsMatrix> = MutableStateFlow(logoDotsMatrix)
var currentDotsMatrixIndex = 0
fun advanceToNextMatrix() {
currentDotsMatrixIndex = (currentDotsMatrixIndex + 1) % gameOfLife.size
val newMatrix =
if (animated) {
gameOfLife[currentDotsMatrixIndex]
} else {
logoDotsMatrix
}
currentDotsMatrix.set(newMatrix)
}
if (animated) {
timer(period = 300L) { advanceToNextMatrix() }
}
@Composable
fun EnabledDot(modifier: Modifier) {
Canvas(modifier = modifier, onDraw = { drawCircle(primaryColor) })
}
@Composable
fun DisabledDot(modifier: Modifier) {
Canvas(modifier = modifier, onDraw = { drawCircle(secondaryColor) })
}
BoxWithConstraints(modifier) {
val currentMatrix = currentDotsMatrix.collectAsState().value
Column(verticalArrangement = Arrangement.spacedBy(this@BoxWithConstraints.maxWidth.div(8))) {
for (y in 0..2) {
Row(horizontalArrangement = Arrangement.spacedBy(this@BoxWithConstraints.maxWidth.div(8))) {
for (x in 0..2) {
if (currentMatrix[y][x]) {
EnabledDot(Modifier.size(this@BoxWithConstraints.maxWidth.div(4)))
} else {
DisabledDot(Modifier.size(this@BoxWithConstraints.maxWidth.div(4)))
}
}
}
}
}
}
}
val gameOfLife: List<DotsMatrix> =
listOf(
listOf(
listOf(false, true, true),
listOf(true, false, true),
listOf(false, false, true),
),
listOf(
listOf(false, true, true),
listOf(false, false, true),
listOf(false, true, false),
),
listOf(
listOf(false, true, true),
listOf(false, false, false),
listOf(false, false, true),
),
listOf(
listOf(false, false, true),
listOf(false, true, false),
listOf(false, false, false),
),
listOf(
listOf(false, true, false),
listOf(false, false, false),
listOf(false, false, false),
),
listOf(
listOf(false, false, false),
listOf(false, false, true),
listOf(false, false, false),
),
listOf(
listOf(false, false, false),
listOf(false, false, false),
listOf(false, false, false),
),
listOf(
listOf(false, false, true),
listOf(false, false, false),
listOf(false, false, false),
),
listOf(
listOf(false, false, false),
listOf(false, false, false),
listOf(true, false, false),
),
listOf(listOf(false, false, false), listOf(false, false, false), listOf(true, true, false)),
listOf(listOf(false, false, false), listOf(true, false, false), listOf(true, true, false)),
listOf(listOf(false, false, false), listOf(true, true, false), listOf(false, true, false)),
listOf(listOf(false, false, false), listOf(true, true, false), listOf(false, true, true)),
listOf(listOf(false, false, false), listOf(true, true, true), listOf(false, false, true)),
listOf(listOf(false, true, false), listOf(true, true, true), listOf(true, false, true)))

@ -1,12 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn.ui.view
import androidx.compose.material3.Switch
import androidx.compose.runtime.Composable
@Composable
fun TintedSwitch(checked: Boolean, onCheckedChange: ((Boolean) -> Unit)?, enabled: Boolean = true) {
Switch(checked = checked, onCheckedChange = onCheckedChange, enabled = enabled)
}

@ -1,193 +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.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
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.runtime.mutableStateOf
import androidx.compose.runtime.remember
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
import com.tailscale.ipn.ui.util.Lists
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()) {
val users by viewModel.loginProfiles.collectAsState()
val currentUser by viewModel.loggedInUser.collectAsState()
val showHeaderMenu by viewModel.showHeaderMenu.collectAsState()
Scaffold(
topBar = {
Header(
R.string.accounts,
onBack = nav.backToSettings,
actions = {
Row {
FusMenu(
viewModel = viewModel,
onAuthKeyClick = nav.onNavigateToAuthKey,
onCustomClick = nav.onNavigateCustomControl)
IconButton(onClick = { viewModel.showHeaderMenu.set(!showHeaderMenu) }) {
Icon(Icons.Default.MoreVert, "menu")
}
}
})
}) { innerPadding ->
Column(
modifier = Modifier.padding(innerPadding).fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(8.dp)) {
val showErrorDialog by viewModel.errorDialog.collectAsState()
// Show the error overlay if need be
showErrorDialog?.let {
ErrorDialog(type = it, action = { viewModel.errorDialog.set(null) })
}
// When switch is invoked, this stores the ID of the user we're trying to switch to
// so we can decorate it with a spinner. The actual logged in user will not change
// until
// we get our first netmap update back with the new userId for SelfNode.
// (jonathan) TODO: This user switch is not immediate. We may need to represent the
// "switching users" state globally (if ipnState is insufficient)
val nextUserId = remember { mutableStateOf<String?>(null) }
LazyColumn {
itemsWithDividers(users ?: emptyList()) { user ->
if (user.ID == currentUser?.ID) {
UserView(profile = user, actionState = UserActionState.CURRENT)
} else {
val state =
if (user.ID == nextUserId.value) UserActionState.SWITCHING
else UserActionState.NONE
UserView(
profile = user,
actionState = state,
onClick = {
nextUserId.value = user.ID
viewModel.switchProfile(user) {
if (it.isFailure) {
viewModel.errorDialog.set(ErrorDialogType.LOGOUT_FAILED)
nextUserId.value = null
} else {
nav.onNavigateHome()
}
}
})
}
}
item {
Lists.SectionDivider()
Setting.Text(R.string.add_account) {
viewModel.addProfile {
if (it.isFailure) {
viewModel.errorDialog.set(ErrorDialogType.ADD_PROFILE_FAILED)
}
}
}
Lists.ItemDivider()
Setting.Text(R.string.reauthenticate) { viewModel.login() }
if (currentUser != null) {
Lists.ItemDivider()
Setting.Text(
R.string.log_out,
destructive = true,
onClick = {
viewModel.logout {
it.onSuccess { nav.onNavigateHome() }
.onFailure {
viewModel.errorDialog.set(ErrorDialogType.LOGOUT_FAILED)
}
}
})
}
}
}
}
}
}
@Composable
fun FusMenu(
onCustomClick: () -> Unit,
onAuthKeyClick: () -> Unit,
viewModel: UserSwitcherViewModel
) {
val expanded by viewModel.showHeaderMenu.collectAsState()
DropdownMenu(
expanded = expanded,
onDismissRequest = { 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))
}
}
@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,102 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn.ui.view
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.offset
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
import androidx.compose.material3.Icon
import androidx.compose.material3.ListItem
import androidx.compose.material3.ListItemColors
import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.tailscale.ipn.R
import com.tailscale.ipn.ui.model.IpnLocal
import com.tailscale.ipn.ui.theme.minTextSize
import com.tailscale.ipn.ui.theme.short
import com.tailscale.ipn.ui.util.AutoResizingText
// Used to decorate UserViews.
// NONE indicates no decoration
// CURRENT indicates the user is the current user and will be "checked"
// SWITCHING indicates the user is being switched to and will be "loading"
// NAV will show a chevron
enum class UserActionState {
CURRENT,
SWITCHING,
NAV,
NONE
}
@Composable
fun UserView(
profile: IpnLocal.LoginProfile?,
onClick: (() -> Unit)? = null,
colors: ListItemColors = ListItemDefaults.colors(),
actionState: UserActionState = UserActionState.NONE,
) {
Box {
var modifier: Modifier = Modifier
onClick?.let { modifier = modifier.clickable { it() } }
profile?.let {
ListItem(
modifier = modifier,
colors = colors,
leadingContent = { Avatar(profile = profile, size = 36) },
headlineContent = {
AutoResizingText(
text = profile.UserProfile.LoginName,
style = MaterialTheme.typography.titleMedium.short,
minFontSize = MaterialTheme.typography.minTextSize,
overflow = TextOverflow.Ellipsis)
},
supportingContent = {
Column {
AutoResizingText(
text = profile.NetworkProfile?.DomainName ?: "",
style = MaterialTheme.typography.bodyMedium.short,
minFontSize = MaterialTheme.typography.minTextSize,
overflow = TextOverflow.Ellipsis)
profile.customControlServerHostname()?.let {
AutoResizingText(
text = it,
style = MaterialTheme.typography.bodyMedium.short,
minFontSize = MaterialTheme.typography.minTextSize,
overflow = TextOverflow.Ellipsis)
}
}
},
trailingContent = {
when (actionState) {
UserActionState.CURRENT -> CheckedIndicator()
UserActionState.SWITCHING -> SimpleActivityIndicator(size = 26)
UserActionState.NAV ->
Icon(
Icons.AutoMirrored.Filled.KeyboardArrowRight, null, Modifier.offset(x = 6.dp))
UserActionState.NONE -> Unit
}
})
}
?: run {
ListItem(
modifier = modifier,
colors = colors,
headlineContent = {
Text(
text = stringResource(id = R.string.accounts),
style = MaterialTheme.typography.titleMedium)
})
}
}
}

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

Loading…
Cancel
Save