diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index aa3152c..f7ec869 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -6,7 +6,7 @@ on: - main pull_request: branches: - - '*' + - "*" jobs: build: @@ -15,20 +15,23 @@ jobs: if: "!contains(github.event.head_commit.message, '[ci skip]')" steps: + - name: Check out code + uses: actions/checkout@v3 + - uses: actions/setup-go@v5 + with: + go-version-file: "go.mod" + - name: Switch to Java 17 # Note: 17 is pre-installed on ubuntu-latest + uses: actions/setup-java@v3 + with: + distribution: "temurin" + java-version: "17" - - name: Check out code - uses: actions/checkout@v3 - - uses: actions/setup-go@v5 - with: - go-version-file: 'go.mod' - - name: Switch to Java 17 # Note: 17 is pre-installed on ubuntu-latest - uses: actions/setup-java@v3 - with: - distribution: 'temurin' - java-version: '17' + # Clean should essentially be a no-op, but make sure that it works. + - name: Clean + run: make clean - - name: Build APKs - run: make tailscale-debug.apk + - name: Build APKs + run: make tailscale-debug.apk - - name: Run tests - run: make test \ No newline at end of file + - name: Run tests + run: make test diff --git a/.gitignore b/.gitignore index aab9616..dd0dde5 100644 --- a/.gitignore +++ b/.gitignore @@ -38,3 +38,5 @@ tailscale.jks libtailscale.aar libtailscale-sources.jar .DS_Store + +tailscale.version diff --git a/Makefile b/Makefile index d6d7185..2524860 100644 --- a/Makefile +++ b/Makefile @@ -8,27 +8,16 @@ # The docker image to use for the build environment. Changing this # will force a rebuild of the docker image. If there is an existing image # with this name, it will be used. -DOCKER_IMAGE=tailscale-android-build-amd64 +# +# The convention here is tailscale-android-build-amd64- +DOCKER_IMAGE=tailscale-android-build-amd64-191124 +export TS_USE_TOOLCHAIN=1 DEBUG_APK=tailscale-debug.apk RELEASE_AAB=tailscale-release.aab RELEASE_TV_AAB=tailscale-tv-release.aab LIBTAILSCALE=android/libs/libtailscale.aar -TAILSCALE_VERSION=$(shell ./version/tailscale-version.sh 200) -OUR_VERSION=$(shell git describe --dirty --exclude "*" --always --abbrev=200) -TAILSCALE_VERSION_ABBREV=$(shell ./version/tailscale-version.sh 11) -OUR_VERSION_ABBREV=$(shell git describe --exclude "*" --always --abbrev=11) -VERSION_LONG=$(TAILSCALE_VERSION_ABBREV)-g$(OUR_VERSION_ABBREV) -# Extract the long version build.gradle's versionName and strip quotes. -VERSIONNAME=$(patsubst "%",%,$(lastword $(shell grep versionName android/build.gradle))) -# Extract the x.y.z part for the short version. -VERSIONNAME_SHORT=$(shell echo $(VERSIONNAME) | cut -d - -f 1) -TAILSCALE_COMMIT=$(shell echo $(TAILSCALE_VERSION) | cut -d - -f 2 | cut -d t -f 2) # Extract the version code from build.gradle. -VERSIONCODE=$(lastword $(shell grep versionCode android/build.gradle)) -VERSIONCODE_PLUSONE=$(shell expr $(VERSIONCODE) + 1) -VERSION_LDFLAGS=-X tailscale.com/version.longStamp=$(VERSIONNAME) -X tailscale.com/version.shortStamp=$(VERSIONNAME_SHORT) -X tailscale.com/version.gitCommitStamp=$(TAILSCALE_COMMIT) -X tailscale.com/version.extraGitCommitStamp=$(OUR_VERSION) -FULL_LDFLAGS=$(VERSION_LDFLAGS) -w ifeq ($(shell uname),Linux) ANDROID_TOOLS_URL="https://dl.google.com/android/repository/commandlinetools-linux-9477386_latest.zip" ANDROID_TOOLS_SUM="bd1aa17c7ef10066949c88dc6c9c8d536be27f992a1f3b5a584f9bd2ba5646a0 commandlinetools-linux-9477386_latest.zip" @@ -69,6 +58,23 @@ else export PATH := $(JAVA_HOME)/bin:$(PATH) endif +AVD_BASE_IMAGE := "system-images;android-33;google_apis;" +export HOST_ARCH=$(shell uname -m) +ifeq ($(HOST_ARCH),aarch64) + AVD_IMAGE := "$(AVD_BASE_IMAGE)arm64-v8a" +else ifeq ($(HOST_ARCH),arm64) + AVD_IMAGE := "$(AVD_BASE_IMAGE)arm64-v8a" +else + AVD_IMAGE := "$(AVD_BASE_IMAGE)x86_64" +endif +AVD ?= tailscale-$(HOST_ARCH) +export AVD_IMAGE +export AVD + +# Use our toolchain or the one that is specified, do not perform dynamic toolchain switching. +GOTOOLCHAIN=local +export GOTOOLCHAIN + # TOOLCHAINDIR is set by fdoid CI and used by tool/* scripts. TOOLCHAINDIR ?= export TOOLCHAINDIR @@ -91,36 +97,43 @@ tailscale-debug: $(DEBUG_APK) ## Build the debug APK # Builds the release AAB and signs it (phone/tablet/chromeOS variant) .PHONY: release -release: update-version jarsign-env $(RELEASE_AAB) ## Build the release AAB +release: jarsign-env $(RELEASE_AAB) ## Build the release AAB @jarsigner -sigalg SHA256withRSA -digestalg SHA-256 -keystore $(JKS_PATH) -storepass $(JKS_PASSWORD) $(RELEASE_AAB) tailscale # Builds the release AAB and signs it (androidTV variant) .PHONY: release-tv -release-tv: update-version jarsign-env $(RELEASE_TV_AAB) ## Build the release AAB +release-tv: jarsign-env $(RELEASE_TV_AAB) ## Build the release AAB @jarsigner -sigalg SHA256withRSA -digestalg SHA-256 -keystore $(JKS_PATH) -storepass $(JKS_PASSWORD) $(RELEASE_TV_AAB) tailscale # gradle-dependencies groups together the android sources and libtailscale needed to assemble tests/debug/release builds. .PHONY: gradle-dependencies -gradle-dependencies: $(shell find android -type f -not -path "android/build/*" -not -path '*/.*') $(LIBTAILSCALE) +gradle-dependencies: $(shell find android -type f -not -path "android/build/*" -not -path '*/.*') $(LIBTAILSCALE) tailscale.version -$(DEBUG_APK): gradle-dependencies +$(DEBUG_APK): version gradle-dependencies (cd android && ./gradlew test assembleDebug) install -C android/build/outputs/apk/debug/android-debug.apk $@ -$(RELEASE_AAB): gradle-dependencies +$(RELEASE_AAB): version gradle-dependencies @echo "Building release AAB" (cd android && ./gradlew test bundleRelease) install -C ./android/build/outputs/bundle/release/android-release.aab $@ -$(RELEASE_TV_AAB): gradle-dependencies +$(RELEASE_TV_AAB): version gradle-dependencies @echo "Building TV release AAB" (cd android && ./gradlew test bundleRelease_tv) install -C ./android/build/outputs/bundle/release_tv/android-release_tv.aab $@ -tailscale-test.apk: gradle-dependencies +tailscale-test.apk: version gradle-dependencies (cd android && ./gradlew assembleApplicationTestAndroidTest) install -C ./android/build/outputs/apk/androidTest/applicationTest/android-applicationTest-androidTest.apk $@ +tailscale.version: go.mod go.sum $(wildcard .git/HEAD) + $(shell ./tool/go run tailscale.com/cmd/mkversion > tailscale.version) + +.PHONY: version +version: tailscale.version ## print the current version information + cat tailscale.version + # # Go Builds: # @@ -129,14 +142,15 @@ android/libs: mkdir -p android/libs $(GOBIN)/gomobile: $(GOBIN)/gobind go.mod go.sum - go install golang.org/x/mobile/cmd/gomobile + ./tool/go install golang.org/x/mobile/cmd/gomobile $(GOBIN)/gobind: go.mod go.sum - go install golang.org/x/mobile/cmd/gobind + ./tool/go install golang.org/x/mobile/cmd/gobind -$(LIBTAILSCALE): Makefile android/libs $(shell find libtailscale -name *.go) go.mod go.sum $(GOBIN)/gomobile - gomobile bind -target android -androidapi 26 \ - -ldflags "$(FULL_LDFLAGS)" \ +$(LIBTAILSCALE): Makefile android/libs $(shell find libtailscale -name *.go) go.mod go.sum $(GOBIN)/gomobile tailscale.version + $(GOBIN)/gomobile bind -target android -androidapi 26 \ + -tags "$$(./build-tags.sh)" \ + -ldflags "-w $$(./version-ldflags.sh)" \ -o $@ ./libtailscale .PHONY: libtailscale @@ -157,6 +171,7 @@ env: @echo ANDROID_STUDIO_ROOT=$(ANDROID_STUDIO_ROOT) @echo JAVA_HOME=$(JAVA_HOME) @echo TOOLCHAINDIR=$(TOOLCHAINDIR) + @echo AVD_IMAGE="$(AVD_IMAGE)" # Ensure that JKS_PATH and JKS_PASSWORD are set before we attempt a build # that requires signing. @@ -180,28 +195,25 @@ androidpath: @echo 'export PATH=$(ANDROID_HOME)/cmdline-tools/latest/bin:$(ANDROID_HOME)/platform-tools:$$PATH' .PHONY: tag_release -tag_release: ## Tag the current commit with the current version - git tag -a "$(VERSION_LONG)" -m "OSS and Version updated to ${VERSION_LONG}" +tag_release: tailscale.version ## Tag the current commit with the current version + source tailscale.version && git tag -a "$${VERSION_LONG}" -m "OSS and Version updated to $${VERSION_LONG}" -.PHONY: bumposs ## Bump to the latest oss and update teh versions. -bumposs: update-oss update-version - git commit -sm "android: bumping OSS" -m "OSS and Version updated to ${VERSION_LONG}" android/build.gradle go.mod go.sum - git tag -a "$(VERSION_LONG)" -m "OSS and Version updated to ${VERSION_LONG}" +.PHONY: bumposs ## Bump to the latest oss and update the versions. +bumposs: update-oss tailscale.version + source tailscale.version && git commit -sm "android: bump OSS" -m "OSS and Version updated to $${VERSION_LONG}" go.toolchain.rev android/build.gradle go.mod go.sum + source tailscale.version && git tag -a "$${VERSION_LONG}" -m "OSS and Version updated to $${VERSION_LONG}" .PHONY: bump_version_code bump_version_code: ## Bump the version code in build.gradle - sed -i'.bak' 's/versionCode .*/versionCode $(VERSIONCODE_PLUSONE)/' android/build.gradle && rm android/build.gradle.bak - -.PHONY: update-version -update-version: ## Update the version in build.gradle - sed -i'.bak' 's/versionName .*/versionName "$(VERSION_LONG)"/' android/build.gradle && rm android/build.gradle.bak + sed -i'.bak' "s/versionCode .*/versionCode $$(expr $$(awk '/versionCode ([0-9]+)/{print $$2}' android/build.gradle) + 1)/" android/build.gradle && rm android/build.gradle.bak .PHONY: update-oss -update-oss: ## Update the tailscale.com go module and update the version in build.gradle - GOPROXY=direct go get tailscale.com@main - go run tailscale.com/cmd/printdep --go > go.toolchain.rev - go mod tidy -compat=1.22 +update-oss: ## Update the tailscale.com go module + GOPROXY=direct ./tool/go get tailscale.com@main + ./tool/go mod tidy -compat=1.23 + ./tool/go run tailscale.com/cmd/printdep --go > go.toolchain.rev.new + mv go.toolchain.rev.new go.toolchain.rev # Get the commandline tools package, this provides (among other things) the sdkmanager binary. $(ANDROID_HOME)/cmdline-tools/latest/bin/sdkmanager: @@ -235,6 +247,21 @@ checkandroidsdk: ## Check that Android SDK is installed test: gradle-dependencies ## Run the Android tests (cd android && ./gradlew test) +.PHONY: emulator +emulator: ## Start an android emulator instance + @echo "Checking installed SDK packages..." + @if ! $(ANDROID_HOME)/cmdline-tools/latest/bin/sdkmanager --list_installed | grep -q "$(AVD_IMAGE)"; then \ + echo "$(AVD_IMAGE) not found, installing..."; \ + $(ANDROID_HOME)/cmdline-tools/latest/bin/sdkmanager "$(AVD_IMAGE)"; \ + fi + @echo "Checking if AVD exists..." + @if ! $(ANDROID_HOME)/cmdline-tools/latest/bin/avdmanager list avd | grep -q "$(AVD)"; then \ + echo "AVD $(AVD) not found, creating..."; \ + $(ANDROID_HOME)/cmdline-tools/latest/bin/avdmanager create avd -n "$(AVD)" -k "$(AVD_IMAGE)"; \ + fi + @echo "Starting emulator..." + @$(ANDROID_HOME)/emulator/emulator -avd "$(AVD)" -logcat-output /dev/stdout -netdelay none -netspeed full + .PHONY: install install: $(DEBUG_APK) ## Install the debug APK on a connected device adb install -r $< @@ -265,7 +292,7 @@ docker-all: docker-build-image docker-run-build $(DOCKER_IMAGE) .PHONY: docker-shell docker-shell: ## Builds a docker image with the android build env and opens a shell docker build -f docker/DockerFile.amd64-shell -t tailscale-android-shell-amd64 . - docker run -v --rm $(CURDIR):/build/tailscale-android -it tailscale-android-shell-amd64 + docker run --rm -v $(CURDIR):/build/tailscale-android -it tailscale-android-shell-amd64 .PHONY: docker-remove-shell-image docker-remove-shell-image: ## Removes all docker shell image @@ -273,8 +300,12 @@ docker-remove-shell-image: ## Removes all docker shell image .PHONY: clean clean: ## Remove build artifacts. Does not purge docker build envs. Use dockerRemoveEnv for that. + @echo "Cleaning up old build artifacts" -rm -rf android/build $(DEBUG_APK) $(RELEASE_AAB) $(RELEASE_TV_AAB) $(LIBTAILSCALE) android/libs *.apk *.aab + @echo "Cleaning cached toolchain" + -rm -rf $(HOME)/.cache/tailscale-go{,.extracted} -pkill -f gradle + -rm tailscale.version .PHONY: help help: ## Show this help diff --git a/README.md b/README.md index ca3fd2b..e072061 100644 --- a/README.md +++ b/README.md @@ -63,13 +63,13 @@ and XML files in Android Studio. Enable "Format on Save". If you wish to avoid installing software on your host system, a Docker based development strategy is available, you can build and start a shell with: ```sh -make dockershell +make docker-shell ``` Several other makefile recipes are available for setting up the proper build environment and running builds. -Note that the docker makefile recipes s will preserve the image and remove container on completion. -If changes are made to the build environment or toolchain, cached docker images may need to be rebuilt. +Note that the docker makefile recipes s will preserve the image and remove container on completion. +If changes are made to the build environment or toolchain, cached docker images may need to be rebuilt. The docker build image name is parameterized in the makefile and changing it provides a simple means to do this. ### Nix diff --git a/android/build.gradle b/android/build.gradle index f405d1e..a1accd7 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -11,7 +11,7 @@ buildscript { } } dependencies { - classpath 'com.android.tools.build:gradle:8.5.1' + classpath 'com.android.tools.build:gradle:8.6.1' classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" classpath "org.jetbrains.kotlin:kotlin-serialization:$kotlin_version" classpath("com.ncorti.ktfmt.gradle:plugin:0.17.0") @@ -37,8 +37,17 @@ android { defaultConfig { minSdkVersion 26 targetSdkVersion 34 - versionCode 241 - versionName "1.73.13-taf3d3c433-g536e1adcc42" + versionCode 242 + versionName getVersionProperty("VERSION_LONG") + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + + // This setting, which defaults to 'true', will cause Tailscale to fall + // back to the Google DNS servers if it cannot determine what the + // operating system's DNS configuration is. + // + // Set it to false either here or in your local.properties file to + // disable this behaviour. + buildConfigField "boolean", "USE_GOOGLE_DNS_FALLBACK", getLocalProperty("tailscale.useGoogleDnsFallback", "true") } compileOptions { @@ -46,6 +55,10 @@ android { targetCompatibility JavaVersion.VERSION_17 } + lintOptions { + warningsAsErrors true + } + kotlinOptions { jvmTarget = "17" } @@ -54,6 +67,7 @@ android { jvmTarget = "17" } buildFeatures { + buildConfig true compose true } composeOptions { @@ -66,9 +80,9 @@ android { applicationTest { initWith debug manifestPlaceholders.leanbackRequired = false - buildConfigField "String", "GITHUB_USERNAME", "\"" + getLocalProperty("githubUsername")+"\"" - buildConfigField "String", "GITHUB_PASSWORD", "\"" + getLocalProperty("githubPassword")+"\"" - buildConfigField "String", "GITHUB_2FA_SECRET", "\"" + getLocalProperty("github2FASecret")+"\"" + buildConfigField "String", "GITHUB_USERNAME", "\"" + getLocalProperty("githubUsername", "")+"\"" + buildConfigField "String", "GITHUB_PASSWORD", "\"" + getLocalProperty("githubPassword", "")+"\"" + buildConfigField "String", "GITHUB_2FA_SECRET", "\"" + getLocalProperty("github2FASecret", "")+"\"" } debug { manifestPlaceholders.leanbackRequired = false @@ -99,7 +113,7 @@ dependencies { implementation 'androidx.core:core-ktx:1.13.1' implementation "androidx.browser:browser:1.8.0" implementation "androidx.security:security-crypto:1.1.0-alpha06" - implementation "androidx.work:work-runtime:2.9.0" + implementation "androidx.work:work-runtime:2.9.1" // Kotlin dependencies. implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.3" @@ -110,20 +124,21 @@ dependencies { implementation "org.jetbrains.kotlin:kotlin-reflect:$kotlin_version" // Compose dependencies. - def composeBom = platform('androidx.compose:compose-bom:2024.06.00') + def composeBom = platform('androidx.compose:compose-bom:2024.09.03') implementation composeBom - implementation 'androidx.compose.material3:material3:1.2.1' - implementation 'androidx.compose.material:material-icons-core:1.6.8' - implementation "androidx.compose.ui:ui:1.6.8" - implementation "androidx.compose.ui:ui-tooling:1.6.8" - implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.8.3' - implementation 'androidx.activity:activity-compose:1.9.0' + implementation 'androidx.compose.material3:material3:1.3.0' + implementation 'androidx.compose.material:material-icons-core:1.7.3' + implementation "androidx.compose.ui:ui:1.7.3" + implementation "androidx.compose.ui:ui-tooling:1.7.3" + implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.8.6' + implementation 'androidx.activity:activity-compose:1.9.2' implementation "com.google.accompanist:accompanist-permissions:$accompanist_version" implementation "com.google.accompanist:accompanist-systemuicontroller:$accompanist_version" implementation "androidx.core:core-splashscreen:1.1.0-rc01" + implementation "androidx.compose.animation:animation:1.7.4" // Navigation dependencies. - def nav_version = "2.7.7" + def nav_version = "2.8.2" implementation "androidx.navigation:navigation-compose:$nav_version" implementation "androidx.navigation:navigation-ui-ktx:$nav_version" @@ -138,7 +153,7 @@ dependencies { // Integration Tests androidTestImplementation composeBom - androidTestImplementation 'androidx.test:runner:1.6.1' + androidTestImplementation 'androidx.test:runner:1.6.2' androidTestImplementation 'androidx.test.ext:junit-ktx:1.2.1' androidTestImplementation 'androidx.test.ext:junit:1.2.1' androidTestImplementation 'androidx.test.espresso:espresso-core:3.6.1' @@ -151,17 +166,30 @@ dependencies { // Unit Tests testImplementation 'junit:junit:4.13.2' + testImplementation 'org.mockito:mockito-core:5.12.0' + testImplementation 'org.mockito:mockito-inline:5.2.0' + testImplementation 'org.mockito.kotlin:mockito-kotlin:5.4.0' debugImplementation("androidx.compose.ui:ui-tooling") implementation("androidx.compose.ui:ui-tooling-preview") } -def getLocalProperty(key) { +def getLocalProperty(key, defaultValue) { try { Properties properties = new Properties() properties.load(project.file('local.properties').newDataInputStream()) - return properties.getProperty(key) + return properties.getProperty(key) ?: defaultValue } catch(Throwable ignored) { - return "" + return defaultValue } } + + +def getVersionProperty(key) { + // tailscale.version is created / updated by the makefile, it is in a loosely + // Makfile/envfile format, which is also loosely a properties file format. + // make tailscale.version + def versionProps = new Properties() + versionProps.load(project.file('../tailscale.version').newDataInputStream()) + return versionProps.getProperty(key).replaceAll('^\"|\"$', '') +} diff --git a/android/proguard-rules.pro b/android/proguard-rules.pro index 0d39ac1..566ece6 100644 --- a/android/proguard-rules.pro +++ b/android/proguard-rules.pro @@ -3,6 +3,13 @@ native ; } +# Keep Tailcale classes for debuggability, but especially +# keep the classes with syspolicy MDM keys, some of which +# get used only by the Go backend. (The second rule is redundant, +# but explicit.) +-keep class com.tailscale.ipn.** { *; } +-keep class com.tailscale.ipn.mdm.** { *; } + # Keep specific classes from Tink library -keep class com.google.crypto.tink.** { *; } @@ -18,4 +25,4 @@ # Keep Joda-Time classes -keep class org.joda.time.** { *; } --dontwarn org.joda.time.** \ No newline at end of file +-dontwarn org.joda.time.** diff --git a/android/src/androidTest/kotlin/com/tailscale/ipn/MainActivityTest.kt b/android/src/androidTest/kotlin/com/tailscale/ipn/MainActivityTest.kt index 0a7fe5c..be76734 100644 --- a/android/src/androidTest/kotlin/com/tailscale/ipn/MainActivityTest.kt +++ b/android/src/androidTest/kotlin/com/tailscale/ipn/MainActivityTest.kt @@ -17,6 +17,11 @@ import androidx.test.uiautomator.UiSelector import dev.turingcomplete.kotlinonetimepassword.HmacAlgorithm import dev.turingcomplete.kotlinonetimepassword.TimeBasedOneTimePasswordConfig import dev.turingcomplete.kotlinonetimepassword.TimeBasedOneTimePasswordGenerator +import java.net.HttpURLConnection +import java.net.URL +import java.util.concurrent.TimeUnit +import kotlin.time.Duration.Companion.minutes +import kotlin.time.Duration.Companion.seconds import org.apache.commons.codec.binary.Base32 import org.junit.After import org.junit.Assert @@ -24,11 +29,6 @@ import org.junit.Before import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith -import java.net.HttpURLConnection -import java.net.URL -import java.util.concurrent.TimeUnit -import kotlin.time.Duration.Companion.minutes -import kotlin.time.Duration.Companion.seconds @RunWith(AndroidJUnit4::class) @LargeTest @@ -59,18 +59,18 @@ class MainActivityTest { timeStep = 30, timeStepUnit = TimeUnit.SECONDS) val githubTOTP = TimeBasedOneTimePasswordGenerator(github2FASecret, config) - val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) - Log.d(TAG, "Wait for VPN permission prompt and accept") - device.find(By.text("Connection request")) - device.find(By.text("OK")).click() Log.d(TAG, "Click through Get Started screen") device.find(By.text("Get Started")) device.find(By.text("Get Started")).click() + Log.d(TAG, "Wait for VPN permission prompt and accept") + device.find(By.text("Connection request")) + device.find(By.text("OK")).click() + asNecessary( - timeout = 2.minutes, + 2.minutes, { Log.d(TAG, "Log in") device.find(By.text("Log in")).click() @@ -93,7 +93,6 @@ class MainActivityTest { }, { Log.d(TAG, "Make sure GitHub page has loaded") - device.find(By.text("New to GitHub")) device.find(By.text("Username or email address")) device.find(By.text("Sign in")) }, @@ -115,10 +114,15 @@ class MainActivityTest { .setText(githubTOTP.generate()) device.find(UiSelector().instance(0).className(Button::class.java)).click() }, + { + Log.d(TAG, "Authorizing Tailscale") + device.find(By.text("Authorize tailscale")).click() + }, { Log.d(TAG, "Accept Tailscale app") device.find(By.text("Learn more about OAuth")) // Sleep a little to give button time to activate + Thread.sleep(5.seconds.inWholeMilliseconds) device.find(UiSelector().instance(1).className(Button::class.java)).click() }, @@ -126,8 +130,7 @@ class MainActivityTest { Log.d(TAG, "Connect device") device.find(By.text("Connect device")) device.find(UiSelector().instance(0).className(Button::class.java)).click() - }, - ) + }) try { Log.d(TAG, "Accept Permission (Either Storage or Notifications)") diff --git a/android/src/main/java/com/tailscale/ipn/App.kt b/android/src/main/java/com/tailscale/ipn/App.kt index 3a1e88e..3b5b243 100644 --- a/android/src/main/java/com/tailscale/ipn/App.kt +++ b/android/src/main/java/com/tailscale/ipn/App.kt @@ -7,15 +7,13 @@ import android.app.Application import android.app.Notification import android.app.NotificationChannel import android.app.PendingIntent +import android.content.BroadcastReceiver import android.content.Context import android.content.Intent +import android.content.IntentFilter import android.content.SharedPreferences import android.content.pm.PackageManager import android.net.ConnectivityManager -import android.net.LinkProperties -import android.net.Network -import android.net.NetworkCapabilities -import android.net.NetworkRequest import android.os.Build import android.os.Environment import android.util.Log @@ -28,6 +26,7 @@ import androidx.lifecycle.ViewModelStoreOwner import androidx.security.crypto.EncryptedSharedPreferences import androidx.security.crypto.MasterKey import com.tailscale.ipn.mdm.MDMSettings +import com.tailscale.ipn.mdm.MDMSettingsChangedReceiver import com.tailscale.ipn.ui.localapi.Client import com.tailscale.ipn.ui.localapi.Request import com.tailscale.ipn.ui.model.Ipn @@ -35,17 +34,19 @@ import com.tailscale.ipn.ui.notifier.HealthNotifier import com.tailscale.ipn.ui.notifier.Notifier import com.tailscale.ipn.ui.viewModel.VpnViewModel import com.tailscale.ipn.ui.viewModel.VpnViewModelFactory +import com.tailscale.ipn.util.TSLog import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json import libtailscale.Libtailscale import java.io.File import java.io.IOException -import java.net.InetAddress import java.net.NetworkInterface import java.security.GeneralSecurityException import java.util.Locale @@ -56,11 +57,6 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner { companion object { private const val FILE_CHANNEL_ID = "tailscale-files" private const val TAG = "App" - private val networkConnectivityRequest = - NetworkRequest.Builder() - .addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) - .addCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VPN) - .build() private lateinit var appInstance: App /** @@ -76,28 +72,42 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner { val dns = DnsConfig() private lateinit var connectivityManager: ConnectivityManager + private lateinit var mdmChangeReceiver: MDMSettingsChangedReceiver private lateinit var app: libtailscale.Application override val viewModelStore: ViewModelStore get() = appViewModelStore - lateinit var vpnViewModel: VpnViewModel - private set - private val appViewModelStore: ViewModelStore by lazy { ViewModelStore() } var healthNotifier: HealthNotifier? = null override fun getPlatformDNSConfig(): String = dns.dnsConfigAsString - override fun isPlayVersion(): Boolean = MaybeGoogle.isGoogle() + override fun getInstallSource(): String = AppSourceChecker.getInstallSource(this) + + override fun shouldUseGoogleDNSFallback(): Boolean = BuildConfig.USE_GOOGLE_DNS_FALLBACK override fun log(s: String, s1: String) { Log.d(s, s1) } + fun getLibtailscaleApp(): libtailscale.Application { + if (!isInitialized) { + initOnce() // Calls the synchronized initialization logic + } + return app + } + override fun onCreate() { super.onCreate() + appInstance = this + setUnprotectedInstance(this) + + mdmChangeReceiver = MDMSettingsChangedReceiver() + val filter = IntentFilter(Intent.ACTION_APPLICATION_RESTRICTIONS_CHANGED) + registerReceiver(mdmChangeReceiver, filter) + createNotificationChannel( STATUS_CHANNEL_ID, getString(R.string.vpn_status), @@ -113,8 +123,6 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner { getString(R.string.health_channel_name), getString(R.string.health_channel_description), NotificationManagerCompat.IMPORTANCE_HIGH) - appInstance = this - setUnprotectedInstance(this) } override fun onTerminate() { @@ -123,17 +131,22 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner { notificationManager.cancelAll() applicationScope.cancel() viewModelStore.clear() + unregisterReceiver(mdmChangeReceiver) } - private var isInitialized = false + @Volatile private var isInitialized = false @Synchronized private fun initOnce() { if (isInitialized) { return } + + initializeApp() isInitialized = true + } + private fun initializeApp() { val dataDir = this.filesDir.absolutePath // Set this to enable direct mode for taildrop whereby downloads will be saved directly @@ -145,76 +158,56 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner { Request.setApp(app) Notifier.setApp(app) Notifier.start(applicationScope) - healthNotifier = HealthNotifier(Notifier.health, applicationScope) + healthNotifier = HealthNotifier(Notifier.health, Notifier.state, applicationScope) connectivityManager = this.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager - setAndRegisterNetworkCallbacks() + NetworkChangeCallback.monitorDnsChanges(connectivityManager, dns) + initViewModels() applicationScope.launch { - Notifier.state.collect { state -> - val ableToStartVPN = state > Ipn.State.NeedsMachineAuth - // If VPN is stopped, show a disconnected notification. If it is running as a foregrround - // service, IPNService will show a connected notification. - if (state == Ipn.State.Stopped) { - notifyStatus(false) - } - val vpnRunning = state == Ipn.State.Starting || state == Ipn.State.Running - updateConnStatus(ableToStartVPN) - QuickToggleService.setVPNRunning(vpnRunning) + Notifier.state.collect { _ -> + combine(Notifier.state, MDMSettings.forceEnabled.flow) { state, forceEnabled -> + Pair(state, forceEnabled) + } + .collect { (state, hideDisconnectAction) -> + val ableToStartVPN = state > Ipn.State.NeedsMachineAuth + // If VPN is stopped, show a disconnected notification. If it is running as a + // foreground + // service, IPNService will show a connected notification. + if (state == Ipn.State.Stopped) { + notifyStatus(vpnRunning = false, hideDisconnectAction = hideDisconnectAction.value) + } + + val vpnRunning = state == Ipn.State.Starting || state == Ipn.State.Running + updateConnStatus(ableToStartVPN) + QuickToggleService.setVPNRunning(vpnRunning) + + // Update notification status when VPN is running + if (vpnRunning) { + notifyStatus(vpnRunning = true, hideDisconnectAction = hideDisconnectAction.value) + } + } } } - initViewModels() + applicationScope.launch { + val hideDisconnectAction = MDMSettings.forceEnabled.flow.first() + } } private fun initViewModels() { vpnViewModel = ViewModelProvider(this, VpnViewModelFactory(this)).get(VpnViewModel::class.java) } - fun setWantRunning(wantRunning: Boolean) { + fun setWantRunning(wantRunning: Boolean, onSuccess: (() -> Unit)? = null) { val callback: (Result) -> Unit = { result -> result.fold( - onSuccess = {}, + onSuccess = { onSuccess?.invoke() }, onFailure = { error -> - Log.d("TAG", "Set want running: failed to update preferences: ${error.message}") + TSLog.d("TAG", "Set want running: failed to update preferences: ${error.message}") }) } Client(applicationScope) .editPrefs(Ipn.MaskedPrefs().apply { WantRunning = wantRunning }, callback) } - // requestNetwork attempts to find the best network that matches the passed NetworkRequest. It is - // possible that this might return an unusuable network, eg a captive portal. - private fun setAndRegisterNetworkCallbacks() { - connectivityManager.requestNetwork( - networkConnectivityRequest, - object : ConnectivityManager.NetworkCallback() { - override fun onAvailable(network: Network) { - super.onAvailable(network) - - val sb = StringBuilder() - val linkProperties: LinkProperties? = connectivityManager.getLinkProperties(network) - val dnsList: MutableList = linkProperties?.dnsServers ?: mutableListOf() - for (ip in dnsList) { - sb.append(ip.hostAddress).append(" ") - } - val searchDomains: String? = linkProperties?.domains - if (searchDomains != null) { - sb.append("\n") - sb.append(searchDomains) - } - - if (dns.updateDNSFromNetwork(sb.toString())) { - Libtailscale.onDNSConfigChanged(linkProperties?.interfaceName) - } - } - - override fun onLost(network: Network) { - super.onLost(network) - if (dns.updateDNSFromNetwork("")) { - Libtailscale.onDNSConfigChanged("") - } - } - }) - } - // encryptToPref a byte array of data using the Jetpack Security // library and writes it to a global encrypted preference store. @Throws(IOException::class, GeneralSecurityException::class) @@ -249,7 +242,7 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner { private fun updateConnStatus(ableToStartVPN: Boolean) { setAbleToStartVPN(ableToStartVPN) QuickToggleService.updateTile() - Log.d("App", "Set Tile Ready: $ableToStartVPN") + TSLog.d("App", "Set Tile Ready: $ableToStartVPN") } override fun getModelName(): String { @@ -312,14 +305,14 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner { downloads.mkdirs() } } catch (e: Exception) { - Log.e(TAG, "Failed to create downloads folder: $e") + TSLog.e(TAG, "Failed to create downloads folder: $e") downloads = File(this.filesDir, "Taildrop") try { if (!downloads.exists()) { downloads.mkdirs() } } catch (e: Exception) { - Log.e(TAG, "Failed to create Taildrop folder: $e") + TSLog.e(TAG, "Failed to create Taildrop folder: $e") downloads = File("") } } @@ -354,7 +347,7 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner { val list = setting.value as? List<*> return Json.encodeToString(list) } catch (e: Exception) { - Log.d("MDM", "$key value cannot be serialized to JSON. Throwing NoSuchKeyException.") + TSLog.d("MDM", "$key value cannot be serialized to JSON. Throwing NoSuchKeyException.") throw MDMSettings.NoSuchKeyException() } } @@ -389,6 +382,8 @@ open class UninitializedApp : Application() { private lateinit var appInstance: UninitializedApp lateinit var notificationManager: NotificationManagerCompat + lateinit var vpnViewModel: VpnViewModel + @JvmStatic fun get(): UninitializedApp { return appInstance @@ -414,16 +409,27 @@ open class UninitializedApp : Application() { fun startVPN() { val intent = Intent(this, IPNService::class.java).apply { action = IPNService.ACTION_START_VPN } + // FLAG_UPDATE_CURRENT ensures that if the intent is already pending, the existing intent will + // be updated rather than creating multiple redundant instances. + val pendingIntent = + PendingIntent.getService( + this, + 0, + intent, + PendingIntent.FLAG_UPDATE_CURRENT or + PendingIntent.FLAG_IMMUTABLE // FLAG_IMMUTABLE for Android 12+ + ) + try { - startForegroundService(intent) + pendingIntent.send() } catch (foregroundServiceStartException: IllegalStateException) { - Log.e( + TSLog.e( TAG, - "startVPN hit ForegroundServiceStartNotAllowedException in startForegroundService(): $foregroundServiceStartException") + "startVPN hit ForegroundServiceStartNotAllowedException: $foregroundServiceStartException") } catch (securityException: SecurityException) { - Log.e(TAG, "startVPN hit SecurityException in startForegroundService(): $securityException") + TSLog.e(TAG, "startVPN hit SecurityException: $securityException") } catch (e: Exception) { - Log.e(TAG, "startVPN hit exception in startForegroundService(): $e") + TSLog.e(TAG, "startVPN hit exception: $e") } } @@ -432,16 +438,32 @@ open class UninitializedApp : Application() { try { startService(intent) } catch (illegalStateException: IllegalStateException) { - Log.e(TAG, "stopVPN hit IllegalStateException in startService(): $illegalStateException") + TSLog.e(TAG, "stopVPN hit IllegalStateException in startService(): $illegalStateException") } catch (e: Exception) { - Log.e(TAG, "stopVPN hit exception in startService(): $e") + TSLog.e(TAG, "stopVPN hit exception in startService(): $e") } } - // Calls stopVPN() followed by startVPN() to restart the VPN. fun restartVPN() { + // Register a receiver to listen for the completion of stopVPN + val stopReceiver = + object : BroadcastReceiver() { + override fun onReceive(context: Context?, intent: Intent?) { + // Ensure stop intent is complete + if (intent?.action == IPNService.ACTION_STOP_VPN) { + // Unregister receiver after receiving the broadcast + context?.unregisterReceiver(this) + // Now start the VPN + startVPN() + } + } + } + + // Register the receiver before stopping VPN + val intentFilter = IntentFilter(IPNService.ACTION_STOP_VPN) + this.registerReceiver(stopReceiver, intentFilter) + stopVPN() - startVPN() } fun createNotificationChannel(id: String, name: String, description: String, importance: Int) { @@ -451,8 +473,8 @@ open class UninitializedApp : Application() { notificationManager.createNotificationChannel(channel) } - fun notifyStatus(vpnRunning: Boolean) { - notifyStatus(buildStatusNotification(vpnRunning)) + fun notifyStatus(vpnRunning: Boolean, hideDisconnectAction: Boolean) { + notifyStatus(buildStatusNotification(vpnRunning, hideDisconnectAction)) } fun notifyStatus(notification: Notification) { @@ -470,7 +492,7 @@ open class UninitializedApp : Application() { notificationManager.notify(STATUS_NOTIFICATION_ID, notification) } - fun buildStatusNotification(vpnRunning: Boolean): Notification { + fun buildStatusNotification(vpnRunning: Boolean, hideDisconnectAction: Boolean): Notification { val message = getString(if (vpnRunning) R.string.connected else R.string.not_connected) val icon = if (vpnRunning) R.drawable.ic_notification else R.drawable.ic_notification_disabled val action = @@ -492,24 +514,27 @@ open class UninitializedApp : Application() { PendingIntent.getActivity( this, 1, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE) - return NotificationCompat.Builder(this, STATUS_CHANNEL_ID) - .setSmallIcon(icon) - .setContentTitle("Tailscale") - .setContentText(message) - .setAutoCancel(!vpnRunning) - .setOnlyAlertOnce(!vpnRunning) - .setOngoing(vpnRunning) - .setSilent(true) - .setOngoing(false) - .setPriority(NotificationCompat.PRIORITY_DEFAULT) - .addAction(NotificationCompat.Action.Builder(0, actionLabel, pendingButtonIntent).build()) - .setContentIntent(pendingIntent) - .build() + val builder = + NotificationCompat.Builder(this, STATUS_CHANNEL_ID) + .setSmallIcon(icon) + .setContentTitle(getString(R.string.app_name)) + .setContentText(message) + .setAutoCancel(!vpnRunning) + .setOnlyAlertOnce(!vpnRunning) + .setOngoing(vpnRunning) + .setSilent(true) + .setPriority(NotificationCompat.PRIORITY_DEFAULT) + .setContentIntent(pendingIntent) + if (!vpnRunning || !hideDisconnectAction) { + builder.addAction( + NotificationCompat.Action.Builder(0, actionLabel, pendingButtonIntent).build()) + } + return builder.build() } fun addUserDisallowedPackageName(packageName: String) { if (packageName.isEmpty()) { - Log.e(TAG, "addUserDisallowedPackageName called with empty packageName") + TSLog.e(TAG, "addUserDisallowedPackageName called with empty packageName") return } @@ -524,7 +549,7 @@ open class UninitializedApp : Application() { fun removeUserDisallowedPackageName(packageName: String) { if (packageName.isEmpty()) { - Log.e(TAG, "removeUserDisallowedPackageName called with empty packageName") + TSLog.e(TAG, "removeUserDisallowedPackageName called with empty packageName") return } @@ -542,7 +567,7 @@ open class UninitializedApp : Application() { val mdmDisallowed = MDMSettings.excludedPackages.flow.value.value?.split(",")?.map { it.trim() } ?: emptyList() if (mdmDisallowed.isNotEmpty()) { - Log.d(TAG, "Excluded application packages were set via MDM: $mdmDisallowed") + TSLog.d(TAG, "Excluded application packages were set via MDM: $mdmDisallowed") return builtInDisallowedPackageNames + mdmDisallowed } val userDisallowed = @@ -550,6 +575,10 @@ open class UninitializedApp : Application() { return builtInDisallowedPackageNames + userDisallowed } + fun getAppScopedViewModel(): VpnViewModel { + return vpnViewModel + } + val builtInDisallowedPackageNames: List = listOf( // RCS/Jibe https://github.com/tailscale/tailscale/issues/2322 @@ -572,5 +601,7 @@ open class UninitializedApp : Application() { "com.vna.service.vvm", "com.dish.vvm", "com.comcast.modesto.vvm.client", + // Android Connectivity Service https://github.com/tailscale/tailscale/issues/14128 + "com.google.android.apps.scone", ) } diff --git a/android/src/main/java/com/tailscale/ipn/AppSourceChecker.kt b/android/src/main/java/com/tailscale/ipn/AppSourceChecker.kt new file mode 100644 index 0000000..fd4d4d7 --- /dev/null +++ b/android/src/main/java/com/tailscale/ipn/AppSourceChecker.kt @@ -0,0 +1,35 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause +package com.tailscale.ipn + +import android.content.Context +import android.os.Build +import android.util.Log + +object AppSourceChecker { + + const val TAG = "AppSourceChecker" + + fun getInstallSource(context: Context): String { + val packageManager = context.packageManager + val packageName = context.packageName + Log.d(TAG, "Package name: $packageName") + + val installerPackageName = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + packageManager.getInstallSourceInfo(packageName).installingPackageName + } else { + @Suppress("deprecation") packageManager.getInstallerPackageName(packageName) + } + + Log.d(TAG, "Installer package name: $installerPackageName") + + return when (installerPackageName) { + "com.android.vending" -> "googleplay" + "org.fdroid.fdroid" -> "fdroid" + "com.amazon.venezia" -> "amazon" + null -> "unknown" + else -> "unknown($installerPackageName)" + } + } +} diff --git a/android/src/main/java/com/tailscale/ipn/IPNService.kt b/android/src/main/java/com/tailscale/ipn/IPNService.kt index 193e2ff..920d08d 100644 --- a/android/src/main/java/com/tailscale/ipn/IPNService.kt +++ b/android/src/main/java/com/tailscale/ipn/IPNService.kt @@ -8,35 +8,51 @@ import android.content.pm.PackageManager import android.net.VpnService import android.os.Build import android.system.OsConstants -import android.util.Log import com.tailscale.ipn.mdm.MDMSettings +import com.tailscale.ipn.ui.model.Ipn +import com.tailscale.ipn.ui.notifier.Notifier +import com.tailscale.ipn.util.TSLog +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch import libtailscale.Libtailscale import java.util.UUID open class IPNService : VpnService(), libtailscale.IPNService { private val TAG = "IPNService" private val randomID: String = UUID.randomUUID().toString() + private lateinit var app: App + val scope = CoroutineScope(Dispatchers.IO) override fun id(): String { return randomID } + override fun updateVpnStatus(status: Boolean) { + app.getAppScopedViewModel().setVpnActive(status) + } + override fun onCreate() { super.onCreate() // grab app to make sure it initializes - App.get() + app = App.get() } override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int = when (intent?.action) { ACTION_STOP_VPN -> { - App.get().setWantRunning(false) + app.setWantRunning(false) close() START_NOT_STICKY } ACTION_START_VPN -> { - showForegroundNotification() - App.get().setWantRunning(true) + scope.launch { + // Collect the first value of hideDisconnectAction asynchronously. + val hideDisconnectAction = MDMSettings.forceEnabled.flow.first() + showForegroundNotification(hideDisconnectAction.value) + } + app.setWantRunning(true) Libtailscale.requestVPN(this) START_STICKY } @@ -44,8 +60,12 @@ open class IPNService : VpnService(), libtailscale.IPNService { // This means we were started by Android due to Always On VPN. // We show a non-foreground notification because we weren't // started as a foreground service. - App.get().notifyStatus(true) - App.get().setWantRunning(true) + scope.launch { + // Collect the first value of hideDisconnectAction asynchronously. + val hideDisconnectAction = MDMSettings.forceEnabled.flow.first() + app.notifyStatus(true, hideDisconnectAction.value) + } + app.setWantRunning(true) Libtailscale.requestVPN(this) START_STICKY } @@ -53,7 +73,11 @@ open class IPNService : VpnService(), libtailscale.IPNService { // This means that we were restarted after the service was killed // (potentially due to OOM). if (UninitializedApp.get().isAbleToStartVPN()) { - showForegroundNotification() + scope.launch { + // Collect the first value of hideDisconnectAction asynchronously. + val hideDisconnectAction = MDMSettings.forceEnabled.flow.first() + showForegroundNotification(hideDisconnectAction.value) + } App.get() Libtailscale.requestVPN(this) START_STICKY @@ -64,27 +88,39 @@ open class IPNService : VpnService(), libtailscale.IPNService { } override fun close() { - stopForeground(STOP_FOREGROUND_REMOVE) + app.setWantRunning(false) {} + Notifier.setState(Ipn.State.Stopping) + disconnectVPN() Libtailscale.serviceDisconnect(this) } + override fun disconnectVPN() { + stopSelf() + } + override fun onDestroy() { close() + updateVpnStatus(false) super.onDestroy() } override fun onRevoke() { close() + updateVpnStatus(false) super.onRevoke() } - private fun showForegroundNotification() { + private fun setVpnPrepared(isPrepared: Boolean) { + app.getAppScopedViewModel().setVpnPrepared(isPrepared) + } + + private fun showForegroundNotification(hideDisconnectAction: Boolean) { try { startForeground( UninitializedApp.STATUS_NOTIFICATION_ID, - UninitializedApp.get().buildStatusNotification(true)) + UninitializedApp.get().buildStatusNotification(true, hideDisconnectAction)) } catch (e: Exception) { - Log.e(TAG, "Failed to start foreground service: $e") + TSLog.e(TAG, "Failed to start foreground service: $e") } } @@ -100,7 +136,7 @@ open class IPNService : VpnService(), libtailscale.IPNService { try { b.addDisallowedApplication(name) } catch (e: PackageManager.NameNotFoundException) { - Log.d(TAG, "Failed to add disallowed application: $e") + TSLog.d(TAG, "Failed to add disallowed application: $e") } } @@ -122,7 +158,7 @@ open class IPNService : VpnService(), libtailscale.IPNService { // Tailscale, // then only allow those apps. for (packageName in includedPackages) { - Log.d(TAG, "Including app: $packageName") + TSLog.d(TAG, "Including app: $packageName") b.addAllowedApplication(packageName) } } else { @@ -130,7 +166,7 @@ open class IPNService : VpnService(), libtailscale.IPNService { // - any app that the user manually disallowed in the GUI // - any app that we disallowed via hard-coding for (disallowedPackageName in UninitializedApp.get().disallowedPackageNames()) { - Log.d(TAG, "Disallowing app: $disallowedPackageName") + TSLog.d(TAG, "Disallowing app: $disallowedPackageName") disallowApp(b, disallowedPackageName) } } diff --git a/android/src/main/java/com/tailscale/ipn/MainActivity.kt b/android/src/main/java/com/tailscale/ipn/MainActivity.kt index 336edd9..118d523 100644 --- a/android/src/main/java/com/tailscale/ipn/MainActivity.kt +++ b/android/src/main/java/com/tailscale/ipn/MainActivity.kt @@ -16,7 +16,6 @@ import android.net.ConnectivityManager import android.net.NetworkCapabilities import android.os.Bundle import android.provider.Settings -import android.util.Log import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.result.ActivityResultLauncher @@ -67,6 +66,7 @@ import com.tailscale.ipn.ui.view.MullvadInfoView import com.tailscale.ipn.ui.view.PeerDetails import com.tailscale.ipn.ui.view.PermissionsView import com.tailscale.ipn.ui.view.RunExitNodeView +import com.tailscale.ipn.ui.view.SearchView import com.tailscale.ipn.ui.view.SettingsView import com.tailscale.ipn.ui.view.SplitTunnelAppPickerView import com.tailscale.ipn.ui.view.TailnetLockSetupView @@ -78,6 +78,7 @@ import com.tailscale.ipn.ui.viewModel.MainViewModelFactory import com.tailscale.ipn.ui.viewModel.PingViewModel import com.tailscale.ipn.ui.viewModel.SettingsNav import com.tailscale.ipn.ui.viewModel.VpnViewModel +import com.tailscale.ipn.util.TSLog import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.MutableStateFlow @@ -89,7 +90,7 @@ class MainActivity : ComponentActivity() { private lateinit var vpnPermissionLauncher: ActivityResultLauncher private val viewModel: MainViewModel by lazy { val app = App.get() - vpnViewModel = app.vpnViewModel + vpnViewModel = app.getAppScopedViewModel() ViewModelProvider(this, MainViewModelFactory(vpnViewModel)).get(MainViewModel::class.java) } private lateinit var vpnViewModel: VpnViewModel @@ -128,16 +129,16 @@ class MainActivity : ComponentActivity() { vpnPermissionLauncher = registerForActivityResult(VpnPermissionContract()) { granted -> if (granted) { - Log.d("VpnPermission", "VPN permission granted") + TSLog.d("VpnPermission", "VPN permission granted") vpnViewModel.setVpnPrepared(true) App.get().startVPN() } else { if (isAnotherVpnActive(this)) { - Log.d("VpnPermission", "Another VPN is likely active") + TSLog.d("VpnPermission", "Another VPN is likely active") showOtherVPNConflictDialog() } else { - Log.d("VpnPermission", "Permission was denied by the user") - viewModel.setVpnPrepared(false) + TSLog.d("VpnPermission", "Permission was denied by the user") + vpnViewModel.setVpnPrepared(false) } } } @@ -174,7 +175,8 @@ class MainActivity : ComponentActivity() { navController.navigate("peerDetails/${it.StableID}") }, onNavigateToExitNodes = { navController.navigate("exitNodes") }, - onNavigateToHealth = { navController.navigate("health") }) + onNavigateToHealth = { navController.navigate("health") }, + onNavigateToSearch = { navController.navigate("search") }) val settingsNav = SettingsNav( @@ -214,6 +216,12 @@ class MainActivity : ComponentActivity() { composable("main", enterTransition = { fadeIn(animationSpec = tween(150)) }) { MainView(loginAtUrl = ::login, navigation = mainViewNav, viewModel = viewModel) } + composable("search") { + SearchView( + viewModel = viewModel, + navController = navController, + onNavigateBack = { navController.popBackStack() }) + } composable("settings") { SettingsView(settingsNav) } composable("exitNodes") { ExitNodePicker(exitNodePickerNav) } composable("health") { HealthView(backTo("main")) } @@ -231,7 +239,7 @@ class MainActivity : ComponentActivity() { "peerDetails/{nodeId}", arguments = listOf(navArgument("nodeId") { type = NavType.StringType })) { PeerDetails( - backTo("main"), + { navController.popBackStack() }, it.arguments?.getString("nodeId") ?: "", PingViewModel()) } @@ -357,7 +365,7 @@ class MainActivity : ComponentActivity() { } } } catch (e: Exception) { - Log.e(TAG, "Login: failed to start MainActivity: $e") + TSLog.e(TAG, "Login: failed to start MainActivity: $e") } } @@ -371,7 +379,7 @@ class MainActivity : ComponentActivity() { val fallbackIntent = Intent(Intent.ACTION_VIEW, url) startActivity(fallbackIntent) } catch (e: Exception) { - Log.e(TAG, "Login: failed to open browser: $e") + TSLog.e(TAG, "Login: failed to open browser: $e") } } } diff --git a/android/src/main/java/com/tailscale/ipn/MaybeGoogle.java b/android/src/main/java/com/tailscale/ipn/MaybeGoogle.java deleted file mode 100644 index 2eb3bac..0000000 --- a/android/src/main/java/com/tailscale/ipn/MaybeGoogle.java +++ /dev/null @@ -1,35 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package com.tailscale.ipn; - -import android.app.Activity; - -import java.lang.reflect.Method; - -public class MaybeGoogle { - static boolean isGoogle() { - return getGoogle() != null; - } - - static String getIdTokenForActivity(Activity act) { - Class google = getGoogle(); - if (google == null) { - return ""; - } - try { - Method method = google.getMethod("getIdTokenForActivity", Activity.class); - return (String) method.invoke(null, act); - } catch (Exception e) { - return ""; - } - } - - private static Class getGoogle() { - try { - return Class.forName("com.tailscale.ipn.Google"); - } catch (ClassNotFoundException e) { - return null; - } - } -} diff --git a/android/src/main/java/com/tailscale/ipn/NetworkChangeCallback.kt b/android/src/main/java/com/tailscale/ipn/NetworkChangeCallback.kt new file mode 100644 index 0000000..9b6f9df --- /dev/null +++ b/android/src/main/java/com/tailscale/ipn/NetworkChangeCallback.kt @@ -0,0 +1,168 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause +package com.tailscale.ipn + +import android.net.ConnectivityManager +import android.net.LinkProperties +import android.net.Network +import android.net.NetworkCapabilities +import android.net.NetworkRequest +import android.util.Log +import com.tailscale.ipn.util.TSLog +import libtailscale.Libtailscale +import java.util.concurrent.locks.ReentrantLock +import kotlin.concurrent.withLock + +object NetworkChangeCallback { + + private const val TAG = "NetworkChangeCallback" + + private data class NetworkInfo(var caps: NetworkCapabilities, var linkProps: LinkProperties) + + private val lock = ReentrantLock() + + private val activeNetworks = mutableMapOf() // 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? { + 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) + } + } +} diff --git a/android/src/main/java/com/tailscale/ipn/QuickToggleService.java b/android/src/main/java/com/tailscale/ipn/QuickToggleService.java index 7b8355f..0ac3bd0 100644 --- a/android/src/main/java/com/tailscale/ipn/QuickToggleService.java +++ b/android/src/main/java/com/tailscale/ipn/QuickToggleService.java @@ -60,6 +60,7 @@ public class QuickToggleService extends TileService { } } + @SuppressWarnings("deprecation") @Override public void onClick() { boolean r; @@ -77,6 +78,7 @@ public class QuickToggleService extends TileService { // Request code for opening activity. startActivityAndCollapse(PendingIntent.getActivity(this, 0, i, PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE)); } else { + // Deprecated, but still required for older versions. startActivityAndCollapse(i); } } diff --git a/android/src/main/java/com/tailscale/ipn/ShareActivity.kt b/android/src/main/java/com/tailscale/ipn/ShareActivity.kt index 899b9d3..3e121f1 100644 --- a/android/src/main/java/com/tailscale/ipn/ShareActivity.kt +++ b/android/src/main/java/com/tailscale/ipn/ShareActivity.kt @@ -8,20 +8,24 @@ import android.net.Uri import android.os.Build import android.os.Bundle import android.provider.OpenableColumns -import android.util.Log import android.webkit.MimeTypeMap import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.ui.Modifier +import androidx.lifecycle.lifecycleScope import com.tailscale.ipn.ui.model.Ipn import com.tailscale.ipn.ui.theme.AppTheme import com.tailscale.ipn.ui.util.set import com.tailscale.ipn.ui.util.universalFit import com.tailscale.ipn.ui.view.TaildropView +import com.tailscale.ipn.util.TSLog +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import kotlin.random.Random // ShareActivity is the entry point for Taildrop share intents @@ -47,7 +51,7 @@ class ShareActivity : ComponentActivity() { super.onStart() // Ensure our app instance is initialized App.get() - loadFiles() + lifecycleScope.launch { withContext(Dispatchers.IO) { loadFiles() } } } override fun onNewIntent(intent: Intent) { @@ -59,7 +63,7 @@ class ShareActivity : ComponentActivity() { // Loads the files from the intent. fun loadFiles() { if (intent == null) { - Log.e(TAG, "Share failure - No intent found") + TSLog.e(TAG, "Share failure - No intent found") return } @@ -83,43 +87,42 @@ class ShareActivity : ComponentActivity() { } } else -> { - Log.e(TAG, "No extras found in intent - nothing to share") + TSLog.e(TAG, "No extras found in intent - nothing to share") null } } - val pendingFiles: List = - uris?.filterNotNull()?.mapNotNull { - contentResolver?.query(it, null, null, null, null)?.let { c -> - val nameCol = c.getColumnIndex(OpenableColumns.DISPLAY_NAME) - val sizeCol = c.getColumnIndex(OpenableColumns.SIZE) - c.moveToFirst() - val name: String = - c.getString(nameCol) - ?: run { - // For some reason, some content resolvers don't return a name. - // Try to build a name from a random integer plus file extension - // (if type can be determined), else just a random integer. - val rand = Random.nextLong() - contentResolver.getType(it)?.let { mimeType -> - MimeTypeMap.getSingleton().getExtensionFromMimeType(mimeType)?.let { - extension -> - "$rand.$extension" - } ?: "$rand" - } ?: "$rand" + val pendingFiles: List = + uris?.filterNotNull()?.mapNotNull { uri -> + contentResolver?.query(uri, null, null, null, null)?.use { cursor -> + val nameCol = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME) + val sizeCol = cursor.getColumnIndex(OpenableColumns.SIZE) + + if (cursor.moveToFirst()) { + val name: String = cursor.getString(nameCol) + ?: generateFallbackName(uri) + val size: Long = cursor.getLong(sizeCol) + Ipn.OutgoingFile(Name = name, DeclaredSize = size).apply { + this.uri = uri } - val size = c.getLong(sizeCol) - c.close() - val file = Ipn.OutgoingFile(Name = name, DeclaredSize = size) - file.uri = it - file - } - } ?: emptyList() + } else { + TSLog.e(TAG, "Cursor is empty for URI: $uri") + null + } + } + } ?: emptyList() if (pendingFiles.isEmpty()) { - Log.e(TAG, "Share failure - no files extracted from intent") + TSLog.e(TAG, "Share failure - no files extracted from intent") } requestedTransfers.set(pendingFiles) } + + private fun generateFallbackName(uri: Uri): String { + val randomId = Random.nextLong() + val mimeType = contentResolver?.getType(uri) + val extension = mimeType?.let { MimeTypeMap.getSingleton().getExtensionFromMimeType(it) } + return if (extension != null) "$randomId.$extension" else randomId.toString() +} } diff --git a/android/src/main/java/com/tailscale/ipn/StartVPNWorker.java b/android/src/main/java/com/tailscale/ipn/StartVPNWorker.java index d75e3fa..9ab4183 100644 --- a/android/src/main/java/com/tailscale/ipn/StartVPNWorker.java +++ b/android/src/main/java/com/tailscale/ipn/StartVPNWorker.java @@ -15,6 +15,8 @@ import androidx.annotation.NonNull; import androidx.work.Worker; import androidx.work.WorkerParameters; +import com.tailscale.ipn.util.TSLog; + /** * A worker that exists to support IPNReceiver. */ @@ -38,7 +40,7 @@ public final class StartVPNWorker extends Worker { } // We aren't ready to start the VPN or don't have permission, open the Tailscale app. - android.util.Log.e("StartVPNWorker", "Tailscale isn't ready to start the VPN, notify the user."); + TSLog.e("StartVPNWorker", "Tailscale isn't ready to start the VPN, notify the user."); // Send notification NotificationManager notificationManager = (NotificationManager) app.getSystemService(Context.NOTIFICATION_SERVICE); diff --git a/android/src/main/java/com/tailscale/ipn/mdm/MDMSettingsChangedReceiver.kt b/android/src/main/java/com/tailscale/ipn/mdm/MDMSettingsChangedReceiver.kt new file mode 100644 index 0000000..b4d17b8 --- /dev/null +++ b/android/src/main/java/com/tailscale/ipn/mdm/MDMSettingsChangedReceiver.kt @@ -0,0 +1,21 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +package com.tailscale.ipn.mdm + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.RestrictionsManager +import com.tailscale.ipn.App +import com.tailscale.ipn.util.TSLog + +class MDMSettingsChangedReceiver : BroadcastReceiver() { + override fun onReceive(context: Context?, intent: Intent?) { + if (intent?.action == android.content.Intent.ACTION_APPLICATION_RESTRICTIONS_CHANGED) { + TSLog.d("syspolicy", "MDM settings changed") + val restrictionsManager = context?.getSystemService(Context.RESTRICTIONS_SERVICE) as RestrictionsManager + MDMSettings.update(App.get(), restrictionsManager) + } + } +} \ No newline at end of file diff --git a/android/src/main/java/com/tailscale/ipn/mdm/MDMSettingsDefinitions.kt b/android/src/main/java/com/tailscale/ipn/mdm/MDMSettingsDefinitions.kt index 1ddf59c..173159f 100644 --- a/android/src/main/java/com/tailscale/ipn/mdm/MDMSettingsDefinitions.kt +++ b/android/src/main/java/com/tailscale/ipn/mdm/MDMSettingsDefinitions.kt @@ -46,9 +46,26 @@ class StringMDMSetting(key: String, localizedTitle: String) : class StringArrayListMDMSetting(key: String, localizedTitle: String) : MDMSetting?>(null, key, localizedTitle) { - override fun getFromBundle(bundle: Bundle) = bundle.getStringArrayList(key) - override fun getFromPrefs(prefs: SharedPreferences) = - prefs.getStringSet(key, HashSet())?.toList() + override fun getFromBundle(bundle: Bundle): List? { + // 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 is found, return null + return null + } + + override fun getFromPrefs(prefs: SharedPreferences): List? { + return prefs.getStringSet(key, HashSet())?.toList() + } } class AlwaysNeverUserDecidesMDMSetting(key: String, localizedTitle: String) : diff --git a/android/src/main/java/com/tailscale/ipn/ui/localapi/Client.kt b/android/src/main/java/com/tailscale/ipn/ui/localapi/Client.kt index 810a642..5b12338 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/localapi/Client.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/localapi/Client.kt @@ -4,7 +4,6 @@ package com.tailscale.ipn.ui.localapi import android.content.Context -import android.util.Log import com.tailscale.ipn.ui.model.BugReportID import com.tailscale.ipn.ui.model.Errors import com.tailscale.ipn.ui.model.Ipn @@ -13,6 +12,8 @@ import com.tailscale.ipn.ui.model.IpnState import com.tailscale.ipn.ui.model.StableNodeID import com.tailscale.ipn.ui.model.Tailcfg import com.tailscale.ipn.ui.util.InputStreamAdapter +import com.tailscale.ipn.util.TSLog +import com.tailscale.ipn.App import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -67,6 +68,11 @@ typealias PingResultHandler = (Result) -> Unit class Client(private val scope: CoroutineScope) { private val TAG = Client::class.simpleName + // Access libtailscale.Application lazily + private val app: libtailscale.Application by lazy { + App.get().getLibtailscaleApp() +} + fun start(options: Ipn.Options, responseHandler: (Result) -> Unit) { val body = Json.encodeToString(options).toByteArray() return post(Endpoint.START, body, responseHandler = responseHandler) @@ -175,7 +181,7 @@ class Client(private val scope: CoroutineScope) { }) } catch (e: Exception) { parts.forEach { it.body.close() } - Log.e(TAG, "Error creating file upload body: $e") + TSLog.e(TAG, "Error creating file upload body: $e") responseHandler(Result.failure(e)) return } @@ -307,7 +313,7 @@ class Request( @OptIn(ExperimentalSerializationApi::class) fun execute() { scope.launch(Dispatchers.IO) { - Log.d(TAG, "Executing request:${method}:${fullPath} on app $app") + TSLog.d(TAG, "Executing request:${method}:${fullPath} on app $app") try { val resp = if (parts != null) app.callLocalAPIMultipart(timeoutMillis, method, fullPath, parts) @@ -350,7 +356,7 @@ class Request( // The response handler will invoked internally by the request parser scope.launch { responseHandler(response) } } catch (e: Exception) { - Log.e(TAG, "Error executing request:${method}:${fullPath}: $e") + TSLog.e(TAG, "Error executing request:${method}:${fullPath}: $e") scope.launch { responseHandler(Result.failure(e)) } } } diff --git a/android/src/main/java/com/tailscale/ipn/ui/model/Ipn.kt b/android/src/main/java/com/tailscale/ipn/ui/model/Ipn.kt index 9fceead..5e1b35a 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/model/Ipn.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/model/Ipn.kt @@ -18,7 +18,11 @@ class Ipn { NeedsMachineAuth(3), Stopped(4), Starting(5), - Running(6); + Running(6), + // Stopping represents a state where a request to stop Tailscale has been issue but has not + // completed. This state allows UI to optimistically reflect a stopped state, and to fallback if + // necessary. + Stopping(7); companion object { fun fromInt(value: Int): State { @@ -158,7 +162,7 @@ class Ipn { ForceDaemonSet = true } - var Hostname: Boolean? = null + var Hostname: String? = null set(value) { field = value HostnameSet = true diff --git a/android/src/main/java/com/tailscale/ipn/ui/model/IpnState.kt b/android/src/main/java/com/tailscale/ipn/ui/model/IpnState.kt index f54e8f4..af36f21 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/model/IpnState.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/model/IpnState.kt @@ -4,6 +4,7 @@ package com.tailscale.ipn.ui.model import kotlinx.serialization.Serializable +import java.net.URL class IpnState { @Serializable @@ -123,9 +124,29 @@ class IpnLocal { val UserProfile: Tailcfg.UserProfile, val NetworkProfile: Tailcfg.NetworkProfile? = null, val LocalUserID: String, + var ControlURL: String? = null, ) { fun isEmpty(): Boolean { return ID.isEmpty() } + + // Returns true if the profile uses a custom control server (not Tailscale SaaS). + private fun isUsingCustomControlServer(): Boolean { + return ControlURL != null && ControlURL != "https://controlplane.tailscale.com" + } + + // Returns the hostname of the custom control server, if any was set. + // + // Returns null if the ControlURL provided by the backend is an invalid URL, and + // a hostname cannot be extracted. + fun customControlServerHostname(): String? { + if (!isUsingCustomControlServer()) return null + + return try { + URL(ControlURL).host + } catch (e: Exception) { + null + } + } } } diff --git a/android/src/main/java/com/tailscale/ipn/ui/model/Permissions.kt b/android/src/main/java/com/tailscale/ipn/ui/model/Permissions.kt index 8579f0b..10e4367 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/model/Permissions.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/model/Permissions.kt @@ -22,7 +22,7 @@ object Permissions { @Composable get() { val permissionStates = rememberMultiplePermissionsState(permissions = all.map { it.name }) - return all.zip(permissionStates.permissions).filter { (permission, state) -> + return all.zip(permissionStates.permissions).filter { (_, state) -> !state.status.isGranted && !state.status.shouldShowRationale } } diff --git a/android/src/main/java/com/tailscale/ipn/ui/notifier/HealthNotifier.kt b/android/src/main/java/com/tailscale/ipn/ui/notifier/HealthNotifier.kt index c9ed6db..8386bcb 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/notifier/HealthNotifier.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/notifier/HealthNotifier.kt @@ -5,7 +5,6 @@ package com.tailscale.ipn.ui.notifier import android.Manifest import android.content.pm.PackageManager -import android.util.Log import androidx.core.app.ActivityCompat import androidx.core.app.NotificationCompat import com.tailscale.ipn.App @@ -13,11 +12,14 @@ import com.tailscale.ipn.R import com.tailscale.ipn.UninitializedApp.Companion.notificationManager import com.tailscale.ipn.ui.model.Health import com.tailscale.ipn.ui.model.Health.UnhealthyState +import com.tailscale.ipn.ui.model.Ipn import com.tailscale.ipn.ui.util.set +import com.tailscale.ipn.util.TSLog import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.launch @@ -25,6 +27,7 @@ import kotlinx.coroutines.launch @OptIn(FlowPreview::class) class HealthNotifier( healthStateFlow: StateFlow, + ipnStateFlow: StateFlow, scope: CoroutineScope, ) { companion object { @@ -45,11 +48,22 @@ class HealthNotifier( scope.launch { healthStateFlow .distinctUntilChanged { old, new -> old?.Warnings?.count() == new?.Warnings?.count() } + .combine(ipnStateFlow, ::Pair) .debounce(5000) - .collect { health -> - Log.d(TAG, "Health updated: ${health?.Warnings?.keys?.sorted()}") - health?.Warnings?.let { - notifyHealthUpdated(it.values.mapNotNull { it }.toTypedArray()) + .collect { pair -> + val health = pair.first + val ipnState = pair.second + // When the client is Stopped, no warnings should get added, and any warnings added + // previously should be removed. + if (ipnState == Ipn.State.Stopped) { + TSLog.d(TAG, "Ignoring and dropping all pre-existing health messages in the Stopped state") + dropAllWarnings() + return@collect + } else { + TSLog.d(TAG, "Health updated: ${health?.Warnings?.keys?.sorted()}") + health?.Warnings?.let { + notifyHealthUpdated(it.values.mapNotNull { it }.toTypedArray()) + } } } } @@ -62,8 +76,24 @@ class HealthNotifier( val warningsBeforeAdd = currentWarnings.value val currentWarnableCodes = warnings.map { it.WarnableCode }.toSet() val addedWarnings: MutableSet = mutableSetOf() + val removedByNewDependency: MutableSet = mutableSetOf() val isWarmingUp = warnings.any { it.WarnableCode == "warming-up" } + /** + * dropDependenciesForAddedWarning checks if there is any warning in `warningsBeforeAdd` that + * needs to be removed because the new warning `w` is listed as a dependency of a warning + * already in `warningsBeforeAdd`, and removes it. + */ + fun dropDependenciesForAddedWarning(w: UnhealthyState) { + for (warning in warningsBeforeAdd) { + warning.DependsOn?.let { + if (it.contains(w.WarnableCode)) { + removedByNewDependency.add(warning) + } + } + } + } + for (warning in warnings) { if (ignoredWarnableCodes.contains(warning.WarnableCode)) { continue @@ -76,28 +106,37 @@ class HealthNotifier( continue } else if (warning.hiddenByDependencies(currentWarnableCodes)) { // Ignore this warning because a dependency is also unhealthy - Log.d(TAG, "Ignoring ${warning.WarnableCode} because of dependency") + TSLog.d(TAG, "Ignoring ${warning.WarnableCode} because of dependency") continue } else if (!isWarmingUp) { - Log.d(TAG, "Adding health warning: ${warning.WarnableCode}") + TSLog.d(TAG, "Adding health warning: ${warning.WarnableCode}") this.currentWarnings.set(this.currentWarnings.value + warning) + dropDependenciesForAddedWarning(warning) if (warning.Severity == Health.Severity.high) { this.sendNotification(warning.Title, warning.Text, warning.WarnableCode) } } else { - Log.d(TAG, "Ignoring ${warning.WarnableCode} because warming up") + TSLog.d(TAG, "Ignoring ${warning.WarnableCode} because warming up") } } - val warningsToDrop = warningsBeforeAdd.minus(addedWarnings) + val warningsToDrop = warningsBeforeAdd.minus(addedWarnings).union(removedByNewDependency) if (warningsToDrop.isNotEmpty()) { - Log.d(TAG, "Dropping health warnings with codes $warningsToDrop") + TSLog.d(TAG, "Dropping health warnings with codes $warningsToDrop") this.removeNotifications(warningsToDrop) } currentWarnings.set(this.currentWarnings.value.subtract(warningsToDrop)) this.updateIcon() } + /** + * Sets the icon displayed to represent the overall health state. + * + * - If there are any high severity warnings, or warnings that affect internet connectivity, + * a warning icon is displayed. + * - If there are any other kind of warnings, an info icon is displayed. + * - If there are no warnings at all, no icon is set. + */ private fun updateIcon() { if (currentWarnings.value.isEmpty()) { this.currentIcon.set(null) @@ -113,7 +152,7 @@ class HealthNotifier( } private fun sendNotification(title: String, text: String, code: String) { - Log.d(TAG, "Sending notification for $code") + TSLog.d(TAG, "Sending notification for $code") val notification = NotificationCompat.Builder(App.get().applicationContext, HEALTH_CHANNEL_ID) .setSmallIcon(R.drawable.ic_notification) @@ -125,14 +164,24 @@ class HealthNotifier( if (ActivityCompat.checkSelfPermission( App.get().applicationContext, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) { - Log.d(TAG, "Notification permission not granted") + TSLog.d(TAG, "Notification permission not granted") return } notificationManager.notify(code.hashCode(), notification) } + /** + * Removes all warnings currently displayed, including any system notifications, and + * updates the icon (causing it to be set to null since the set of warnings is empty). + */ + private fun dropAllWarnings() { + removeNotifications(this.currentWarnings.value) + this.currentWarnings.set(emptySet()) + this.updateIcon() + } + private fun removeNotifications(warnings: Set) { - Log.d(TAG, "Removing notifications for $warnings") + TSLog.d(TAG, "Removing notifications for $warnings") for (warning in warnings) { notificationManager.cancel(warning.WarnableCode.hashCode()) } diff --git a/android/src/main/java/com/tailscale/ipn/ui/notifier/Notifier.kt b/android/src/main/java/com/tailscale/ipn/ui/notifier/Notifier.kt index c01a142..4962823 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/notifier/Notifier.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/notifier/Notifier.kt @@ -3,7 +3,6 @@ package com.tailscale.ipn.ui.notifier -import android.util.Log import com.tailscale.ipn.App import com.tailscale.ipn.ui.model.Empty import com.tailscale.ipn.ui.model.Health @@ -11,6 +10,7 @@ import com.tailscale.ipn.ui.model.Ipn import com.tailscale.ipn.ui.model.Ipn.Notify import com.tailscale.ipn.ui.model.Netmap import com.tailscale.ipn.ui.util.set +import com.tailscale.ipn.util.TSLog import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow @@ -33,7 +33,8 @@ object Notifier { private val decoder = Json { ignoreUnknownKeys = true } // General IPN Bus State - val state: StateFlow = MutableStateFlow(Ipn.State.NoState) + private val _state = MutableStateFlow(Ipn.State.NoState) + val state: StateFlow = _state val netmap: StateFlow = MutableStateFlow(null) val prefs: StateFlow = MutableStateFlow(null) val engineStatus: StateFlow = MutableStateFlow(null) @@ -51,14 +52,16 @@ object Notifier { private lateinit var app: libtailscale.Application private var manager: libtailscale.NotificationManager? = null + @Synchronized @JvmStatic fun setApp(newApp: libtailscale.Application) { app = newApp } + @Synchronized @OptIn(ExperimentalSerializationApi::class) fun start(scope: CoroutineScope) { - Log.d(TAG, "Starting") + TSLog.d(TAG, "Starting Notifier") if (!::app.isInitialized) { App.get() } @@ -67,7 +70,8 @@ object Notifier { NotifyWatchOpt.Netmap.value or NotifyWatchOpt.Prefs.value or NotifyWatchOpt.InitialState.value or - NotifyWatchOpt.InitialHealthState.value + NotifyWatchOpt.InitialHealthState.value or + NotifyWatchOpt.RateLimitNetmaps.value manager = app.watchNotifications(mask.toLong()) { notification -> val notify = decoder.decodeFromStream(notification.inputStream()) @@ -88,7 +92,7 @@ object Notifier { } fun stop() { - Log.d(TAG, "Stopping") + TSLog.d(TAG, "Stopping Notifier") manager?.let { it.stop() manager = null @@ -106,5 +110,10 @@ object Notifier { InitialTailFSShares(32), InitialOutgoingFiles(64), InitialHealthState(128), + RateLimitNetmaps(256), + } + + fun setState(newState: Ipn.State) { + _state.value = newState } } diff --git a/android/src/main/java/com/tailscale/ipn/ui/theme/Theme.kt b/android/src/main/java/com/tailscale/ipn/ui/theme/Theme.kt index 7426372..6af87cf 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/theme/Theme.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/theme/Theme.kt @@ -44,7 +44,8 @@ fun AppTheme(useDarkTheme: Boolean = isSystemInDarkTheme(), content: @Composable // margins in list items. bodyMedium = MaterialTheme.typography.bodyMedium.copy(fontSize = 16.sp)) - val systemUiController = rememberSystemUiController() + // TODO: Migrate to Activity.enableEdgeToEdge + @Suppress("deprecation") val systemUiController = rememberSystemUiController() DisposableEffect(systemUiController, useDarkTheme) { systemUiController.setStatusBarColor(color = colors.surfaceContainer) @@ -446,7 +447,6 @@ val ColorScheme.disabled: Color val ColorScheme.searchBarColors: TextFieldColors @Composable get() { - val defaults = OutlinedTextFieldDefaults.colors() return OutlinedTextFieldDefaults.colors( focusedLeadingIconColor = MaterialTheme.colorScheme.onSurface, unfocusedLeadingIconColor = MaterialTheme.colorScheme.onSurface, diff --git a/android/src/main/java/com/tailscale/ipn/ui/util/AndroidTVUtil.kt b/android/src/main/java/com/tailscale/ipn/ui/util/AndroidTVUtil.kt index bad37f3..b4265d2 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/util/AndroidTVUtil.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/util/AndroidTVUtil.kt @@ -15,7 +15,7 @@ import com.tailscale.ipn.ui.util.AndroidTVUtil.isAndroidTV object AndroidTVUtil { fun isAndroidTV(): Boolean { val pm = UninitializedApp.get().packageManager - return (pm.hasSystemFeature(PackageManager.FEATURE_TELEVISION) || + return (pm.hasSystemFeature(@Suppress("deprecation") PackageManager.FEATURE_TELEVISION) || pm.hasSystemFeature(PackageManager.FEATURE_LEANBACK)) } } diff --git a/android/src/main/java/com/tailscale/ipn/ui/util/AppVersion.kt b/android/src/main/java/com/tailscale/ipn/ui/util/AppVersion.kt new file mode 100644 index 0000000..fb042fb --- /dev/null +++ b/android/src/main/java/com/tailscale/ipn/ui/util/AppVersion.kt @@ -0,0 +1,20 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +package com.tailscale.ipn.ui.util + +import com.tailscale.ipn.BuildConfig + +class AppVersion { + companion object { + // Returns the short version of the build version, which is what users typically expect. + // For instance, if the build version is "1.75.80-t8fdffb8da-g2daeee584df", + // this function returns "1.75.80". + fun Short(): String { + // Split the full version string by hyphen (-) + val parts = BuildConfig.VERSION_NAME.split("-") + // Return only the part before the first hyphen + return parts[0] + } + } +} diff --git a/android/src/main/java/com/tailscale/ipn/ui/util/ClipboardValueView.kt b/android/src/main/java/com/tailscale/ipn/ui/util/ClipboardValueView.kt index 79a5c34..865282f 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/util/ClipboardValueView.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/util/ClipboardValueView.kt @@ -3,53 +3,59 @@ package com.tailscale.ipn.ui.util +import androidx.compose.foundation.LocalIndication +import androidx.compose.foundation.background import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.height +import androidx.compose.foundation.focusable +import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.size import androidx.compose.material3.Icon import androidx.compose.material3.ListItem import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.unit.dp import com.tailscale.ipn.R -import com.tailscale.ipn.ui.theme.titledListItem -import com.tailscale.ipn.ui.util.AndroidTVUtil.isAndroidTV @Composable fun ClipboardValueView(value: String, title: String? = null, subtitle: String? = null) { - val localClipboardManager = LocalClipboardManager.current - val modifier = - if (isAndroidTV()) { - Modifier - } else { - Modifier.clickable { localClipboardManager.setText(AnnotatedString(value)) } - } + val isFocused = remember { mutableStateOf(false) } + val localClipboardManager = LocalClipboardManager.current + val interactionSource = remember { MutableInteractionSource() } - ListItem( - colors = MaterialTheme.colorScheme.titledListItem, - modifier = modifier, - overlineContent = title?.let { { Text(it, style = MaterialTheme.typography.titleMedium) } }, - headlineContent = { Text(text = value, style = MaterialTheme.typography.bodyMedium) }, - supportingContent = - subtitle?.let { - { - Text( - it, - modifier = Modifier.padding(top = 8.dp), - style = MaterialTheme.typography.bodyMedium) - } - }, - trailingContent = { - Icon( - painterResource(R.drawable.clipboard), - stringResource(R.string.copy_to_clipboard), - modifier = Modifier.width(24.dp).height(24.dp)) - }) -} + ListItem( + modifier = Modifier + .focusable(interactionSource = interactionSource) + .onFocusChanged { focusState -> isFocused.value = focusState.isFocused } + .clickable( + interactionSource = interactionSource, + indication = LocalIndication.current + ) { localClipboardManager.setText(AnnotatedString(value)) } + .background( + if (isFocused.value) MaterialTheme.colorScheme.primary.copy(alpha = 0.12f) + else Color.Transparent + ), + overlineContent = title?.let { { Text(it, style = MaterialTheme.typography.titleMedium) } }, + headlineContent = { Text(text = value, style = MaterialTheme.typography.bodyMedium) }, + supportingContent = subtitle?.let { + { Text(it, modifier = Modifier.padding(top = 8.dp), style = MaterialTheme.typography.bodyMedium) } + }, + trailingContent = { + Icon( + painterResource(R.drawable.clipboard), + contentDescription = stringResource(R.string.copy_to_clipboard), + modifier = Modifier.size(24.dp) + ) + } + ) +} \ No newline at end of file diff --git a/android/src/main/java/com/tailscale/ipn/ui/util/ModifierUtil.kt b/android/src/main/java/com/tailscale/ipn/ui/util/ModifierUtil.kt new file mode 100644 index 0000000..49d2f19 --- /dev/null +++ b/android/src/main/java/com/tailscale/ipn/ui/util/ModifierUtil.kt @@ -0,0 +1,18 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +package com.tailscale.ipn.ui.util + +import androidx.compose.ui.Modifier + +/// Applies different modifiers to the receiver based on a condition. +inline fun Modifier.conditional( + condition: Boolean, + ifTrue: Modifier.() -> Modifier, + ifFalse: Modifier.() -> Modifier = { this }, +): Modifier = + if (condition) { + then(ifTrue(Modifier)) + } else { + then(ifFalse(Modifier)) + } diff --git a/android/src/main/java/com/tailscale/ipn/ui/util/TimeUtil.kt b/android/src/main/java/com/tailscale/ipn/ui/util/TimeUtil.kt index 970981f..43927be 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/util/TimeUtil.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/util/TimeUtil.kt @@ -3,8 +3,8 @@ package com.tailscale.ipn.ui.util -import android.util.Log import com.tailscale.ipn.R +import com.tailscale.ipn.util.TSLog import java.time.Duration import java.time.Instant import java.time.format.DateTimeFormatter @@ -108,12 +108,12 @@ object TimeUtil { 'm' -> durationFragment * 60.0 's' -> durationFragment else -> { - Log.e(TAG, "Invalid duration string: $goDuration") + TSLog.e(TAG, "Invalid duration string: $goDuration") return null } } } catch (e: NumberFormatException) { - Log.e(TAG, "Invalid duration string: $goDuration") + TSLog.e(TAG, "Invalid duration string: $goDuration") return null } valStr = "" diff --git a/android/src/main/java/com/tailscale/ipn/ui/view/AboutView.kt b/android/src/main/java/com/tailscale/ipn/ui/view/AboutView.kt index 7640b6c..fb0c098 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/view/AboutView.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/view/AboutView.kt @@ -33,6 +33,7 @@ import com.tailscale.ipn.BuildConfig import com.tailscale.ipn.R import com.tailscale.ipn.ui.Links import com.tailscale.ipn.ui.theme.logoBackground +import com.tailscale.ipn.ui.util.AppVersion @Composable fun AboutView(backToSettings: BackNavigation) { @@ -69,9 +70,14 @@ fun AboutView(backToSettings: BackNavigation) { Text( modifier = Modifier.clickable { + // When users tap on the version number, the extended version string + // (including commit hashes) is copied to the clipboard. + // This may be useful for debugging purposes... localClipboardManager.setText(AnnotatedString(BuildConfig.VERSION_NAME)) }, - text = "${stringResource(R.string.version)} ${BuildConfig.VERSION_NAME}", + // ... but we always display the short version in the UI to avoid user + // confusion. + text = "${stringResource(R.string.version)} ${AppVersion.Short()}", fontWeight = MaterialTheme.typography.bodyMedium.fontWeight, fontSize = MaterialTheme.typography.bodyMedium.fontSize) } diff --git a/android/src/main/java/com/tailscale/ipn/ui/view/Avatar.kt b/android/src/main/java/com/tailscale/ipn/ui/view/Avatar.kt index 9f64878..89ef4d9 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/view/Avatar.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/view/Avatar.kt @@ -3,46 +3,91 @@ package com.tailscale.ipn.ui.view +import androidx.compose.foundation.background import androidx.compose.foundation.clickable +import androidx.compose.foundation.focusable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Person -import androidx.compose.material.ripple.rememberRipple import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ripple import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import coil.annotation.ExperimentalCoilApi import coil.compose.AsyncImage import com.tailscale.ipn.R import com.tailscale.ipn.ui.model.IpnLocal +import com.tailscale.ipn.ui.util.AndroidTVUtil +import com.tailscale.ipn.ui.util.conditional @OptIn(ExperimentalCoilApi::class) @Composable -fun Avatar(profile: IpnLocal.LoginProfile?, size: Int = 50, action: (() -> Unit)? = null) { - Box(contentAlignment = Alignment.Center, modifier = Modifier.size(size.dp).clip(CircleShape)) { - var modifier = Modifier.size((size * .8f).dp) - action?.let { +fun Avatar( + profile: IpnLocal.LoginProfile?, + size: Int = 50, + action: (() -> Unit)? = null, + isFocusable: Boolean = false +) { + var isFocused = remember { mutableStateOf(false) } + val focusManager = LocalFocusManager.current + + // Outer Box for the larger focusable and clickable area + Box( + contentAlignment = Alignment.Center, modifier = - modifier.clickable( - interactionSource = remember { MutableInteractionSource() }, - indication = rememberRipple(bounded = false), - onClick = action) - } - Icon( - imageVector = Icons.Default.Person, - contentDescription = stringResource(R.string.settings_title), - modifier = modifier) + Modifier.conditional(AndroidTVUtil.isAndroidTV(), { padding(4.dp) }) + .conditional( + AndroidTVUtil.isAndroidTV(), + { + size((size * 1.5f).dp) // Focusable area is larger than the avatar + }) + .clip(CircleShape) // Ensure both the focus and click area are circular + .background( + if (isFocused.value) MaterialTheme.colorScheme.surface else Color.Transparent, + ) + .onFocusChanged { focusState -> isFocused.value = focusState.isFocused } + .focusable() // Make this outer Box focusable (after onFocusChanged) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = ripple(bounded = true), // Apply ripple effect inside circular bounds + onClick = { + action?.invoke() + focusManager.clearFocus() // Clear focus after clicking the avatar + })) { + // Inner Box to hold the avatar content (Icon or AsyncImage) + Box( + contentAlignment = Alignment.Center, + modifier = Modifier.size(size.dp).clip(CircleShape)) { + // Always display the default icon as a background layer + Icon( + imageVector = Icons.Default.Person, + contentDescription = stringResource(R.string.settings_title), + modifier = + Modifier.conditional(AndroidTVUtil.isAndroidTV(), { size((size * 0.8f).dp) }) + .clip(CircleShape) // Icon size slightly smaller than the Box + ) - profile?.UserProfile?.ProfilePicURL?.let { url -> - AsyncImage(model = url, modifier = Modifier.size((size * 1.2f).dp), contentDescription = null) - } - } + // Overlay the profile picture if available + profile?.UserProfile?.ProfilePicURL?.let { url -> + AsyncImage( + model = url, + modifier = Modifier.size(size.dp).clip(CircleShape), + contentDescription = null) + } + } + } } diff --git a/android/src/main/java/com/tailscale/ipn/ui/view/CustomLogin.kt b/android/src/main/java/com/tailscale/ipn/ui/view/CustomLogin.kt index 9bbe5ab..82d2e37 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/view/CustomLogin.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/view/CustomLogin.kt @@ -9,6 +9,7 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material3.Button import androidx.compose.material3.ListItem import androidx.compose.material3.MaterialTheme @@ -25,6 +26,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.unit.dp import com.tailscale.ipn.R import com.tailscale.ipn.ui.theme.listItem @@ -137,10 +139,12 @@ fun LoginView( onValueChange = { textVal = it }, placeholder = { Text(strings.placeholder, style = MaterialTheme.typography.bodySmall) - }) + }, + keyboardOptions = KeyboardOptions(capitalization = KeyboardCapitalization.None) + ) }) - ListItem( + ListItem( colors = MaterialTheme.colorScheme.listItem, headlineContent = { Box(modifier = Modifier.fillMaxWidth()) { diff --git a/android/src/main/java/com/tailscale/ipn/ui/view/ExitNodePicker.kt b/android/src/main/java/com/tailscale/ipn/ui/view/ExitNodePicker.kt index 19bec62..6a9396f 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/view/ExitNodePicker.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/view/ExitNodePicker.kt @@ -61,7 +61,7 @@ fun ExitNodePicker( if (forcedExitNodeId != null) { Text( text = - managedByOrganization?.let { + managedByOrganization.value?.let { stringResource(R.string.exit_node_mdm_orgname, it) } ?: stringResource(R.string.exit_node_mdm), style = MaterialTheme.typography.bodyMedium, diff --git a/android/src/main/java/com/tailscale/ipn/ui/view/LoginQRView.kt b/android/src/main/java/com/tailscale/ipn/ui/view/LoginQRView.kt index b3f465d..022e471 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/view/LoginQRView.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/view/LoginQRView.kt @@ -25,6 +25,8 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog @@ -40,7 +42,7 @@ fun LoginQRView(onDismiss: () -> Unit = {}, model: LoginQRViewModel = viewModel( Surface(color = MaterialTheme.colorScheme.scrim, modifier = Modifier.fillMaxSize()) { Dialog(onDismissRequest = onDismiss) { val image by model.qrCode.collectAsState() - + val numCode by model.numCode.collectAsState() Column( modifier = Modifier.clip(RoundedCornerShape(10.dp)) @@ -51,12 +53,13 @@ fun LoginQRView(onDismiss: () -> Unit = {}, model: LoginQRViewModel = viewModel( Text( text = stringResource(R.string.scan_to_connect_to_your_tailnet), style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onSurface) + color = MaterialTheme.colorScheme.onSurface, + textAlign = TextAlign.Center) + Box( modifier = Modifier.size(200.dp) - .background(MaterialTheme.colorScheme.onSurface) - .fillMaxWidth(), + .background(MaterialTheme.colorScheme.onSurface), contentAlignment = Alignment.Center) { image?.let { Image( @@ -65,7 +68,28 @@ fun LoginQRView(onDismiss: () -> Unit = {}, model: LoginQRViewModel = viewModel( modifier = Modifier.fillMaxSize()) } } - Button(onClick = onDismiss) { Text(text = stringResource(R.string.dismiss)) } + Text( + text = stringResource(R.string.enter_code_to_connect_to_tailnet), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface) + + numCode?.let { + Box( + modifier = + Modifier + .clip(RoundedCornerShape(6.dp)) + .background(MaterialTheme.colorScheme.primary.copy(alpha = 0.1f)), + contentAlignment = Alignment.Center) { + Text( + text =it, + style = + MaterialTheme.typography.bodyLarge.copy(fontWeight = FontWeight.Bold), + color = MaterialTheme.colorScheme.onSurface) + } + } + Button(onClick = onDismiss, modifier = Modifier.padding(top = 16.dp)) { + Text(text = stringResource(R.string.dismiss)) + } } } } @@ -76,5 +100,6 @@ fun LoginQRView(onDismiss: () -> Unit = {}, model: LoginQRViewModel = viewModel( fun LoginQRViewPreview() { val vm = LoginQRViewModel() vm.qrCode.set(vm.generateQRCode("https://tailscale.com", 200, 0)) + vm.numCode.set("123456789") AppTheme { LoginQRView({}, vm) } } diff --git a/android/src/main/java/com/tailscale/ipn/ui/view/MDMSettingsDebugView.kt b/android/src/main/java/com/tailscale/ipn/ui/view/MDMSettingsDebugView.kt index ec6a68a..ce8e6f4 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/view/MDMSettingsDebugView.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/view/MDMSettingsDebugView.kt @@ -25,7 +25,10 @@ import com.tailscale.ipn.ui.viewModel.IpnViewModel @OptIn(ExperimentalMaterial3Api::class) @Composable -fun MDMSettingsDebugView(backToSettings: BackNavigation, model: IpnViewModel = viewModel()) { +fun MDMSettingsDebugView( + backToSettings: BackNavigation, + @Suppress("UNUSED_PARAMETER") model: IpnViewModel = viewModel() +) { Scaffold(topBar = { Header(R.string.current_mdm_settings, onBack = backToSettings) }) { innerPadding -> LazyColumn(modifier = Modifier.padding(innerPadding)) { diff --git a/android/src/main/java/com/tailscale/ipn/ui/view/MainView.kt b/android/src/main/java/com/tailscale/ipn/ui/view/MainView.kt index c12b1b1..2c1ef27 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/view/MainView.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/view/MainView.kt @@ -21,15 +21,13 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.statusBars +import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.outlined.ArrowDropDown -import androidx.compose.material.icons.outlined.Clear -import androidx.compose.material.icons.outlined.Close import androidx.compose.material.icons.outlined.Lock -import androidx.compose.material.icons.outlined.Search import androidx.compose.material.icons.outlined.Settings import androidx.compose.material3.Button import androidx.compose.material3.DropdownMenu @@ -41,7 +39,6 @@ import androidx.compose.material3.ListItem import androidx.compose.material3.ListItemDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet -import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -59,7 +56,6 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalClipboardManager -import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.SpanStyle @@ -87,13 +83,11 @@ import com.tailscale.ipn.ui.theme.exitNodeToggleButton import com.tailscale.ipn.ui.theme.listItem import com.tailscale.ipn.ui.theme.minTextSize import com.tailscale.ipn.ui.theme.primaryListItem -import com.tailscale.ipn.ui.theme.searchBarColors import com.tailscale.ipn.ui.theme.secondaryButton import com.tailscale.ipn.ui.theme.short import com.tailscale.ipn.ui.theme.surfaceContainerListItem import com.tailscale.ipn.ui.theme.warningButton import com.tailscale.ipn.ui.theme.warningListItem -import com.tailscale.ipn.ui.util.AndroidTVUtil.isAndroidTV import com.tailscale.ipn.ui.util.AutoResizingText import com.tailscale.ipn.ui.util.Lists import com.tailscale.ipn.ui.util.LoadingIndicator @@ -109,7 +103,8 @@ data class MainViewNavigation( val onNavigateToSettings: () -> Unit, val onNavigateToPeerDetails: (Tailcfg.Node) -> Unit, val onNavigateToExitNodes: () -> Unit, - val onNavigateToHealth: () -> Unit + val onNavigateToHealth: () -> Unit, + val onNavigateToSearch: () -> Unit, ) @OptIn(ExperimentalPermissionsApi::class, ExperimentalMaterial3Api::class) @@ -142,7 +137,7 @@ fun MainView( val showKeyExpiry by viewModel.showExpiry.collectAsState(initial = false) // Hide the header only on Android TV when the user needs to login - val hideHeader = (isAndroidTV() && state == Ipn.State.NeedsLogin) + val hideHeader = (/*isAndroidTV() && */ state == Ipn.State.NeedsLogin) ListItem( colors = MaterialTheme.colorScheme.surfaceContainerListItem, @@ -187,20 +182,16 @@ fun MainView( } }, trailingContent = { - Box(modifier = Modifier.weight(1f), contentAlignment = Alignment.CenterEnd) { + Box(modifier = Modifier.padding(8.dp), contentAlignment = Alignment.CenterEnd) { when (user) { null -> SettingsButton { navigation.onNavigateToSettings() } - else -> - Box( - contentAlignment = Alignment.Center, - modifier = - Modifier.size(42.dp).clip(CircleShape).clickable { - navigation.onNavigateToSettings() - }) { - Avatar(profile = user, size = 36) { - navigation.onNavigateToSettings() - } - } + else -> { + Avatar( + profile = user, + size = 36, + { navigation.onNavigateToSettings() }, + isFocusable = true) + } } } }) @@ -224,7 +215,7 @@ fun MainView( PeerList( viewModel = viewModel, onNavigateToPeerDetails = navigation.onNavigateToPeerDetails, - onSearch = { viewModel.searchPeers(it) }) + onSearchBarClick = navigation.onNavigateToSearch) } Ipn.State.NoState, Ipn.State.Starting -> StartingView() @@ -232,6 +223,9 @@ fun MainView( ConnectView( state, isPrepared, + // If Tailscale is stopping, don't automatically restart; wait for user to take + // action (eg, if the user connected to another VPN). + state != Ipn.State.Stopping, user, { viewModel.toggleVpn() }, { viewModel.login() }, @@ -242,7 +236,7 @@ fun MainView( } } - currentPingDevice?.let { peer -> + currentPingDevice?.let { _ -> ModalBottomSheet(onDismissRequest = { viewModel.onPingDismissal() }) { PingView(model = viewModel.pingViewModel) } @@ -407,6 +401,7 @@ fun StartingView() { fun ConnectView( state: Ipn.State, isPrepared: Boolean, + shouldStartAutomatically: Boolean, user: IpnLocal.LoginProfile?, connectAction: () -> Unit, loginAction: () -> Unit, @@ -415,7 +410,7 @@ fun ConnectView( showVPNPermissionLauncherIfUnauthorized: () -> Unit ) { LaunchedEffect(isPrepared) { - if (!isPrepared) { + if (!isPrepared && shouldStartAutomatically) { showVPNPermissionLauncherIfUnauthorized() } } @@ -523,7 +518,7 @@ fun ConnectView( fun PeerList( viewModel: MainViewModel, onNavigateToPeerDetails: (Tailcfg.Node) -> Unit, - onSearch: (String) -> Unit + onSearchBarClick: () -> Unit ) { val peerList by viewModel.peers.collectAsState(initial = emptyList()) val searchTermStr by viewModel.searchTerm.collectAsState(initial = "") @@ -531,154 +526,117 @@ fun PeerList( remember { derivedStateOf { searchTermStr.isNotEmpty() && peerList.isEmpty() } }.value val netmap = viewModel.netmap.collectAsState() - - val focusManager = LocalFocusManager.current - var isFocussed by remember { mutableStateOf(false) } - var isListFocussed by remember { mutableStateOf(false) } - val expandedPeer = viewModel.expandedMenuPeer.collectAsState() val localClipboardManager = LocalClipboardManager.current + val enableSearch = true // !isAndroidTV() - val enableSearch = !isAndroidTV() - - if (enableSearch) { - Box(modifier = Modifier.fillMaxWidth().background(color = MaterialTheme.colorScheme.surface)) { - OutlinedTextField( - modifier = - Modifier.fillMaxWidth() - .padding(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 0.dp) - .onFocusChanged { isFocussed = it.isFocused }, - singleLine = true, - shape = MaterialTheme.shapes.extraLarge, - colors = MaterialTheme.colorScheme.searchBarColors, - leadingIcon = { - Icon(imageVector = Icons.Outlined.Search, contentDescription = "search") - }, - trailingIcon = { - if (isFocussed) { - IconButton( - onClick = { - focusManager.clearFocus() - onSearch("") - }) { - Icon( - imageVector = - if (searchTermStr.isEmpty()) Icons.Outlined.Close - else Icons.Outlined.Clear, - contentDescription = "clear search", - tint = MaterialTheme.colorScheme.onSurfaceVariant) - } - } - }, - placeholder = { - Text( - text = stringResource(id = R.string.search), - style = MaterialTheme.typography.bodyLarge, - maxLines = 1) - }, - value = searchTermStr, - onValueChange = { onSearch(it) }) + Column(modifier = Modifier.fillMaxSize()) { + if (enableSearch) { + SearchWithDynamicSuggestions(viewModel, onSearchBarClick) + + Spacer(modifier = Modifier.height(if (showNoResults) 0.dp else 8.dp)) } - } - LazyColumn( - modifier = - Modifier.fillMaxSize() - .onFocusChanged { isListFocussed = it.isFocused } - .background(color = MaterialTheme.colorScheme.surface)) { - if (showNoResults) { - item { - Spacer( - Modifier.height(16.dp) - .fillMaxSize() - .focusable(false) - .background(color = MaterialTheme.colorScheme.surface)) - - Lists.LargeTitle( - stringResource(id = R.string.no_results), - bottomPadding = 8.dp, - style = MaterialTheme.typography.bodyMedium, - fontWeight = FontWeight.Light) + // Peers display + LazyColumn( + modifier = + Modifier.fillMaxWidth() + .weight(1f) // LazyColumn gets the remaining vertical space + .onFocusChanged { isListFocussed = it.isFocused } + .background(color = MaterialTheme.colorScheme.surface)) { + + // Handle case when no results are found + if (showNoResults) { + item { + Spacer( + Modifier.height(16.dp) + .fillMaxSize() + .focusable(false) + .background(color = MaterialTheme.colorScheme.surface)) + Lists.LargeTitle( + stringResource(id = R.string.no_results), + bottomPadding = 8.dp, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Light) + } } - } - var first = true - peerList.forEach { peerSet -> - if (!first) { - item(key = "user_divider_${peerSet.user?.ID ?: 0L}") { Lists.ItemDivider() } - } - first = false + // Iterate over peer sets to display them + var first = true + peerList.forEach { peerSet -> + if (!first) { + item(key = "user_divider_${peerSet.user?.ID ?: 0L}") { Lists.ItemDivider() } + } + first = false - // Sticky headers are a bit broken on Android TV - they hide their content - if (isAndroidTV()) { + // if (isAndroidTV()) { item { NodesSectionHeader(peerSet = peerSet) } - } else { - stickyHeader { NodesSectionHeader(peerSet = peerSet) } - } + /* } else { + stickyHeader { NodesSectionHeader(peerSet = peerSet) } + }*/ - itemsWithDividers(peerSet.peers, key = { it.StableID }) { peer -> - ListItem( - modifier = - Modifier.combinedClickable( - onClick = { onNavigateToPeerDetails(peer) }, - onLongClick = { viewModel.expandedMenuPeer.set(peer) }), - colors = MaterialTheme.colorScheme.listItem, - headlineContent = { - Row(verticalAlignment = Alignment.CenterVertically) { - Box( - modifier = - Modifier.padding(top = 2.dp) - .size(10.dp) - .background( - color = peer.connectedColor(netmap.value), - shape = RoundedCornerShape(percent = 50))) {} - Spacer(modifier = Modifier.size(8.dp)) - Text(text = peer.displayName, style = MaterialTheme.typography.titleMedium) - DropdownMenu( - expanded = expandedPeer.value?.StableID == peer.StableID, - onDismissRequest = { viewModel.hidePeerDropdownMenu() }) { - DropdownMenuItem( - leadingIcon = { - Icon( - painter = painterResource(R.drawable.clipboard), - contentDescription = null) - }, - text = { Text(text = stringResource(R.string.copy_ip_address)) }, - onClick = { - viewModel.copyIpAddress(peer, localClipboardManager) - viewModel.hidePeerDropdownMenu() - }) - - netmap.value?.let { netMap -> - if (!peer.isSelfNode(netMap)) { - // Don't show the ping item for the self-node - DropdownMenuItem( - leadingIcon = { - Icon( - painter = painterResource(R.drawable.timer), - contentDescription = null) - }, - text = { Text(text = stringResource(R.string.ping)) }, - onClick = { - viewModel.hidePeerDropdownMenu() - viewModel.startPing(peer) - }) + itemsWithDividers(peerSet.peers, key = { it.StableID }) { peer -> + ListItem( + modifier = + Modifier.combinedClickable( + onClick = { onNavigateToPeerDetails(peer) }, + onLongClick = { viewModel.expandedMenuPeer.set(peer) }), + colors = MaterialTheme.colorScheme.listItem, + headlineContent = { + Row(verticalAlignment = Alignment.CenterVertically) { + Box( + modifier = + Modifier.padding(top = 2.dp) + .size(10.dp) + .background( + color = peer.connectedColor(netmap.value), + shape = RoundedCornerShape(percent = 50))) {} + Spacer(modifier = Modifier.size(8.dp)) + Text(text = peer.displayName, style = MaterialTheme.typography.titleMedium) + DropdownMenu( + expanded = expandedPeer.value?.StableID == peer.StableID, + onDismissRequest = { viewModel.hidePeerDropdownMenu() }) { + DropdownMenuItem( + leadingIcon = { + Icon( + painter = painterResource(R.drawable.clipboard), + contentDescription = null) + }, + text = { Text(text = stringResource(R.string.copy_ip_address)) }, + onClick = { + viewModel.copyIpAddress(peer, localClipboardManager) + viewModel.hidePeerDropdownMenu() + }) + netmap.value?.let { netMap -> + if (!peer.isSelfNode(netMap)) { + DropdownMenuItem( + leadingIcon = { + Icon( + painter = painterResource(R.drawable.timer), + contentDescription = null) + }, + text = { Text(text = stringResource(R.string.ping)) }, + onClick = { + viewModel.hidePeerDropdownMenu() + viewModel.startPing(peer) + }) + } } } - } - } - }, - supportingContent = { - Text( - text = peer.Addresses?.first()?.split("/")?.first() ?: "", - style = - MaterialTheme.typography.bodyMedium.copy( - lineHeight = MaterialTheme.typography.titleMedium.lineHeight)) - }) + } + }, + supportingContent = { + Text( + text = peer.Addresses?.first()?.split("/")?.first() ?: "", + style = + MaterialTheme.typography.bodyMedium.copy( + lineHeight = MaterialTheme.typography.titleMedium.lineHeight)) + }) + } } } - } + } } @Composable @@ -688,7 +646,7 @@ fun NodesSectionHeader(peerSet: PeerSet) { Lists.LargeTitle( peerSet.user?.DisplayName ?: stringResource(id = R.string.unknown_user), bottomPadding = 8.dp, - focusable = isAndroidTV(), + focusable = true, // isAndroidTV(), style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.SemiBold) } @@ -734,6 +692,47 @@ fun PromptPermissionsIfNecessary() { } } +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SearchWithDynamicSuggestions( + viewModel: MainViewModel, + onSearchBarClick: () -> Unit // Callback for navigating to SearchView +) { + // Prevent multiple taps + var isNavigating by remember { mutableStateOf(false) } + + // Outer Box to handle clicks + Box( + modifier = + Modifier.fillMaxWidth() + .height(56.dp) // Height matching Material Design search bar + .clip(RoundedCornerShape(28.dp)) // Fully rounded edges + .background(MaterialTheme.colorScheme.surface) // Surface background + .clickable(enabled = !isNavigating) { // Intercept taps + isNavigating = true + onSearchBarClick() // Trigger navigation + } + .padding(horizontal = 16.dp) // Padding for a clean look + ) { + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxSize()) { + // Search Icon + Icon( + imageVector = Icons.Default.Search, + contentDescription = "Search", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(start = 16.dp)) + Spacer(modifier = Modifier.width(8.dp)) + // Placeholder Text + Text( + text = "Search...", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.weight(1f) // Fill remaining space + ) + } + } +} + @Preview @Composable fun MainViewPreview() { @@ -746,6 +745,7 @@ fun MainViewPreview() { onNavigateToSettings = {}, onNavigateToPeerDetails = {}, onNavigateToExitNodes = {}, - onNavigateToHealth = {}), + onNavigateToHealth = {}, + onNavigateToSearch = {}), vm) } diff --git a/android/src/main/java/com/tailscale/ipn/ui/view/ManagedByView.kt b/android/src/main/java/com/tailscale/ipn/ui/view/ManagedByView.kt index e66e89a..7494e3e 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/view/ManagedByView.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/view/ManagedByView.kt @@ -23,14 +23,16 @@ import com.tailscale.ipn.R import com.tailscale.ipn.mdm.MDMSettings import com.tailscale.ipn.ui.viewModel.IpnViewModel +@Suppress("UNUSED_PARAMETER") @Composable fun ManagedByView(backToSettings: BackNavigation, model: IpnViewModel = viewModel()) { - Scaffold(topBar = { Header(R.string.managed_by, onBack = backToSettings) }) { innerPadding -> + Scaffold(topBar = { Header(R.string.managed_by, onBack = backToSettings) }) { _ -> Column( verticalArrangement = Arrangement.spacedBy(space = 20.dp, alignment = Alignment.CenterVertically), horizontalAlignment = Alignment.Start, - modifier = Modifier.fillMaxWidth().safeContentPadding().verticalScroll(rememberScrollState())) { + modifier = + Modifier.fillMaxWidth().safeContentPadding().verticalScroll(rememberScrollState())) { val managedByOrganization = MDMSettings.managedByOrganizationName.flow.collectAsState().value.value val managedByCaption = MDMSettings.managedByCaption.flow.collectAsState().value.value diff --git a/android/src/main/java/com/tailscale/ipn/ui/view/PeerDetails.kt b/android/src/main/java/com/tailscale/ipn/ui/view/PeerDetails.kt index 18c2156..1490e2b 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/view/PeerDetails.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/view/PeerDetails.kt @@ -47,7 +47,7 @@ import com.tailscale.ipn.ui.viewModel.PingViewModel @OptIn(ExperimentalMaterial3Api::class) @Composable fun PeerDetails( - backToHome: BackNavigation, + onNavigateBack: () -> Unit, nodeId: String, pingViewModel: PingViewModel, model: PeerDetailsViewModel = @@ -90,7 +90,7 @@ fun PeerDetails( contentDescription = "Ping device") } }, - onBack = backToHome) + onBack = onNavigateBack) }, ) { innerPadding -> LazyColumn( diff --git a/android/src/main/java/com/tailscale/ipn/ui/view/SearchView.kt b/android/src/main/java/com/tailscale/ipn/ui/view/SearchView.kt new file mode 100644 index 0000000..20f6204 --- /dev/null +++ b/android/src/main/java/com/tailscale/ipn/ui/view/SearchView.kt @@ -0,0 +1,150 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +package com.tailscale.ipn.ui.view + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material.icons.filled.Clear +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.ListItem +import androidx.compose.material3.ListItemDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SearchBar +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.unit.dp +import androidx.navigation.NavController +import com.tailscale.ipn.ui.viewModel.MainViewModel + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SearchView( + viewModel: MainViewModel, + navController: NavController, // Use NavController for navigation + onNavigateBack: () -> Unit +) { + val searchTerm by viewModel.searchTerm.collectAsState() + val filteredPeers by viewModel.peers.collectAsState() + val netmap by viewModel.netmap.collectAsState() + val keyboardController = LocalSoftwareKeyboardController.current + val focusRequester = remember { FocusRequester() } + val focusManager = LocalFocusManager.current + var expanded by rememberSaveable { mutableStateOf(true) } + + LaunchedEffect(Unit) { + focusRequester.requestFocus() + keyboardController?.show() + } + + Column( + modifier = + Modifier.fillMaxWidth().focusRequester(focusRequester).clickable { + focusRequester.requestFocus() + keyboardController?.show() + }) { + SearchBar( + modifier = Modifier.fillMaxWidth(), + query = searchTerm, + onQueryChange = { query -> + viewModel.updateSearchTerm(query) + expanded = query.isNotEmpty() + }, + onSearch = { query -> + viewModel.updateSearchTerm(query) + focusManager.clearFocus() + keyboardController?.hide() + }, + placeholder = { Text("Search") }, + leadingIcon = { + IconButton( + onClick = { + focusManager.clearFocus() + onNavigateBack() + }) { + Icon( + imageVector = Icons.Default.ArrowBack, + contentDescription = "Back", + tint = MaterialTheme.colorScheme.onSurfaceVariant) + } + }, + trailingIcon = { + if (searchTerm.isNotEmpty()) { + IconButton( + onClick = { + viewModel.updateSearchTerm("") + focusManager.clearFocus() + keyboardController?.hide() + }) { + Icon(Icons.Default.Clear, contentDescription = "Clear search") + } + } + }, + active = expanded, + onActiveChange = { expanded = it }, + content = { + Column(Modifier.verticalScroll(rememberScrollState()).fillMaxSize()) { + filteredPeers.forEach { peerSet -> + val userName = peerSet.user?.DisplayName ?: "Unknown User" + peerSet.peers.forEach { peer -> + val deviceName = peer.displayName ?: "Unknown Device" + val ipAddress = peer.Addresses?.firstOrNull()?.split("/")?.first() ?: "No IP" + + ListItem( + headlineContent = { Text(userName) }, + supportingContent = { + Column { + Row(verticalAlignment = Alignment.CenterVertically) { + val onlineColor = peer.connectedColor(netmap) + Box( + modifier = + Modifier.size(10.dp) + .background(onlineColor, shape = RoundedCornerShape(50))) + Spacer(modifier = Modifier.size(8.dp)) + Text(deviceName) + } + Text(ipAddress) + } + }, + colors = ListItemDefaults.colors(containerColor = Color.Transparent), + modifier = + Modifier.clickable { + navController.navigate("peerDetails/${peer.StableID}") + } + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 4.dp)) + } + } + } + }) + } +} diff --git a/android/src/main/java/com/tailscale/ipn/ui/view/SettingsView.kt b/android/src/main/java/com/tailscale/ipn/ui/view/SettingsView.kt index d83f50d..b89df15 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/view/SettingsView.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/view/SettingsView.kt @@ -39,6 +39,8 @@ import com.tailscale.ipn.ui.viewModel.SettingsNav import com.tailscale.ipn.ui.viewModel.SettingsViewModel import com.tailscale.ipn.ui.viewModel.VpnViewModel import com.tailscale.ipn.ui.notifier.Notifier +import com.tailscale.ipn.ui.util.AndroidTVUtil +import com.tailscale.ipn.ui.util.AppVersion @Composable fun SettingsView(settingsNav: SettingsNav, viewModel: SettingsViewModel = viewModel(), vpnViewModel: VpnViewModel = viewModel()) { @@ -95,9 +97,10 @@ fun SettingsView(settingsNav: SettingsNav, viewModel: SettingsViewModel = viewMo }, onClick = settingsNav.onNavigateToTailnetLock) } - - Lists.ItemDivider() - Setting.Text(R.string.permissions, onClick = settingsNav.onNavigateToPermissions) + if (!AndroidTVUtil.isAndroidTV()){ + Lists.ItemDivider() + Setting.Text(R.string.permissions, onClick = settingsNav.onNavigateToPermissions) + } managedByOrganization.value?.let { Lists.ItemDivider() @@ -112,7 +115,7 @@ fun SettingsView(settingsNav: SettingsNav, viewModel: SettingsViewModel = viewMo Lists.ItemDivider() Setting.Text( R.string.about_tailscale, - subtitle = "${stringResource(id = R.string.version)} ${BuildConfig.VERSION_NAME}", + subtitle = "${stringResource(id = R.string.version)} ${AppVersion.Short()}", onClick = settingsNav.onNavigateToAbout) // TODO: put a heading for the debug section diff --git a/android/src/main/java/com/tailscale/ipn/ui/view/SharedViews.kt b/android/src/main/java/com/tailscale/ipn/ui/view/SharedViews.kt index de31170..8978636 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/view/SharedViews.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/view/SharedViews.kt @@ -13,7 +13,6 @@ import androidx.compose.foundation.layout.width import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.CheckCircle -import androidx.compose.material.ripple.rememberRipple import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon @@ -21,6 +20,7 @@ import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar +import androidx.compose.material3.ripple import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember @@ -45,40 +45,44 @@ fun Header( actions: @Composable RowScope.() -> Unit = {}, onBack: (() -> Unit)? = null ) { - val f = FocusRequester() + val focusRequester = remember { FocusRequester() } - if (isAndroidTV()) { - LaunchedEffect(Unit) { f.requestFocus() } - } + if (isAndroidTV()) { + LaunchedEffect(Unit) { + focusRequester.requestFocus() + } + } - TopAppBar( - title = { - title?.let { title() } - ?: Text( - stringResource(titleRes), - style = MaterialTheme.typography.titleLarge, - color = MaterialTheme.colorScheme.onSurface) - }, - colors = MaterialTheme.colorScheme.topAppBar, - actions = actions, - navigationIcon = { onBack?.let { BackArrow(action = it, focusRequester = f) } }, - ) + TopAppBar( + title = { + title?.let { title() } + ?: Text( + stringResource(titleRes), + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface + ) + }, + colors = MaterialTheme.colorScheme.topAppBar, + actions = actions, + navigationIcon = { onBack?.let { BackArrow(action = it, focusRequester = focusRequester) } }, + ) } @Composable fun BackArrow(action: () -> Unit, focusRequester: FocusRequester) { - - Box(modifier = Modifier.padding(start = 8.dp, end = 8.dp)) { - Icon( - Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = "Go back to the previous screen", - modifier = - Modifier.focusRequester(focusRequester) + Box(modifier = Modifier.padding(start = 8.dp, end = 8.dp)) { + Icon( + Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Go back to the previous screen", + modifier = Modifier + .focusRequester(focusRequester) .clickable( interactionSource = remember { MutableInteractionSource() }, - indication = rememberRipple(bounded = false), - onClick = { action() })) - } + indication = ripple(bounded = false), + onClick = { action() } + ) + ) + } } @Composable diff --git a/android/src/main/java/com/tailscale/ipn/ui/view/TailnetLockSetupView.kt b/android/src/main/java/com/tailscale/ipn/ui/view/TailnetLockSetupView.kt index a4ff740..bf3cb79 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/view/TailnetLockSetupView.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/view/TailnetLockSetupView.kt @@ -3,6 +3,11 @@ package com.tailscale.ipn.ui.view +import androidx.compose.foundation.LocalIndication +import androidx.compose.foundation.clickable +import androidx.compose.foundation.focusable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items @@ -15,7 +20,9 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource @@ -49,13 +56,19 @@ fun TailnetLockSetupView( Scaffold(topBar = { Header(R.string.tailnet_lock, onBack = backToSettings) }) { innerPadding -> LoadingIndicator.Wrap { - LazyColumn(modifier = Modifier.padding(innerPadding)) { - item(key = "header") { ExplainerView() } - - items(items = statusItems, key = { "status_${it.title}" }) { statusItem -> - Lists.ItemDivider() + LazyColumn(modifier = Modifier.padding(innerPadding).fillMaxSize()) { + item { ExplainerView() } + items(statusItems) { statusItem -> + val interactionSource = remember { MutableInteractionSource() } ListItem( + modifier = + Modifier.focusable( + interactionSource = interactionSource) + .clickable( + interactionSource = interactionSource, + indication = LocalIndication.current + ) {}, leadingContent = { Icon( painter = painterResource(id = statusItem.icon), @@ -65,18 +78,16 @@ fun TailnetLockSetupView( headlineContent = { Text(stringResource(statusItem.title)) }) } - item(key = "nodeKey") { + item { + // Node key section Lists.SectionDivider() - ClipboardValueView( value = nodeKey, title = stringResource(R.string.node_key), subtitle = stringResource(R.string.node_key_explainer)) - } - item(key = "tailnetLockKey") { + // Tailnet lock key section Lists.SectionDivider() - ClipboardValueView( value = tailnetLockTlPubKey, title = stringResource(R.string.tailnet_lock_key), @@ -101,7 +112,7 @@ private fun ExplainerView() { @Composable fun explainerText(): AnnotatedString { - val annotatedString = buildAnnotatedString { + return buildAnnotatedString { withStyle(SpanStyle(color = MaterialTheme.colorScheme.defaultTextColor)) { append(stringResource(id = R.string.tailnet_lock_explainer)) } @@ -117,7 +128,6 @@ fun explainerText(): AnnotatedString { } pop() } - return annotatedString } @Composable diff --git a/android/src/main/java/com/tailscale/ipn/ui/view/UserView.kt b/android/src/main/java/com/tailscale/ipn/ui/view/UserView.kt index 2279a8e..0c2a3dc 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/view/UserView.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/view/UserView.kt @@ -5,6 +5,7 @@ package com.tailscale.ipn.ui.view import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.offset import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight @@ -54,17 +55,27 @@ fun UserView( leadingContent = { Avatar(profile = profile, size = 36) }, headlineContent = { AutoResizingText( - text = profile.UserProfile.DisplayName, + text = profile.UserProfile.LoginName, style = MaterialTheme.typography.titleMedium.short, minFontSize = MaterialTheme.typography.minTextSize, overflow = TextOverflow.Ellipsis) }, supportingContent = { - AutoResizingText( - text = profile.NetworkProfile?.DomainName ?: "", - style = MaterialTheme.typography.bodyMedium.short, - minFontSize = MaterialTheme.typography.minTextSize, - overflow = TextOverflow.Ellipsis) + Column { + AutoResizingText( + text = profile.NetworkProfile?.DomainName ?: "", + style = MaterialTheme.typography.bodyMedium.short, + minFontSize = MaterialTheme.typography.minTextSize, + overflow = TextOverflow.Ellipsis) + + profile.customControlServerHostname()?.let { + AutoResizingText( + text = it, + style = MaterialTheme.typography.bodyMedium.short, + minFontSize = MaterialTheme.typography.minTextSize, + overflow = TextOverflow.Ellipsis) + } + } }, trailingContent = { when (actionState) { diff --git a/android/src/main/java/com/tailscale/ipn/ui/viewModel/CustomLoginViewModel.kt b/android/src/main/java/com/tailscale/ipn/ui/viewModel/CustomLoginViewModel.kt index 0e830e6..ed2c581 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/viewModel/CustomLoginViewModel.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/viewModel/CustomLoginViewModel.kt @@ -36,7 +36,9 @@ class LoginWithCustomControlURLViewModel : CustomLoginViewModel() { // localAPIClient will use the default server if we give it a broken URL, // but we can make sure we can construct a URL from the input string and // ensure it has an http/https scheme - when (urlStr.startsWith("http") && urlStr.contains("://") && urlStr.length > 7) { + when (urlStr.startsWith("http", ignoreCase = true) && + urlStr.contains("://") && + urlStr.length > 7) { false -> { errorDialog.set(ErrorDialogType.INVALID_CUSTOM_URL) return diff --git a/android/src/main/java/com/tailscale/ipn/ui/viewModel/DNSSettingsViewModel.kt b/android/src/main/java/com/tailscale/ipn/ui/viewModel/DNSSettingsViewModel.kt index 80a95cb..5ba489a 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/viewModel/DNSSettingsViewModel.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/viewModel/DNSSettingsViewModel.kt @@ -3,7 +3,6 @@ package com.tailscale.ipn.ui.viewModel -import android.util.Log import androidx.annotation.StringRes import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable @@ -24,6 +23,7 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch +import com.tailscale.ipn.util.TSLog class DNSSettingsViewModelFactory : ViewModelProvider.Factory { @Suppress("UNCHECKED_CAST") @@ -43,7 +43,7 @@ class DNSSettingsViewModel : IpnViewModel() { .combine(Notifier.prefs) { netmap, prefs -> Pair(netmap, prefs) } .stateIn(viewModelScope) .collect { (netmap, prefs) -> - Log.d("DNSSettingsViewModel", "prefs: CorpDNS=" + prefs?.CorpDNS.toString()) + TSLog.d("DNSSettingsViewModel", "prefs: CorpDNS=" + prefs?.CorpDNS.toString()) prefs?.let { if (it.CorpDNS) { enablementState.set(DNSEnablementState.ENABLED) diff --git a/android/src/main/java/com/tailscale/ipn/ui/viewModel/IpnViewModel.kt b/android/src/main/java/com/tailscale/ipn/ui/viewModel/IpnViewModel.kt index c38b5c0..20230f6 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/viewModel/IpnViewModel.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/viewModel/IpnViewModel.kt @@ -3,7 +3,6 @@ package com.tailscale.ipn.ui.viewModel -import android.util.Log import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.tailscale.ipn.UninitializedApp @@ -16,6 +15,7 @@ import com.tailscale.ipn.ui.notifier.Notifier import com.tailscale.ipn.ui.util.AdvertisedRoutesHelper import com.tailscale.ipn.ui.util.LoadingIndicator import com.tailscale.ipn.ui.util.set +import com.tailscale.ipn.util.TSLog import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine @@ -130,15 +130,10 @@ open class IpnViewModel : ViewModel() { } .collect { nodeState -> _nodeState.value = nodeState } } - Log.d(TAG, "Created") + TSLog.d(TAG, "Created") } // VPN Control - - fun setVpnPrepared(prepared: Boolean) { - _vpnPrepared.value = prepared - } - fun startVPN() { UninitializedApp.get().startVPN() } @@ -158,8 +153,8 @@ open class IpnViewModel : ViewModel() { val loginAction = { Client(viewModelScope).startLoginInteractive { result -> result - .onSuccess { Log.d(TAG, "Login started: $it") } - .onFailure { Log.e(TAG, "Error starting login: ${it.message}") } + .onSuccess { TSLog.d(TAG, "Login started: $it") } + .onFailure { TSLog.e(TAG, "Error starting login: ${it.message}") } completionHandler(result) } } @@ -170,7 +165,7 @@ open class IpnViewModel : ViewModel() { Client(viewModelScope).editPrefs(Ipn.MaskedPrefs().apply { WantRunning = false }) { result -> result .onSuccess { loginAction() } - .onFailure { Log.e(TAG, "Error setting wantRunning to false: ${it.message}") } + .onFailure { TSLog.e(TAG, "Error setting wantRunning to false: ${it.message}") } } } @@ -187,7 +182,7 @@ open class IpnViewModel : ViewModel() { if (mdmControlURL != null) { prefs = prefs ?: Ipn.MaskedPrefs() prefs.ControlURL = mdmControlURL - Log.d(TAG, "Overriding control URL with MDM value: $mdmControlURL") + TSLog.d(TAG, "Overriding control URL with MDM value: $mdmControlURL") } prefs?.let { @@ -215,8 +210,8 @@ open class IpnViewModel : ViewModel() { fun logout(completionHandler: (Result) -> Unit = {}) { Client(viewModelScope).logout { result -> result - .onSuccess { Log.d(TAG, "Logout started: $it") } - .onFailure { Log.e(TAG, "Error starting logout: ${it.message}") } + .onSuccess { TSLog.d(TAG, "Logout started: $it") } + .onFailure { TSLog.e(TAG, "Error starting logout: ${it.message}") } completionHandler(result) } } @@ -226,14 +221,14 @@ open class IpnViewModel : ViewModel() { private fun loadUserProfiles() { Client(viewModelScope).profiles { result -> result.onSuccess(loginProfiles::set).onFailure { - Log.e(TAG, "Error loading profiles: ${it.message}") + TSLog.e(TAG, "Error loading profiles: ${it.message}") } } Client(viewModelScope).currentProfile { result -> result .onSuccess { loggedInUser.set(if (it.isEmpty()) null else it) } - .onFailure { Log.e(TAG, "Error loading current profile: ${it.message}") } + .onFailure { TSLog.e(TAG, "Error loading current profile: ${it.message}") } } } @@ -247,7 +242,7 @@ open class IpnViewModel : ViewModel() { Client(viewModelScope).editPrefs(Ipn.MaskedPrefs().apply { WantRunning = false }) { result -> result .onSuccess { switchProfile() } - .onFailure { Log.e(TAG, "Error setting wantRunning to false: ${it.message}") } + .onFailure { TSLog.e(TAG, "Error setting wantRunning to false: ${it.message}") } } } @@ -282,7 +277,7 @@ open class IpnViewModel : ViewModel() { Client(viewModelScope).setUseExitNode(true) { LoadingIndicator.stop() } } else { // This should not be possible. In this state the button is hidden - Log.e(TAG, "No exit node to disable and no prior exit node to enable") + TSLog.e(TAG, "No exit node to disable and no prior exit node to enable") } } @@ -297,7 +292,7 @@ open class IpnViewModel : ViewModel() { } Client(viewModelScope).editPrefs(newPrefs) { result -> LoadingIndicator.stop() - Log.d("RunExitNodeViewModel", "Edited prefs: $result") + TSLog.d("RunExitNodeViewModel", "Edited prefs: $result") } } } diff --git a/android/src/main/java/com/tailscale/ipn/ui/viewModel/LoginQRViewModel.kt b/android/src/main/java/com/tailscale/ipn/ui/viewModel/LoginQRViewModel.kt index a3c5dcf..36c1cbc 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/viewModel/LoginQRViewModel.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/viewModel/LoginQRViewModel.kt @@ -20,12 +20,30 @@ import kotlinx.coroutines.launch class LoginQRViewModel : IpnViewModel() { + val numCode: StateFlow = MutableStateFlow(null) val qrCode: StateFlow = MutableStateFlow(null) + // Remove this once changes to admin console allowing input code to be entered are made. init { viewModelScope.launch { Notifier.browseToURL.collect { url -> - url?.let { qrCode.set(generateQRCode(url, 200, 0)) } ?: run { qrCode.set(null) } + url?.let { + qrCode.set(generateQRCode(url, 200, 0)) + + // Extract the string after "https://login.tailscale.com/a/" + val prefix = "https://login.tailscale.com/a/" + val code = + if (it.startsWith(prefix)) { + it.removePrefix(prefix) + } else { + null + } + numCode.set(code) + } + ?: run { + qrCode.set(null) + numCode.set(null) + } } } } diff --git a/android/src/main/java/com/tailscale/ipn/ui/viewModel/MainViewModel.kt b/android/src/main/java/com/tailscale/ipn/ui/viewModel/MainViewModel.kt index 33141d3..2896b42 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/viewModel/MainViewModel.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/viewModel/MainViewModel.kt @@ -23,13 +23,18 @@ import com.tailscale.ipn.ui.util.PeerCategorizer import com.tailscale.ipn.ui.util.PeerSet import com.tailscale.ipn.ui.util.TimeUtil import com.tailscale.ipn.ui.util.set +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.launch import java.time.Duration class MainViewModelFactory(private val vpnViewModel: VpnViewModel) : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") override fun create(modelClass: Class): T { if (modelClass.isAssignableFrom(MainViewModel::class.java)) { return MainViewModel(vpnViewModel) as T @@ -38,6 +43,7 @@ class MainViewModelFactory(private val vpnViewModel: VpnViewModel) : ViewModelPr } } +@OptIn(FlowPreview::class) class MainViewModel(private val vpnViewModel: VpnViewModel) : IpnViewModel() { // The user readable state of the system @@ -51,13 +57,15 @@ class MainViewModel(private val vpnViewModel: VpnViewModel) : IpnViewModel() { private var vpnPermissionLauncher: ActivityResultLauncher? = null // The list of peers - val peers: StateFlow> = MutableStateFlow(emptyList()) + private val _peers = MutableStateFlow>(emptyList()) + val peers: StateFlow> = _peers // The current state of the IPN for determining view visibility val ipnState = Notifier.state // The active search term for filtering peers - val searchTerm: StateFlow = MutableStateFlow("") + private val _searchTerm = MutableStateFlow("") + val searchTerm: StateFlow = _searchTerm // True if we should render the key expiry bannder val showExpiry: StateFlow = MutableStateFlow(false) @@ -69,9 +77,17 @@ class MainViewModel(private val vpnViewModel: VpnViewModel) : IpnViewModel() { val isVpnPrepared: StateFlow = vpnViewModel.vpnPrepared + val isVpnActive: StateFlow = vpnViewModel.vpnActive + + var searchJob: Job? = null + // Icon displayed in the button to present the health view val healthIcon: StateFlow = MutableStateFlow(null) + fun updateSearchTerm(term: String) { + _searchTerm.value = term + } + fun hidePeerDropdownMenu() { expandedMenuPeer.set(null) } @@ -94,19 +110,24 @@ class MainViewModel(private val vpnViewModel: VpnViewModel) : IpnViewModel() { viewModelScope.launch { var previousState: State? = null - combine(Notifier.state, isVpnPrepared) { state, prepared -> state to prepared } - .collect { (currentState, prepared) -> - stateRes.set(userStringRes(currentState, previousState, prepared)) + combine(Notifier.state, isVpnActive) { state, active -> state to active } + .collect { (currentState, active) -> + // Determine the correct state resource string + stateRes.set(userStringRes(currentState, previousState, active)) + // Determine if the VPN toggle should be on val isOn = when { - prepared && currentState == State.Running || currentState == State.Starting -> + active && (currentState == State.Running || currentState == State.Starting) -> true previousState == State.NoState && currentState == State.Starting -> true else -> false } + // Update the VPN toggle state _vpnToggleState.value = isOn + + // Update the previous state previousState = currentState } } @@ -114,8 +135,12 @@ class MainViewModel(private val vpnViewModel: VpnViewModel) : IpnViewModel() { viewModelScope.launch { Notifier.netmap.collect { it -> it?.let { netmap -> - peerCategorizer.regenerateGroupedPeers(netmap) - peers.set(peerCategorizer.groupedAndFilteredPeers(searchTerm.value)) + searchJob?.cancel() + launch(Dispatchers.Default) { + peerCategorizer.regenerateGroupedPeers(netmap) + val filteredPeers = peerCategorizer.groupedAndFilteredPeers(searchTerm.value) + _peers.value = filteredPeers + } if (netmap.SelfNode.keyDoesNotExpire) { showExpiry.set(false) @@ -133,8 +158,11 @@ class MainViewModel(private val vpnViewModel: VpnViewModel) : IpnViewModel() { } viewModelScope.launch { - searchTerm.collect { term -> peers.set(peerCategorizer.groupedAndFilteredPeers(term)) } - } + searchTerm.collect { term -> + val filteredPeers = peerCategorizer.groupedAndFilteredPeers(term) + _peers.value = filteredPeers + } + } viewModelScope.launch { App.get().healthNotifier?.currentIcon?.collect { icon -> healthIcon.set(icon) } @@ -164,8 +192,12 @@ class MainViewModel(private val vpnViewModel: VpnViewModel) : IpnViewModel() { } fun searchPeers(searchTerm: String) { - this.searchTerm.set(searchTerm) - } + _searchTerm.value = searchTerm // Update the search term + viewModelScope.launch(Dispatchers.Default) { + val filteredPeers = peerCategorizer.groupedAndFilteredPeers(searchTerm) + _peers.value = filteredPeers // Update filtered peers + } +} fun setVpnPermissionLauncher(launcher: ActivityResultLauncher) { // No intent means we're already authorized @@ -173,17 +205,17 @@ class MainViewModel(private val vpnViewModel: VpnViewModel) : IpnViewModel() { } } -private fun userStringRes(currentState: State?, previousState: State?, vpnPrepared: Boolean): Int { +private fun userStringRes(currentState: State?, previousState: State?, vpnActive: Boolean): Int { return when { previousState == State.NoState && currentState == State.Starting -> R.string.starting currentState == State.NoState -> R.string.placeholder currentState == State.InUseOtherUser -> R.string.placeholder currentState == State.NeedsLogin -> - if (vpnPrepared) R.string.please_login else R.string.connect_to_vpn + if (vpnActive) R.string.please_login else R.string.connect_to_vpn currentState == State.NeedsMachineAuth -> R.string.needs_machine_auth currentState == State.Stopped -> R.string.stopped currentState == State.Starting -> R.string.starting - currentState == State.Running -> if (vpnPrepared) R.string.connected else R.string.placeholder + currentState == State.Running -> if (vpnActive) R.string.connected else R.string.placeholder else -> R.string.placeholder } } diff --git a/android/src/main/java/com/tailscale/ipn/ui/viewModel/PingViewModel.kt b/android/src/main/java/com/tailscale/ipn/ui/viewModel/PingViewModel.kt index d0ad1f2..eedd3d0 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/viewModel/PingViewModel.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/viewModel/PingViewModel.kt @@ -5,7 +5,6 @@ package com.tailscale.ipn.ui.viewModel import android.content.Context import android.os.CountDownTimer -import android.util.Log import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewModelScope @@ -16,6 +15,7 @@ import com.tailscale.ipn.ui.model.Tailcfg import com.tailscale.ipn.ui.util.ConnectionMode import com.tailscale.ipn.ui.util.set import com.tailscale.ipn.ui.view.roundedString +import com.tailscale.ipn.util.TSLog import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -39,7 +39,7 @@ class PingViewModel : ViewModel() { } override fun onFinish() { - Log.d(TAG, "Ping timer terminated") + TSLog.d(TAG, "Ping timer terminated") } } @@ -94,7 +94,7 @@ class PingViewModel : ViewModel() { response.onFailure { error -> val context: Context = App.get().applicationContext val stringError = error.toString() - Log.d(TAG, "Ping request failed: $stringError") + TSLog.d(TAG, "Ping request failed: $stringError") if (stringError.contains("timeout")) { this.errorMessage.set( context.getString( @@ -125,7 +125,7 @@ class PingViewModel : ViewModel() { } } } - statusResult.onFailure { Log.d(TAG, "Failed to fetch status: $it") } + statusResult.onFailure { TSLog.d(TAG, "Failed to fetch status: $it") } } } } diff --git a/android/src/main/java/com/tailscale/ipn/ui/viewModel/TaildropViewModel.kt b/android/src/main/java/com/tailscale/ipn/ui/viewModel/TaildropViewModel.kt index 3d55057..92bce33 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/viewModel/TaildropViewModel.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/viewModel/TaildropViewModel.kt @@ -4,7 +4,6 @@ package com.tailscale.ipn.ui.viewModel import android.content.Context -import android.util.Log import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.material3.MaterialTheme @@ -26,6 +25,7 @@ import com.tailscale.ipn.ui.util.set import com.tailscale.ipn.ui.view.ActivityIndicator import com.tailscale.ipn.ui.view.CheckedIndicator import com.tailscale.ipn.ui.view.ErrorDialogType +import com.tailscale.ipn.util.TSLog import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -144,7 +144,7 @@ class TaildropViewModel( allSharablePeers.filter { !(it.Online ?: false) }.sortedBy { it.Name } myPeers.set(onlinePeers + offlinePeers) } - .onFailure { Log.e(TAG, "Error loading targets: ${it.message}") } + .onFailure { TSLog.e(TAG, "Error loading targets: ${it.message}") } } } diff --git a/android/src/main/java/com/tailscale/ipn/ui/viewModel/VpnViewModel.kt b/android/src/main/java/com/tailscale/ipn/ui/viewModel/VpnViewModel.kt index f1e33bc..a6ee734 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/viewModel/VpnViewModel.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/viewModel/VpnViewModel.kt @@ -26,9 +26,15 @@ class VpnViewModelFactory(private val application: Application) : ViewModelProvi // application scoped because Tailscale might be toggled on and off outside of the activity // lifecycle. class VpnViewModel(application: Application) : AndroidViewModel(application) { - // Whether the VPN is prepared + // Whether the VPN is prepared. This is set to true if the VPN application is already prepared, or + // if the user has previously consented to the VPN application. This is used to determine whether + // a VPN permission launcher needs to be shown. val _vpnPrepared = MutableStateFlow(false) val vpnPrepared: StateFlow = _vpnPrepared + // Whether a VPN interface has been established. This is set by net.updateTUN upon + // VpnServiceBuilder.establish, and consumed by UI to reflect VPN state. + val _vpnActive = MutableStateFlow(false) + val vpnActive: StateFlow = _vpnActive val TAG = "VpnViewModel" init { @@ -49,7 +55,11 @@ class VpnViewModel(application: Application) : AndroidViewModel(application) { } } - fun setVpnPrepared(prepared: Boolean) { - _vpnPrepared.value = prepared + fun setVpnActive(isActive: Boolean) { + _vpnActive.value = isActive + } + + fun setVpnPrepared(isPrepared: Boolean) { + _vpnPrepared.value = isPrepared } } diff --git a/android/src/main/java/com/tailscale/ipn/util/TSLog.kt b/android/src/main/java/com/tailscale/ipn/util/TSLog.kt new file mode 100644 index 0000000..4394574 --- /dev/null +++ b/android/src/main/java/com/tailscale/ipn/util/TSLog.kt @@ -0,0 +1,44 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause +package com.tailscale.ipn.util + +import android.util.Log +import libtailscale.Libtailscale + +object TSLog { + var libtailscaleWrapper = LibtailscaleWrapper() + + fun d(tag: String?, message: String) { + Log.d(tag, message) + libtailscaleWrapper.sendLog(tag, message) + } + + fun w(tag: String, message: String) { + Log.w(tag, message) + libtailscaleWrapper.sendLog(tag, message) + } + + // Overloaded function without Throwable because Java does not support default parameters + @JvmStatic + fun e(tag: String?, message: String) { + Log.e(tag, message) + libtailscaleWrapper.sendLog(tag, message) + } + + fun e(tag: String?, message: String, throwable: Throwable? = null) { + if (throwable == null) { + Log.e(tag, message) + libtailscaleWrapper.sendLog(tag, message) + } else { + Log.e(tag, message, throwable) + libtailscaleWrapper.sendLog(tag, "$message ${throwable?.localizedMessage}") + } + } + + class LibtailscaleWrapper { + public fun sendLog(tag: String?, message: String) { + val logTag = tag ?: "" + Libtailscale.sendLog((logTag + ": " + message).toByteArray(Charsets.UTF_8)) + } + } +} diff --git a/android/src/main/res/values/strings.xml b/android/src/main/res/values/strings.xml index dfa41de..8d6657e 100644 --- a/android/src/main/res/values/strings.xml +++ b/android/src/main/res/values/strings.xml @@ -208,6 +208,8 @@ Manage Tailnet lock visibility Shows or hides the UI to run the Android device as an exit node. Run as exit node visibility + Defines an auth key that will be used for login. + Auth Key Permissions @@ -242,6 +244,7 @@ Tailscale is a mesh VPN for securely connecting your devices. All connections are device-to-device, so we never see your data. We collect and use your email address and name, as well as your device name, OS version, and IP address in order to help you to connect your devices and manage your settings. We log when you are connected to your network. Scan this QR code to log in to your tailnet + or enter this code in the Machines > Add device section of the admin console: VPN is not ready to start @@ -298,7 +301,5 @@ Only one VPN can be active, and it appears another is already running. Before starting Tailscale, disable the other VPN. Go to Settings Cancel - Defines an auth key that will be used for login. - Auth Key diff --git a/android/src/test/kotlin/com/tailcale/ipn/ui/util/.TimeUtilTest.kt.swp b/android/src/test/kotlin/com/tailcale/ipn/ui/util/.TimeUtilTest.kt.swp new file mode 100644 index 0000000..ddc7a31 Binary files /dev/null and b/android/src/test/kotlin/com/tailcale/ipn/ui/util/.TimeUtilTest.kt.swp differ diff --git a/android/src/test/kotlin/com/tailcale/ipn/ui/util/TimeUtilTest.kt b/android/src/test/kotlin/com/tailcale/ipn/ui/util/TimeUtilTest.kt index ddea99e..743e574 100644 --- a/android/src/test/kotlin/com/tailcale/ipn/ui/util/TimeUtilTest.kt +++ b/android/src/test/kotlin/com/tailcale/ipn/ui/util/TimeUtilTest.kt @@ -1,60 +1,107 @@ // Copyright (c) Tailscale Inc & AUTHORS // SPDX-License-Identifier: BSD-3-Clause + package com.tailcale.ipn.ui.util + import com.tailscale.ipn.ui.util.TimeUtil +import com.tailscale.ipn.util.TSLog +import com.tailscale.ipn.util.TSLog.LibtailscaleWrapper +import org.junit.After import org.junit.Assert.assertEquals import org.junit.Assert.assertNull +import org.junit.Before import org.junit.Test +import org.mockito.ArgumentMatchers.anyString +import org.mockito.Mockito.doNothing +import org.mockito.Mockito.mock import java.time.Duration + class TimeUtilTest { - @Test - fun durationInvalidMsUnits() { - val input = "5s10ms" - val actual = TimeUtil.duration(input) - assertNull("Should return null", actual) - } - - @Test - fun durationInvalidUsUnits() { - val input = "5s10us" - val actual = TimeUtil.duration(input) - assertNull("Should return null", actual) - } - - @Test - fun durationTestHappyPath() { - val input = arrayOf("1.0y1.0w1.0d1.0h1.0m1.0s", "1s", "1m", "1h", "1d", "1w", "1y") - val expectedSeconds = - arrayOf((31536000 + 604800 + 86400 + 3600 + 60 + 1), 1, 60, 3600, 86400, 604800, 31536000) - val expected = expectedSeconds.map { Duration.ofSeconds(it.toLong()) } - val actual = input.map { TimeUtil.duration(it) } - assertEquals("Incorrect conversion", expected, actual) - } - - @Test - fun testBadDurationString() { - val input = "1..0y1.0w1.0d1.0h1.0m1.0s" - val actual = TimeUtil.duration(input) - assertNull("Should return null", actual) - } - - @Test - fun testBadDInputString() { - val input = "1.0yy1.0w1.0d1.0h1.0m1.0s" - val actual = TimeUtil.duration(input) - assertNull("Should return null", actual) - } - - @Test - fun testIgnoreFractionalSeconds() { - val input = "10.9s" - val expectedSeconds = 10 - val expected = Duration.ofSeconds(expectedSeconds.toLong()) - val actual = TimeUtil.duration(input) - assertEquals("Should return $expectedSeconds seconds", expected, actual) - } + + private lateinit var libtailscaleWrapperMock: LibtailscaleWrapper + private lateinit var originalWrapper: LibtailscaleWrapper + + + @Before + fun setUp() { + libtailscaleWrapperMock = mock(LibtailscaleWrapper::class.java) + doNothing().`when`(libtailscaleWrapperMock).sendLog(anyString(), anyString()) + + + // Store the original wrapper so we can reset it later + originalWrapper = TSLog.libtailscaleWrapper + // Inject mock into TSLog + TSLog.libtailscaleWrapper = libtailscaleWrapperMock + } + + + @After + fun tearDown() { + // Reset TSLog after each test to avoid side effects + TSLog.libtailscaleWrapper = originalWrapper + } + + + @Test + fun durationInvalidMsUnits() { + val input = "5s10ms" + val actual = TimeUtil.duration(input) + assertNull("Should return null", actual) + } + + + @Test + fun durationInvalidUsUnits() { + val input = "5s10us" + val actual = TimeUtil.duration(input) + assertNull("Should return null", actual) + } + + + @Test + fun durationTestHappyPath() { + val input = arrayOf("1.0y1.0w1.0d1.0h1.0m1.0s", "1s", "1m", "1h", "1d", "1w", "1y") + val expectedSeconds = + arrayOf((31536000 + 604800 + 86400 + 3600 + 60 + 1), 1, 60, 3600, 86400, 604800, 31536000) + val expected = expectedSeconds.map { Duration.ofSeconds(it.toLong()) } + val actual = input.map { TimeUtil.duration(it) } + assertEquals("Incorrect conversion", expected, actual) + } + + + @Test + fun testBadDurationString() { + val input = "1..0y1.0w1.0d1.0h1.0m1.0s" + val actual = TimeUtil.duration(input) + assertNull("Should return null", actual) + } + + + @Test + fun testBadDInputString() { + val libtailscaleWrapperMock = mock(LibtailscaleWrapper::class.java) + doNothing().`when`(libtailscaleWrapperMock).sendLog(anyString(), anyString()) + + + val input = "1.0yy1.0w1.0d1.0h1.0m1.0s" + val actual = TimeUtil.duration(input) + assertNull("Should return null", actual) + } + + + @Test + fun testIgnoreFractionalSeconds() { + val input = "10.9s" + val expectedSeconds = 10 + val expected = Duration.ofSeconds(expectedSeconds.toLong()) + val actual = TimeUtil.duration(input) + assertEquals("Should return $expectedSeconds seconds", expected, actual) + } } + + + diff --git a/build-tags.sh b/build-tags.sh new file mode 100755 index 0000000..de81fc9 --- /dev/null +++ b/build-tags.sh @@ -0,0 +1,16 @@ +#!/bin/bash + +if [[ -z "$TOOLCHAIN_DIR" ]]; then + # By default, if TOOLCHAIN_DIR is unset, we assume we're + # using the Tailscale Go toolchain (github.com/tailscale/go) + # at the revision specified by go.toolchain.rev. If so, + # we tell our caller to use the "tailscale_go" build tag. + echo "tailscale_go" +else + # Otherwise, if TOOLCHAIN_DIR is specified, we assume + # we're F-Droid or something using a stock Go toolchain. + # That's fine. But we don't set the tailscale_go build tag. + # Return some no-op build tag that's non-empty for clarity + # when debugging. + echo "not_tailscale_go" +fi diff --git a/docker/DockerFile.amd64-build b/docker/DockerFile.amd64-build index 09f51e5..c4fa38a 100644 --- a/docker/DockerFile.amd64-build +++ b/docker/DockerFile.amd64-build @@ -24,7 +24,7 @@ ENV PATH $PATH:$HOME/bin:$ANDROID_HOME/platform-tools # We need some version of Go new enough to support the "embed" package # to run "go run tailscale.com/cmd/printdep" to figure out which Tailscale Go # version we need later, but otherwise this toolchain isn't used: -RUN curl -L https://go.dev/dl/go1.22.0.linux-amd64.tar.gz | tar -C /usr/local -zxv +RUN curl -L https://go.dev/dl/go1.23.0.linux-amd64.tar.gz | tar -C /usr/local -zxv RUN ln -s /usr/local/go/bin/go /usr/bin RUN mkdir -p $HOME/tailscale-android @@ -42,5 +42,5 @@ COPY android/gradle android/gradle RUN ./android/gradlew # Build the android app, bump the playstore version code, and make the tv release -CMD make release && make bump_version_code && make release-tv +CMD make clean && make release && make bump_version_code && make release-tv diff --git a/docker/DockerFile.amd64-shell b/docker/DockerFile.amd64-shell index bd67657..5f73272 100644 --- a/docker/DockerFile.amd64-shell +++ b/docker/DockerFile.amd64-shell @@ -24,7 +24,7 @@ ENV PATH $PATH:$HOME/bin:$ANDROID_HOME/platform-tools # We need some version of Go new enough to support the "embed" package # to run "go run tailscale.com/cmd/printdep" to figure out which Tailscale Go # version we need later, but otherwise this toolchain isn't used: -RUN curl -L https://go.dev/dl/go1.22.0.linux-amd64.tar.gz | tar -C /usr/local -zxv +RUN curl -L https://go.dev/dl/go1.23.0.linux-amd64.tar.gz | tar -C /usr/local -zxv RUN ln -s /usr/local/go/bin/go /usr/bin RUN mkdir -p $HOME/tailscale-android diff --git a/go.mod b/go.mod index 59d0598..7a09a28 100644 --- a/go.mod +++ b/go.mod @@ -1,13 +1,12 @@ module github.com/tailscale/tailscale-android -go 1.22.0 +go 1.23.1 require ( - github.com/tailscale/wireguard-go v0.0.0-20240731203015-71393c576b98 - golang.org/x/mobile v0.0.0-20240319015410-c58ccf4b0c87 - golang.org/x/sys v0.22.0 + github.com/tailscale/wireguard-go v0.0.0-20241113014420-4e883d38c8d3 + golang.org/x/mobile v0.0.0-20240806205939-81131f6468ab inet.af/netaddr v0.0.0-20220617031823-097006376321 - tailscale.com v1.73.0-pre.0.20240821174438-af3d3c433b67 + tailscale.com v1.77.0-pre.0.20241202172800-8d0c690f8997 ) require ( @@ -47,13 +46,13 @@ require ( github.com/gorilla/csrf v1.7.2 // indirect github.com/gorilla/securecookie v1.1.2 // indirect github.com/hdevalence/ed25519consensus v0.2.0 // indirect - github.com/illarion/gonotify v1.0.1 // indirect + github.com/illarion/gonotify/v2 v2.0.3 // indirect github.com/insomniacslk/dhcp v0.0.0-20231206064809-8c70d406f6d2 // indirect github.com/jellydator/ttlcache/v3 v3.1.0 // indirect github.com/jmespath/go-jmespath v0.4.0 // indirect github.com/josharian/native v1.1.1-0.20230202152459-5c7d0dd6ab86 // indirect github.com/jsimonetti/rtnetlink v1.4.0 // indirect - github.com/klauspost/compress v1.17.4 // indirect + github.com/klauspost/compress v1.17.11 // indirect github.com/kortschak/wol v0.0.0-20200729010619-da482cc4850a // indirect github.com/mdlayher/genetlink v1.3.2 // indirect github.com/mdlayher/netlink v1.7.2 // indirect @@ -69,28 +68,28 @@ require ( github.com/tailscale/golang-x-crypto v0.0.0-20240604161659-3fde5e568aa4 // indirect github.com/tailscale/goupnp v1.0.1-0.20210804011211-c64d0f06ea05 // indirect github.com/tailscale/hujson v0.0.0-20221223112325-20486734a56a // indirect - github.com/tailscale/netlink v1.1.1-0.20211101221916-cabfb018fe85 // indirect + github.com/tailscale/netlink v1.1.1-0.20240822203006-4d49adab4de7 // indirect github.com/tailscale/peercred v0.0.0-20240214030740-b535050b2aa4 // indirect github.com/tailscale/web-client-prebuilt v0.0.0-20240226180453-5db17b287bf1 // indirect github.com/tailscale/xnet v0.0.0-20240729143630-8497ac4dab2e // indirect github.com/tcnksm/go-httpstat v0.2.0 // indirect github.com/u-root/uio v0.0.0-20240118234441-a3c409a6018e // indirect - github.com/vishvananda/netlink v1.2.1-beta.2 // indirect github.com/vishvananda/netns v0.0.4 // indirect github.com/x448/float16 v0.8.4 // indirect go4.org/intern v0.0.0-20211027215823-ae77deb06f29 // indirect go4.org/mem v0.0.0-20220726221520-4f986261bf13 // indirect go4.org/netipx v0.0.0-20231129151722-fdeea329fbba // indirect go4.org/unsafe/assume-no-moving-gc v0.0.0-20230525183740-e7c30c78aeb2 // indirect - golang.org/x/crypto v0.25.0 // indirect + golang.org/x/crypto v0.26.0 // indirect golang.org/x/exp v0.0.0-20240119083558-1b970713d09a // indirect - golang.org/x/mod v0.19.0 // indirect - golang.org/x/net v0.27.0 // indirect - golang.org/x/sync v0.7.0 // indirect - golang.org/x/term v0.22.0 // indirect - golang.org/x/text v0.16.0 // indirect + golang.org/x/mod v0.20.0 // indirect + golang.org/x/net v0.28.0 // indirect + golang.org/x/sync v0.9.0 // indirect + golang.org/x/sys v0.27.0 // indirect + golang.org/x/term v0.23.0 // indirect + golang.org/x/text v0.17.0 // indirect golang.org/x/time v0.5.0 // indirect - golang.org/x/tools v0.23.0 // indirect + golang.org/x/tools v0.24.0 // indirect golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect golang.zx2c4.com/wireguard/windows v0.5.3 // indirect gvisor.dev/gvisor v0.0.0-20240722211153-64c016c92987 // indirect diff --git a/go.sum b/go.sum index 85869a0..3e87841 100644 --- a/go.sum +++ b/go.sum @@ -86,8 +86,8 @@ github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kX github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo= github.com/hdevalence/ed25519consensus v0.2.0 h1:37ICyZqdyj0lAZ8P4D1d1id3HqbbG1N3iBb1Tb4rdcU= github.com/hdevalence/ed25519consensus v0.2.0/go.mod h1:w3BHWjwJbFU29IRHL1Iqkw3sus+7FctEyM4RqDxYNzo= -github.com/illarion/gonotify v1.0.1 h1:F1d+0Fgbq/sDWjj/r66ekjDG+IDeecQKUFH4wNwsoio= -github.com/illarion/gonotify v1.0.1/go.mod h1:zt5pmDofZpU1f8aqlK0+95eQhoEAn/d4G4B/FjVW4jE= +github.com/illarion/gonotify/v2 v2.0.3 h1:B6+SKPo/0Sw8cRJh1aLzNEeNVFfzE3c6N+o+vyxM+9A= +github.com/illarion/gonotify/v2 v2.0.3/go.mod h1:38oIJTgFqupkEydkkClkbL6i5lXV/bxdH9do5TALPEE= github.com/insomniacslk/dhcp v0.0.0-20231206064809-8c70d406f6d2 h1:9K06NfxkBh25x56yVhWWlKFE8YpicaSfHwoV8SFbueA= github.com/insomniacslk/dhcp v0.0.0-20231206064809-8c70d406f6d2/go.mod h1:3A9PQ1cunSDF/1rbTq99Ts4pVnycWg+vlPkfeD2NLFI= github.com/jellydator/ttlcache/v3 v3.1.0 h1:0gPFG0IHHP6xyUyXq+JaD8fwkDCqgqwohXNJBcYE71g= @@ -101,8 +101,8 @@ github.com/josharian/native v1.1.1-0.20230202152459-5c7d0dd6ab86 h1:elKwZS1OcdQ0 github.com/josharian/native v1.1.1-0.20230202152459-5c7d0dd6ab86/go.mod h1:aFAMtuldEgx/4q7iSGazk22+IcgvtiC+HIimFO9XlS8= github.com/jsimonetti/rtnetlink v1.4.0 h1:Z1BF0fRgcETPEa0Kt0MRk3yV5+kF1FWTni6KUFKrq2I= github.com/jsimonetti/rtnetlink v1.4.0/go.mod h1:5W1jDvWdnthFJ7fxYX1GMK07BUpI4oskfOqvPteYS6E= -github.com/klauspost/compress v1.17.4 h1:Ej5ixsIri7BrIjBkRZLTo6ghwrEtHFk7ijlczPW4fZ4= -github.com/klauspost/compress v1.17.4/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM= +github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc= +github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0= github.com/kortschak/wol v0.0.0-20200729010619-da482cc4850a h1:+RR6SqnTkDLWyICxS1xpjCi/3dhyV+TgZwA6Ww3KncQ= github.com/kortschak/wol v0.0.0-20200729010619-da482cc4850a/go.mod h1:YTtCCM3ryyfiu4F7t8HQ1mxvp1UBdWM2r6Xa+nGWvDk= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= @@ -131,8 +131,8 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRI github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/prometheus-community/pro-bing v0.4.0 h1:YMbv+i08gQz97OZZBwLyvmmQEEzyfyrrjEaAchdy3R4= github.com/prometheus-community/pro-bing v0.4.0/go.mod h1:b7wRYZtCcPmt4Sz319BykUU241rWLe1VFXyiyWK/dH4= -github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M= -github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA= +github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= +github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= github.com/safchain/ethtool v0.3.0 h1:gimQJpsI6sc1yIqP/y8GYgiXn/NjgvpM0RNoWLVVmP0= github.com/safchain/ethtool v0.3.0/go.mod h1:SA9BwrgyAqNo7M+uaL6IYbxpm5wk3L7Mm6ocLW+CJUs= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= @@ -150,14 +150,14 @@ github.com/tailscale/goupnp v1.0.1-0.20210804011211-c64d0f06ea05 h1:4chzWmimtJPx github.com/tailscale/goupnp v1.0.1-0.20210804011211-c64d0f06ea05/go.mod h1:PdCqy9JzfWMJf1H5UJW2ip33/d4YkoKN0r67yKH1mG8= github.com/tailscale/hujson v0.0.0-20221223112325-20486734a56a h1:SJy1Pu0eH1C29XwJucQo73FrleVK6t4kYz4NVhp34Yw= github.com/tailscale/hujson v0.0.0-20221223112325-20486734a56a/go.mod h1:DFSS3NAGHthKo1gTlmEcSBiZrRJXi28rLNd/1udP1c8= -github.com/tailscale/netlink v1.1.1-0.20211101221916-cabfb018fe85 h1:zrsUcqrG2uQSPhaUPjUQwozcRdDdSxxqhNgNZ3drZFk= -github.com/tailscale/netlink v1.1.1-0.20211101221916-cabfb018fe85/go.mod h1:NzVQi3Mleb+qzq8VmcWpSkcSYxXIg0DkI6XDzpVkhJ0= +github.com/tailscale/netlink v1.1.1-0.20240822203006-4d49adab4de7 h1:uFsXVBE9Qr4ZoF094vE6iYTLDl0qCiKzYXlL6UeWObU= +github.com/tailscale/netlink v1.1.1-0.20240822203006-4d49adab4de7/go.mod h1:NzVQi3Mleb+qzq8VmcWpSkcSYxXIg0DkI6XDzpVkhJ0= github.com/tailscale/peercred v0.0.0-20240214030740-b535050b2aa4 h1:Gz0rz40FvFVLTBk/K8UNAenb36EbDSnh+q7Z9ldcC8w= github.com/tailscale/peercred v0.0.0-20240214030740-b535050b2aa4/go.mod h1:phI29ccmHQBc+wvroosENp1IF9195449VDnFDhJ4rJU= github.com/tailscale/web-client-prebuilt v0.0.0-20240226180453-5db17b287bf1 h1:tdUdyPqJ0C97SJfjB9tW6EylTtreyee9C44de+UBG0g= github.com/tailscale/web-client-prebuilt v0.0.0-20240226180453-5db17b287bf1/go.mod h1:agQPE6y6ldqCOui2gkIh7ZMztTkIQKH049tv8siLuNQ= -github.com/tailscale/wireguard-go v0.0.0-20240731203015-71393c576b98 h1:RNpJrXfI5u6e+uzyIzvmnXbhmhdRkVf//90sMBH3lso= -github.com/tailscale/wireguard-go v0.0.0-20240731203015-71393c576b98/go.mod h1:BOm5fXUBFM+m9woLNBoxI9TaBXXhGNP50LX/TGIvGb4= +github.com/tailscale/wireguard-go v0.0.0-20241113014420-4e883d38c8d3 h1:dmoPb3dG27tZgMtrvqfD/LW4w7gA6BSWl8prCPNmkCQ= +github.com/tailscale/wireguard-go v0.0.0-20241113014420-4e883d38c8d3/go.mod h1:BOm5fXUBFM+m9woLNBoxI9TaBXXhGNP50LX/TGIvGb4= github.com/tailscale/xnet v0.0.0-20240729143630-8497ac4dab2e h1:zOGKqN5D5hHhiYUp091JqK7DPCqSARyUfduhGUY8Bek= github.com/tailscale/xnet v0.0.0-20240729143630-8497ac4dab2e/go.mod h1:orPd6JZXXRyuDusYilywte7k094d7dycXXU5YnWsrwg= github.com/tc-hib/winres v0.2.1 h1:YDE0FiP0VmtRaDn7+aaChp1KiF4owBiJa5l964l5ujA= @@ -166,8 +166,6 @@ github.com/tcnksm/go-httpstat v0.2.0 h1:rP7T5e5U2HfmOBmZzGgGZjBQ5/GluWUylujl0tJ0 github.com/tcnksm/go-httpstat v0.2.0/go.mod h1:s3JVJFtQxtBEBC9dwcdTTXS9xFnM3SXAZwPG41aurT8= github.com/u-root/uio v0.0.0-20240118234441-a3c409a6018e h1:BA9O3BmlTmpjbvajAwzWx4Wo2TRVdpPXZEeemGQcajw= github.com/u-root/uio v0.0.0-20240118234441-a3c409a6018e/go.mod h1:eLL9Nub3yfAho7qB0MzZizFhTU2QkLeoVsWdHtDW264= -github.com/vishvananda/netlink v1.2.1-beta.2 h1:Llsql0lnQEbHj0I1OuKyp8otXp0r3q0mPkuhwHfStVs= -github.com/vishvananda/netlink v1.2.1-beta.2/go.mod h1:twkDnbuQxJYemMlGd4JFIcuhgX83tXhKS2B/PRMpOho= github.com/vishvananda/netns v0.0.0-20200728191858-db3c7e526aae/go.mod h1:DD4vA1DwXk04H54A1oHXtwZmA0grkVMdPxx/VGLCah0= github.com/vishvananda/netns v0.0.4 h1:Oeaw1EM2JMxD51g9uhtC0D7erkIjgmj8+JZc26m1YX8= github.com/vishvananda/netns v0.0.4/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM= @@ -189,27 +187,27 @@ go4.org/unsafe/assume-no-moving-gc v0.0.0-20230525183740-e7c30c78aeb2/go.mod h1: golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/crypto v0.25.0 h1:ypSNr+bnYL2YhwoMt2zPxHFmbAN1KZs/njMG3hxUp30= -golang.org/x/crypto v0.25.0/go.mod h1:T+wALwcMOSE0kXgUAnPAHqTLW+XHgcELELW8VaDgm/M= +golang.org/x/crypto v0.26.0 h1:RrRspgV4mU+YwB4FYnuBoKsUapNIL5cohGAmSH3azsw= +golang.org/x/crypto v0.26.0/go.mod h1:GY7jblb9wI+FOo5y8/S2oY4zWP07AkOJ4+jxCqdqn54= golang.org/x/exp v0.0.0-20240119083558-1b970713d09a h1:Q8/wZp0KX97QFTc2ywcOE0YRjZPVIx+MXInMzdvQqcA= golang.org/x/exp v0.0.0-20240119083558-1b970713d09a/go.mod h1:idGWGoKP1toJGkd5/ig9ZLuPcZBC3ewk7SzmH0uou08= -golang.org/x/image v0.18.0 h1:jGzIakQa/ZXI1I0Fxvaa9W7yP25TqT6cHIHn+6CqvSQ= -golang.org/x/image v0.18.0/go.mod h1:4yyo5vMFQjVjUcVk4jEQcU9MGy/rulF5WvUILseCM2E= -golang.org/x/mobile v0.0.0-20240319015410-c58ccf4b0c87 h1:dm00oNtDy265HReLTARPfIDXTRb2IG0jqQVpn7p5MKE= -golang.org/x/mobile v0.0.0-20240319015410-c58ccf4b0c87/go.mod h1:DN+F2TpepQEh5goqWnM3gopfFakSWM8OmHiz0rPRjT4= +golang.org/x/image v0.19.0 h1:D9FX4QWkLfkeqaC62SonffIIuYdOk/UE2XKUBgRIBIQ= +golang.org/x/image v0.19.0/go.mod h1:y0zrRqlQRWQ5PXaYCOMLTW2fpsxZ8Qh9I/ohnInJEys= +golang.org/x/mobile v0.0.0-20240806205939-81131f6468ab h1:KONOFF8Uy3b60HEzOsGnNghORNhY4ImyOx0PGm73K9k= +golang.org/x/mobile v0.0.0-20240806205939-81131f6468ab/go.mod h1:udWezQGYjqrCxz5nV321pXQTx5oGbZx+khZvFjZNOPM= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.19.0 h1:fEdghXQSo20giMthA7cd28ZC+jts4amQ3YMXiP5oMQ8= -golang.org/x/mod v0.19.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/mod v0.20.0 h1:utOm6MM3R3dnawAiJgn0y+xvuYRsm1RKM/4giyfDgV0= +golang.org/x/mod v0.20.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= -golang.org/x/net v0.27.0 h1:5K3Njcw06/l2y9vpGCSdcxWOYHOUk3dVNGDXN+FvAys= -golang.org/x/net v0.27.0/go.mod h1:dDi0PyhWNoiUOrAS8uXv/vnScO4wnHQO4mj9fn/RytE= +golang.org/x/net v0.28.0 h1:a9JDOJc5GMUJ0+UDqmLT86WiEy7iWyIhz8gz8E4e5hE= +golang.org/x/net v0.28.0/go.mod h1:yqtgsTWOOnlGLG9GFRrK3++bGOUEkNBoHZc8MEDWPNg= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M= -golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.9.0 h1:fEo0HyrW1GIgZdpbhCRO0PkJajUS5H9IFUztCgEo2jQ= +golang.org/x/sync v0.9.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200217220822-9197077df867/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -222,21 +220,21 @@ golang.org/x/sys v0.0.0-20220817070843-5a390386f1f2/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.4.1-0.20230131160137-e7d7f63158de/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI= -golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/term v0.22.0 h1:BbsgPEJULsl2fV/AT3v15Mjva5yXKQDyKf+TbDz7QJk= -golang.org/x/term v0.22.0/go.mod h1:F3qCibpT5AMpCRfhfT53vVJwhLtIVHhB9XDjfFvnMI4= +golang.org/x/sys v0.27.0 h1:wBqf8DvsY9Y/2P8gAfPDEYNuS30J4lPHJxXSb/nJZ+s= +golang.org/x/sys v0.27.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/term v0.23.0 h1:F6D4vR+EHoL9/sWAWgAR1H2DcHr4PareCbAaCo1RpuU= +golang.org/x/term v0.23.0/go.mod h1:DgV24QBUrK6jhZXl+20l6UWznPlwAHm1Q1mGHtydmSk= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4= -golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= +golang.org/x/text v0.17.0 h1:XtiM5bkSOt+ewxlOE/aE/AKEHibwj/6gvWMl9Rsh0Qc= +golang.org/x/text v0.17.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk= golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0= -golang.org/x/tools v0.23.0 h1:SGsXPZ+2l4JsgaCKkx+FQ9YZ5XEtA1GZYuoDjenLjvg= -golang.org/x/tools v0.23.0/go.mod h1:pnu6ufv6vQkll6szChhK3C3L/ruaIv5eBeztNG8wtsI= +golang.org/x/tools v0.24.0 h1:J1shsA93PJUEVaUSaay7UXAyE8aimq3GW0pjlolpa24= +golang.org/x/tools v0.24.0/go.mod h1:YhNqVBIfWHdzvTLs0d8LCuMhkKUgSUKldakyV7W/WDQ= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= @@ -258,5 +256,5 @@ inet.af/netaddr v0.0.0-20220617031823-097006376321 h1:B4dC8ySKTQXasnjDTMsoCMf1sQ inet.af/netaddr v0.0.0-20220617031823-097006376321/go.mod h1:OIezDfdzOgFhuw4HuWapWq2e9l0H9tK4F1j+ETRtF3k= software.sslmate.com/src/go-pkcs12 v0.4.0 h1:H2g08FrTvSFKUj+D309j1DPfk5APnIdAQAB8aEykJ5k= software.sslmate.com/src/go-pkcs12 v0.4.0/go.mod h1:Qiz0EyvDRJjjxGyUQa2cCNZn/wMyzrRJ/qcDXOQazLI= -tailscale.com v1.73.0-pre.0.20240821174438-af3d3c433b67 h1:QiC96H1MvmzSjMn1Bs5oboovD+qXRecko3sJhbOfEr8= -tailscale.com v1.73.0-pre.0.20240821174438-af3d3c433b67/go.mod h1:v7OHtg0KLAnhOVf81Z8WrjNefj238QbFhgkWJQoKxbs= +tailscale.com v1.77.0-pre.0.20241202172800-8d0c690f8997 h1:jAL0TXstGYT1L0V2qH+zpQSEBwBGgIOVkflckrU0kqo= +tailscale.com v1.77.0-pre.0.20241202172800-8d0c690f8997/go.mod h1:OQHJodEdJx4e8lKkSpYmK2H/B1D/VN0EU7g9imiGj2k= diff --git a/go.toolchain.rev b/go.toolchain.rev index 7d064e9..500d853 100644 --- a/go.toolchain.rev +++ b/go.toolchain.rev @@ -1 +1 @@ -22ef9eb38e9a2d21b4a45f7adc75addb05f3efb8 +96578f73d04e1a231fa2a495ad3fa97747785bc6 diff --git a/libtailscale/backend.go b/libtailscale/backend.go index f108f10..c0be4bc 100644 --- a/libtailscale/backend.go +++ b/libtailscale/backend.go @@ -11,6 +11,7 @@ import ( "net/http" "os" "path/filepath" + "reflect" "runtime/debug" "sync" "sync/atomic" @@ -105,9 +106,7 @@ type settingsFunc func(*router.Config, *dns.OSConfig) error func (a *App) runBackend(ctx context.Context) error { paths.AppSharedDir.Store(a.dataDir) hostinfo.SetOSVersion(a.osVersion()) - if !a.appCtx.IsPlayVersion() { - hostinfo.SetPackage("nogoogle") - } + hostinfo.SetPackage(a.appCtx.GetInstallSource()) deviceModel := a.modelName() if a.isChromeOS() { deviceModel = "ChromeOS: " + deviceModel @@ -150,7 +149,6 @@ func (a *App) runBackend(ctx context.Context) error { cfg configPair state ipn.State networkMap *netmap.NetworkMap - service IPNService ) stateCh := make(chan ipn.State) @@ -168,38 +166,26 @@ func (a *App) runBackend(ctx context.Context) error { select { case s := <-stateCh: state = s - if cfg.rcfg != nil && state >= ipn.Starting && service != nil { + if state >= ipn.Starting && vpnService.service != nil && b.isConfigNonNilAndDifferent(cfg.rcfg, cfg.dcfg) { // On state change, check if there are router or config changes requiring an update to VPNBuilder - if err := b.updateTUN(service, cfg.rcfg, cfg.dcfg); err != nil { + if err := b.updateTUN(cfg.rcfg, cfg.dcfg); err != nil { if errors.Is(err, errMultipleUsers) { // TODO: surface error to user } - log.Printf("VPN update failed: %v", err) - - mp := new(ipn.MaskedPrefs) - mp.WantRunning = false - mp.WantRunningSet = true - - _, err := a.EditPrefs(*mp) - if err != nil { - log.Printf("localapi edit prefs error %v", err) - } - - b.lastCfg = nil - b.CloseTUNs() + a.closeVpnService(err, b) } } case n := <-netmapCh: networkMap = n case c := <-configs: cfg = c - if b == nil || service == nil || cfg.rcfg == nil { + if vpnService.service == nil || !b.isConfigNonNilAndDifferent(cfg.rcfg, cfg.dcfg) { configErrs <- nil break } - configErrs <- b.updateTUN(service, cfg.rcfg, cfg.dcfg) + configErrs <- b.updateTUN(cfg.rcfg, cfg.dcfg) case s := <-onVPNRequested: - if service != nil && service.ID() == s.ID() { + if vpnService.service != nil && vpnService.service.ID() == s.ID() { // Still the same VPN instance, do nothing break } @@ -228,29 +214,24 @@ func (a *App) runBackend(ctx context.Context) error { // See https://github.com/tailscale/corp/issues/13814 b.backend.DebugRebind() - service = s + vpnService.service = s if networkMap != nil { // TODO } - if cfg.rcfg != nil && state >= ipn.Starting { - if err := b.updateTUN(service, cfg.rcfg, cfg.dcfg); err != nil { - log.Printf("VPN update failed: %v", err) - service.Close() - b.lastCfg = nil - b.CloseTUNs() + if state >= ipn.Starting && b.isConfigNonNilAndDifferent(cfg.rcfg, cfg.dcfg) { + if err := b.updateTUN(cfg.rcfg, cfg.dcfg); err != nil { + a.closeVpnService(err, b) } } case s := <-onDisconnect: b.CloseTUNs() - if service != nil && service.ID() == s.ID() { + if vpnService.service != nil && vpnService.service.ID() == s.ID() { netns.SetAndroidProtectFunc(nil) - service = nil + vpnService.service = nil } case i := <-onDNSConfigChanged: - if b != nil { - go b.NetworkChanged(i) - } + go b.NetworkChanged(i) } } } @@ -305,6 +286,7 @@ func (a *App) newBackend(dataDir, directFileRoot string, appCtx AppContext, stor SetSubsystem: sys.Set, NetMon: b.netMon, HealthTracker: sys.HealthTracker(), + Metrics: sys.UserMetricsRegistry(), DriveForLocal: driveimpl.NewFileSystemForLocal(logf), }) if err != nil { @@ -312,7 +294,7 @@ func (a *App) newBackend(dataDir, directFileRoot string, appCtx AppContext, stor } sys.Set(engine) b.logIDPublic = logID.Public() - ns, err := netstack.Create(logf, sys.Tun.Get(), engine, sys.MagicSock.Get(), dialer, sys.DNSManager.Get(), sys.ProxyMapper(), nil) + ns, err := netstack.Create(logf, sys.Tun.Get(), engine, sys.MagicSock.Get(), dialer, sys.DNSManager.Get(), sys.ProxyMapper()) if err != nil { return nil, fmt.Errorf("netstack.Create: %w", err) } @@ -349,3 +331,29 @@ func (a *App) newBackend(dataDir, directFileRoot string, appCtx AppContext, stor }() return b, nil } + +func (b *backend) isConfigNonNilAndDifferent(rcfg *router.Config, dcfg *dns.OSConfig) bool { + if reflect.DeepEqual(rcfg, b.lastCfg) && reflect.DeepEqual(dcfg, b.lastDNSCfg) { + b.logger.Logf("isConfigNonNilAndDifferent: no change to Routes or DNS, ignore") + return false + } + return rcfg != nil +} + +func (a *App) closeVpnService(err error, b *backend) { + log.Printf("VPN update failed: %v", err) + + mp := new(ipn.MaskedPrefs) + mp.WantRunning = false + mp.WantRunningSet = true + + if _, localApiErr := a.EditPrefs(*mp); localApiErr != nil { + log.Printf("localapi edit prefs error %v", localApiErr) + } + + b.lastCfg = nil + b.CloseTUNs() + + vpnService.service.DisconnectVPN() + vpnService.service = nil +} diff --git a/libtailscale/callbacks.go b/libtailscale/callbacks.go index e79a173..2ee022a 100644 --- a/libtailscale/callbacks.go +++ b/libtailscale/callbacks.go @@ -20,6 +20,9 @@ var ( // onDNSConfigChanged is notified when the network changes and the DNS config needs to be updated. It receives the updated interface name. onDNSConfigChanged = make(chan string, 1) + + // onLog receives Android logs to be sent to the logger + onLog = make(chan string, 10) ) // ifname is the interface name retrieved from LinkProperties on network change. An empty string is used if there is no network available. diff --git a/libtailscale/interfaces.go b/libtailscale/interfaces.go index c6ebf51..6460c9f 100644 --- a/libtailscale/interfaces.go +++ b/libtailscale/interfaces.go @@ -3,7 +3,11 @@ package libtailscale -import _ "golang.org/x/mobile/bind" +import ( + "log" + + _ "golang.org/x/mobile/bind" +) // Start starts the application, storing state in the given dataDir and using // the given appCtx. @@ -31,9 +35,11 @@ type AppContext interface { // GetModelName gets the Android device's model name. GetModelName() (string, error) - // IsPlayVersion reports whether this is the Google Play version of the app - // (as opposed to F-droid/sideloaded). - IsPlayVersion() bool + // GetInstallSource gets information about how the app was installed or updated. + GetInstallSource() string + + // ShouldUseGoogleDNSFallback reports whether or not to use Google for DNS fallback. + ShouldUseGoogleDNSFallback() bool // IsChromeOS reports whether we're on a ChromeOS device. IsChromeOS() (bool, error) @@ -73,6 +79,10 @@ type IPNService interface { NewBuilder() VPNServiceBuilder Close() + + DisconnectVPN() + + UpdateVpnStatus(bool) } // VPNServiceBuilder corresponds to Android's VpnService.Builder. @@ -162,3 +172,13 @@ func RequestVPN(service IPNService) { func ServiceDisconnect(service IPNService) { onDisconnect <- service } + +func SendLog(logstr []byte) { + select { + case onLog <- string(logstr): + // Successfully sent log + default: + // Channel is full, log not sent + log.Printf("Log %v not sent", logstr) // missing argument in original code + } +} diff --git a/libtailscale/net.go b/libtailscale/net.go index ec3f60f..85d2ef6 100644 --- a/libtailscale/net.go +++ b/libtailscale/net.go @@ -9,12 +9,11 @@ import ( "log" "net" "net/netip" - "reflect" "runtime/debug" "strings" + "syscall" "github.com/tailscale/wireguard-go/tun" - "golang.org/x/sys/unix" "inet.af/netaddr" "tailscale.com/net/dns" "tailscale.com/net/netmon" @@ -33,6 +32,15 @@ var errVPNNotPrepared = errors.New("VPN service not prepared or was revoked") // https://github.com/tailscale/tailscale/issues/2180 var errMultipleUsers = errors.New("VPN cannot be created on this device due to an Android bug with multiple users") +// VpnService contains the IPNService class from Android, the file descriptor, and whether the descriptor has been detached. +type VpnService struct { + service IPNService + fd int32 + fdDetached bool +} + +var vpnService = &VpnService{} + // Report interfaces in the device in net.Interface format. func (a *App) getInterfaces() ([]netmon.Interface, error) { var ifaces []netmon.Interface @@ -116,12 +124,7 @@ var googleDNSServers = []netip.Addr{ netip.MustParseAddr("2001:4860:4860::8844"), } -func (b *backend) updateTUN(service IPNService, rcfg *router.Config, dcfg *dns.OSConfig) error { - if reflect.DeepEqual(rcfg, b.lastCfg) && reflect.DeepEqual(dcfg, b.lastDNSCfg) { - b.logger.Logf("updateTUN: no change to Routes or DNS, ignore") - return nil - } - +func (b *backend) updateTUN(rcfg *router.Config, dcfg *dns.OSConfig) error { b.logger.Logf("updateTUN: changed") defer b.logger.Logf("updateTUN: finished") @@ -138,7 +141,7 @@ func (b *backend) updateTUN(service IPNService, rcfg *router.Config, dcfg *dns.O if len(rcfg.LocalAddrs) == 0 { return nil } - builder := service.NewBuilder() + builder := vpnService.service.NewBuilder() b.logger.Logf("updateTUN: got new builder") if err := builder.SetMTU(defaultMTU); err != nil { @@ -193,10 +196,15 @@ func (b *backend) updateTUN(service IPNService, rcfg *router.Config, dcfg *dns.O parcelFD, err := builder.Establish() if err != nil { if strings.Contains(err.Error(), "INTERACT_ACROSS_USERS") { + // Update VPN status if VPN interface cannot be created + b.logger.Logf("updateTUN: could not establish VPN because %v", err) + vpnService.service.UpdateVpnStatus(false) return errMultipleUsers } return fmt.Errorf("VpnService.Builder.establish: %v", err) } + log.Printf("Setting vpn activity status to true") + vpnService.service.UpdateVpnStatus(true) b.logger.Logf("updateTUN: established VPN") if parcelFD == nil { @@ -205,6 +213,9 @@ func (b *backend) updateTUN(service IPNService, rcfg *router.Config, dcfg *dns.O // detachFd. tunFD, err := parcelFD.Detach() + vpnService.fdDetached = true + vpnService.fd = tunFD + if err != nil { return fmt.Errorf("detachFd: %v", err) } @@ -213,7 +224,7 @@ func (b *backend) updateTUN(service IPNService, rcfg *router.Config, dcfg *dns.O // Create TUN device. tunDev, _, err := tun.CreateUnmonitoredTUNFromFD(int(tunFD)) if err != nil { - unix.Close(int(tunFD)) + closeFileDescriptor() return err } b.logger.Logf("updateTUN: created TUN device") @@ -226,6 +237,16 @@ func (b *backend) updateTUN(service IPNService, rcfg *router.Config, dcfg *dns.O return nil } +func closeFileDescriptor() error { + if vpnService.fd != -1 && vpnService.fdDetached { + err := syscall.Close(int(vpnService.fd)) + vpnService.fd = -1 + vpnService.fdDetached = false + return fmt.Errorf("error closing file descriptor: %w", err) + } + return nil +} + // CloseVPN closes any active TUN devices. func (b *backend) CloseTUNs() { b.lastCfg = nil @@ -258,7 +279,7 @@ func (b *backend) getDNSBaseConfig() (ret dns.OSConfig, _ error) { // DNS config are lacking, and almost all Android phones use Google // services anyway, so it's a reasonable default: it's an ecosystem the // user has selected by having an Android device. - if len(ret.Nameservers) == 0 && b.appCtx.IsPlayVersion() { + if len(ret.Nameservers) == 0 && b.appCtx.ShouldUseGoogleDNSFallback() { log.Printf("getDNSBaseConfig: none found; falling back to Google public DNS") ret.Nameservers = append(ret.Nameservers, googleDNSServers...) } diff --git a/libtailscale/tailscale.go b/libtailscale/tailscale.go index 7370726..6ae9131 100644 --- a/libtailscale/tailscale.go +++ b/libtailscale/tailscale.go @@ -134,4 +134,13 @@ func (b *backend) setupLogs(logDir string, logID logid.PrivateID, logf logger.Lo if filchErr != nil { log.Printf("SetupLogs: filch setup failed: %v", filchErr) } + + go func() { + for { + select { + case logstr := <-onLog: + b.logger.Logf(logstr) + } + } + }() } diff --git a/tool/go b/tool/go index 28734f0..3d9bd3c 100755 --- a/tool/go +++ b/tool/go @@ -8,6 +8,8 @@ if [[ "${CI:-}" == "true" && "${NOBASHDEBUG:-}" != "true" ]]; then set -x fi +tsandroid=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )/.." &> /dev/null && pwd ) + # Allow TOOLCHAINDIR to be overridden, as a special case for the fdroid build if [[ -z "${TOOLCHAINDIR}" ]]; then toolchain="$HOME/.cache/tailscale-go" @@ -29,10 +31,13 @@ if [[ -z "${TOOLCHAINDIR}" ]]; then rm -rf "$toolchain" "$toolchain.extracted" fi fi - if [[ ! -d "$toolchain" ]]; then - mkdir -p "$HOME/.cache" - read -r REV &2 "no tailscale.version file found" +if [[ -z "${VERSION_LONG}" ]]; then + exit 1 +fi +echo "-X tailscale.com/version.longStamp=${VERSION_LONG}" +echo "-X tailscale.com/version.shortStamp=${VERSION_SHORT}" +echo "-X tailscale.com/version.gitCommitStamp=${VERSION_GIT_HASH}" +echo "-X tailscale.com/version.extraGitCommitStamp=${VERSION_EXTRA_HASH}" diff --git a/version/tailscale-version.sh b/version/tailscale-version.sh deleted file mode 100755 index 16ce7ee..0000000 --- a/version/tailscale-version.sh +++ /dev/null @@ -1,43 +0,0 @@ -#!/usr/bin/env bash - -# Copyright (c) 2020 Tailscale Inc & AUTHORS All rights reserved. -# Use of this source code is governed by a BSD-style -# license that can be found in the LICENSE file. - -# Print the version tailscale repository corresponding -# to the version listed in go.mod. - -set -euo pipefail - -go_list=$(go list -m tailscale.com) -# go list outputs `tailscale.com `. Extract the version. -mod_version=${go_list#tailscale.com} - -if [ -z "$mod_version" ]; then - echo >&2 "no version reported by go list -m tailscale.com: $go_list" - exit 1 -fi - -case "$mod_version" in - *-*-*) - # A pseudo-version such as "v1.1.1-0.20201030135043-eab6e9ea4e45" - # includes the commit hash. - mod_version=${mod_version##*-*-} - ;; -esac - -tailscale_clone=$(mktemp -d -t tailscale-clone-XXXXXXXXXX) -git clone -q https://github.com/tailscale/tailscale.git "$tailscale_clone" - -cd $tailscale_clone -git reset --hard -q -git clean -d -x -f -git fetch -q --all --tags -git checkout -q ${mod_version} - -eval $(./build_dist.sh shellvars) -git_hash=$(git rev-parse HEAD) -short_hash=$(echo "$git_hash" | cut -c1-9) -echo ${VERSION_SHORT}-t${short_hash} -cd /tmp -rm -rf "$tailscale_clone"