├── .github ├── licenses.tmpl └── workflows │ ├── android.yml │ ├── go_mod_tidy.yml │ └── license-headers.yml ├── .gitignore ├── .vscode └── settings.json ├── LICENSE ├── Makefile ├── PATENTS ├── README.md ├── android ├── build.gradle ├── gradle.properties ├── gradle │ └── wrapper │ │ ├── gradle-wrapper.jar │ │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat ├── proguard-rules.pro └── src │ ├── androidTest │ └── kotlin │ │ └── com │ │ └── tailscale │ │ └── ipn │ │ ├── MainActivityTest.kt │ │ └── TestUtil.kt │ ├── main │ ├── AndroidManifest.xml │ ├── ic_launcher-playstore.png │ ├── java │ │ └── com │ │ │ └── tailscale │ │ │ └── ipn │ │ │ ├── App.kt │ │ │ ├── AppSourceChecker.kt │ │ │ ├── DnsConfig.java │ │ │ ├── IPNReceiver.java │ │ │ ├── IPNService.kt │ │ │ ├── MainActivity.kt │ │ │ ├── NetworkChangeCallback.kt │ │ │ ├── QuickToggleService.java │ │ │ ├── ShareActivity.kt │ │ │ ├── StartVPNWorker.java │ │ │ ├── StopVPNWorker.java │ │ │ ├── TaildropDirectoryStore.kt │ │ │ ├── UseExitNodeWorker.kt │ │ │ ├── VPNServiceBuilder.kt │ │ │ ├── mdm │ │ │ ├── MDMSettings.kt │ │ │ ├── MDMSettingsChangedReceiver.kt │ │ │ └── MDMSettingsDefinitions.kt │ │ │ ├── ui │ │ │ ├── Links.kt │ │ │ ├── localapi │ │ │ │ └── Client.kt │ │ │ ├── model │ │ │ │ ├── Dns.kt │ │ │ │ ├── Health.kt │ │ │ │ ├── Ipn.kt │ │ │ │ ├── IpnState.kt │ │ │ │ ├── NetMap.kt │ │ │ │ ├── Permissions.kt │ │ │ │ ├── TailCfg.kt │ │ │ │ └── Types.kt │ │ │ ├── notifier │ │ │ │ ├── HealthNotifier.kt │ │ │ │ └── Notifier.kt │ │ │ ├── theme │ │ │ │ ├── Color.kt │ │ │ │ └── Theme.kt │ │ │ ├── util │ │ │ │ ├── AdvertisedRoutesHelper.kt │ │ │ │ ├── AndroidTVUtil.kt │ │ │ │ ├── AppVersion.kt │ │ │ │ ├── AutoResizingText.kt │ │ │ │ ├── ClipboardValueView.kt │ │ │ │ ├── ComposableStringFormatter.kt │ │ │ │ ├── ConnectionMode.kt │ │ │ │ ├── DisplayAddress.kt │ │ │ │ ├── Flag.kt │ │ │ │ ├── InputStreamAdapter.kt │ │ │ │ ├── InstalledAppsManager.kt │ │ │ │ ├── Lists.kt │ │ │ │ ├── LoadingIndicator.kt │ │ │ │ ├── ModifierUtil.kt │ │ │ │ ├── OutputStreamAdapter.kt │ │ │ │ ├── PeerHelper.kt │ │ │ │ ├── PermissionsDisplayUtil.kt │ │ │ │ ├── StateFlow.kt │ │ │ │ └── TimeUtil.kt │ │ │ ├── view │ │ │ │ ├── AboutView.kt │ │ │ │ ├── Avatar.kt │ │ │ │ ├── BugReportView.kt │ │ │ │ ├── Buttons.kt │ │ │ │ ├── CustomLogin.kt │ │ │ │ ├── DNSSettingsView.kt │ │ │ │ ├── EditSubnetRouteDialogView.kt │ │ │ │ ├── ErrorDialog.kt │ │ │ │ ├── ExitNodePicker.kt │ │ │ │ ├── HealthView.kt │ │ │ │ ├── IntroView.kt │ │ │ │ ├── LoginQRView.kt │ │ │ │ ├── MDMSettingsDebugView.kt │ │ │ │ ├── MainView.kt │ │ │ │ ├── ManagedByView.kt │ │ │ │ ├── MullvadExitNodePicker.kt │ │ │ │ ├── MullvadExitNodePickerList.kt │ │ │ │ ├── MullvadInfoView.kt │ │ │ │ ├── NotificationsView.kt │ │ │ │ ├── PeerDetails.kt │ │ │ │ ├── PeerView.kt │ │ │ │ ├── PermissionsView.kt │ │ │ │ ├── PingView.kt │ │ │ │ ├── RunExitNodeView.kt │ │ │ │ ├── SearchView.kt │ │ │ │ ├── SettingsView.kt │ │ │ │ ├── SharedViews.kt │ │ │ │ ├── SplitTunnelAppPickerView.kt │ │ │ │ ├── SubnetRouteRowView.kt │ │ │ │ ├── SubnetRoutingView.kt │ │ │ │ ├── TaildropDirView.kt │ │ │ │ ├── TaildropView.kt │ │ │ │ ├── TailnetLockSetupView.kt │ │ │ │ ├── TailscaleLogoView.kt │ │ │ │ ├── TintedSwitch.kt │ │ │ │ ├── UserSwitcherView.kt │ │ │ │ └── UserView.kt │ │ │ └── viewModel │ │ │ │ ├── BugReportViewModel.kt │ │ │ │ ├── CustomLoginViewModel.kt │ │ │ │ ├── DNSSettingsViewModel.kt │ │ │ │ ├── ExitNodePickerViewModel.kt │ │ │ │ ├── HealthViewModel.kt │ │ │ │ ├── IpnViewModel.kt │ │ │ │ ├── LoginQRViewModel.kt │ │ │ │ ├── MainViewModel.kt │ │ │ │ ├── PeerDetailsViewModel.kt │ │ │ │ ├── PermissionsViewModel.kt │ │ │ │ ├── PingViewModel.kt │ │ │ │ ├── SettingsViewModel.kt │ │ │ │ ├── SplitTunnelAppPickerViewModel.kt │ │ │ │ ├── SubnetRoutingViewModel.kt │ │ │ │ ├── TaildropViewModel.kt │ │ │ │ ├── TailnetLockSetupViewModel.kt │ │ │ │ ├── UserSwitcherViewModel.kt │ │ │ │ └── VpnViewModel.kt │ │ │ └── util │ │ │ ├── FeatureFlags.kt │ │ │ ├── ShareFileHelper.kt │ │ │ └── TSLog.kt │ └── res │ │ ├── drawable-xhdpi │ │ └── tv_banner.png │ │ ├── drawable │ │ ├── android.xml │ │ ├── baseline_drive_folder_upload_24.xml │ │ ├── baseline_folder_open_24.xml │ │ ├── baseline_notifications_none_24.xml │ │ ├── check_circle.xml │ │ ├── clipboard.xml │ │ ├── computer.xml │ │ ├── globe.xml │ │ ├── ic_launcher_foreground.xml │ │ ├── ic_notification.xml │ │ ├── ic_notification_disabled.xml │ │ ├── ic_tile.xml │ │ ├── info.xml │ │ ├── link.xml │ │ ├── link_off.xml │ │ ├── mullvad_logo.png │ │ ├── pencil.xml │ │ ├── power.xml │ │ ├── single_file.xml │ │ ├── timer.xml │ │ ├── warning.xml │ │ ├── warning_rounded.xml │ │ ├── xmark.xml │ │ └── xmark_circle.xml │ │ ├── mipmap-anydpi-v26 │ │ ├── ic_launcher.xml │ │ └── ic_launcher_round.xml │ │ ├── mipmap-hdpi │ │ ├── ic_launcher.webp │ │ └── ic_launcher_round.webp │ │ ├── mipmap-mdpi │ │ ├── ic_launcher.webp │ │ └── ic_launcher_round.webp │ │ ├── mipmap-xhdpi │ │ ├── ic_launcher.webp │ │ └── ic_launcher_round.webp │ │ ├── mipmap-xxhdpi │ │ ├── ic_launcher.webp │ │ └── ic_launcher_round.webp │ │ ├── mipmap-xxxhdpi │ │ ├── ic_launcher.webp │ │ └── ic_launcher_round.webp │ │ ├── values │ │ ├── ic_launcher_background.xml │ │ ├── splash.xml │ │ ├── string-arrays.xml │ │ └── strings.xml │ │ └── xml │ │ └── app_restrictions.xml │ └── test │ ├── java │ └── android │ │ └── util │ │ └── Log.java │ └── kotlin │ └── com │ └── tailcale │ └── ipn │ └── ui │ └── util │ ├── .TimeUtilTest.kt.swp │ └── TimeUtilTest.kt ├── build-tags.sh ├── docker └── DockerFile.amd64-build ├── eclipse-formatter.xml ├── flake.lock ├── flake.nix ├── go.mod ├── go.sum ├── go.toolchain.rev ├── libtailscale ├── backend.go ├── callbacks.go ├── fileops.go ├── interfaces.go ├── localapi.go ├── log.go ├── multitun.go ├── net.go ├── notifier.go ├── store.go ├── syspolicy_handler.go ├── tailscale.go └── vpnfacade.go ├── metadata └── en-US │ ├── full_description.txt │ ├── images │ ├── featureGraphic.png │ ├── icon.png │ ├── phoneScreenshots │ │ ├── 1.png │ │ ├── 2.png │ │ ├── 3.png │ │ └── 4.png │ └── tvScreenshots │ │ ├── 1.png │ │ ├── 2.png │ │ └── 3.png │ └── short_description.txt ├── scripts ├── check_license_headers.sh └── docker-build-apt-get.sh ├── tool └── go └── version-ldflags.sh /.github/licenses.tmpl: -------------------------------------------------------------------------------- 1 | {{/* 2 | 3 | This template is used to generate the license notices published at 4 | https://github.com/tailscale/tailscale/blob/main/licenses/android.md. 5 | Publishing is managed by the go-licenses GitHub Action. Non-Go dependencies 6 | should be manually updated at the bottom of this file as needed. 7 | 8 | */}}# Tailscale for Android dependencies 9 | 10 | The following open source dependencies are used to build the [Tailscale Android 11 | Client][]. See also the dependencies in the [Tailscale CLI][]. 12 | 13 | [Tailscale Android Client]: https://github.com/tailscale/tailscale-android 14 | 15 | ## Go Packages 16 | 17 | {{ range . }} 18 | - [{{.Name}}](https://pkg.go.dev/{{.Name}}) ([{{.LicenseName}}]({{.LicenseURL}})) 19 | {{- end }} 20 | - [tailscale.com](https://pkg.go.dev/tailscale.com) ([BSD-3-Clause](https://github.com/tailscale/tailscale/blob/HEAD/LICENSE)) 21 | -------------------------------------------------------------------------------- /.github/workflows/android.yml: -------------------------------------------------------------------------------- 1 | name: Android CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | - "release-branch/*" 8 | pull_request: 9 | # all PRs on all branches 10 | 11 | jobs: 12 | build: 13 | runs-on: ubuntu-latest 14 | 15 | if: "!contains(github.event.head_commit.message, '[ci skip]')" 16 | 17 | steps: 18 | - name: Check out code 19 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 20 | - uses: actions/setup-go@0aaccfd150d50ccaeb58ebd88d36e91967a5f35b # v5.4.0 21 | with: 22 | go-version-file: "go.mod" 23 | - name: Switch to Java 17 # Note: 17 is pre-installed on ubuntu-latest 24 | uses: actions/setup-java@3a4f6e1af504cf6a31855fa899c6aa5355ba6c12 # v4.7.0 25 | with: 26 | distribution: "temurin" 27 | java-version: "17" 28 | 29 | # Clean should essentially be a no-op, but make sure that it works. 30 | - name: Clean 31 | run: make clean 32 | 33 | - name: Build APKs 34 | run: make tailscale-debug.apk 35 | 36 | - name: Run tests 37 | run: make test 38 | -------------------------------------------------------------------------------- /.github/workflows/go_mod_tidy.yml: -------------------------------------------------------------------------------- 1 | name: go mod tidy 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | - "release-branch/*" 8 | pull_request: 9 | # all PRs on all branches 10 | 11 | concurrency: 12 | group: ${{ github.workflow }}-$${{ github.head_ref || github.run_id }} 13 | cancel-in-progress: true 14 | 15 | jobs: 16 | check-go-mod-tidy: 17 | runs-on: [ubuntu-latest] 18 | timeout-minutes: 8 19 | 20 | steps: 21 | - name: Check out code 22 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 23 | 24 | - name: Set up Go 25 | uses: actions/setup-go@0aaccfd150d50ccaeb58ebd88d36e91967a5f35b # v5.4.0 26 | with: 27 | cache: false 28 | go-version-file: go.mod 29 | 30 | - name: Check 'go mod tidy' is clean 31 | run: | 32 | ./tool/go mod tidy 33 | echo 34 | echo 35 | git diff --name-only --exit-code || (echo "The files above need updating. Please run 'go mod tidy'."; exit 1) 36 | -------------------------------------------------------------------------------- /.github/workflows/license-headers.yml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | branches: 4 | - "main" 5 | - "release-branch/*" 6 | pull_request: 7 | # all PRs on all branches 8 | merge_group: 9 | branches: 10 | - "main" 11 | 12 | jobs: 13 | license_headers: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - name: checkout 17 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 18 | - name: check license headers 19 | run: ./scripts/check_license_headers.sh . 20 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Ignore Gradle project-specific cache directory 2 | .gradle 3 | 4 | # Ignore Gradle build output directory 5 | build 6 | 7 | # The destination for the Go Android archive. 8 | android/libs 9 | android_legacy/libs 10 | 11 | # Ignore ABI 12 | android/src/main/jniLibs/* 13 | 14 | # Android Studio files 15 | android_legacy/.idea 16 | android_legacy/local.properties 17 | android/.idea 18 | android/local.properties 19 | .idea 20 | 21 | # Output files from the Makefile: 22 | *.apk 23 | *.aab 24 | 25 | # Signing key 26 | tailscale.jks 27 | 28 | # android sdk dir 29 | ./android-sdk 30 | 31 | # Java profiling output 32 | *.hprof 33 | 34 | #IDE 35 | .vscode 36 | .idea 37 | 38 | # Native libraries 39 | *.stripped 40 | *.unstripped 41 | 42 | # Debug symbols 43 | *.debug 44 | 45 | libtailscale.aar 46 | libtailscale-sources.jar 47 | .DS_Store 48 | 49 | tailscale.version 50 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tailscale/tailscale-android/5f59a367e3a5573b0ce12ec42cd07fa50a98a785/.vscode/settings.json -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2020 Tailscale & AUTHORS. All rights reserved. 2 | 3 | Redistribution and use in source and binary forms, with or without 4 | modification, are permitted provided that the following conditions are 5 | met: 6 | 7 | * Redistributions of source code must retain the above copyright 8 | notice, this list of conditions and the following disclaimer. 9 | * Redistributions in binary form must reproduce the above 10 | copyright notice, this list of conditions and the following disclaimer 11 | in the documentation and/or other materials provided with the 12 | distribution. 13 | * Neither the name of Tailscale Inc. nor the names of its 14 | contributors may be used to endorse or promote products derived from 15 | this software without specific prior written permission. 16 | 17 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 18 | "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 19 | LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 20 | A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT 21 | OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 22 | SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT 23 | LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 24 | DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY 25 | THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 26 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 27 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 28 | -------------------------------------------------------------------------------- /PATENTS: -------------------------------------------------------------------------------- 1 | Additional IP Rights Grant (Patents) 2 | 3 | "This implementation" means the copyrightable works distributed by 4 | Tailscale Inc. as part of the Tailscale project. 5 | 6 | Tailscale Inc. hereby grants to You a perpetual, worldwide, 7 | non-exclusive, no-charge, royalty-free, irrevocable (except as stated 8 | in this section) patent license to make, have made, use, offer to 9 | sell, sell, import, transfer and otherwise run, modify and propagate 10 | the contents of this implementation of Tailscale, where such license 11 | applies only to those patent claims, both currently owned or 12 | controlled by Tailscale Inc. and acquired in the future, licensable 13 | by Tailscale Inc. that are necessarily infringed by this 14 | implementation of Tailscale. This grant does not include claims that 15 | would be infringed only as a consequence of further modification of 16 | this implementation. If you or your agent or exclusive licensee 17 | institute or order or agree to the institution of patent litigation 18 | against any entity (including a cross-claim or counterclaim in a 19 | lawsuit) alleging that this implementation of Tailscale or any code 20 | incorporated within this implementation of Tailscale constitutes 21 | direct or contributory patent infringement, or inducement of patent 22 | infringement, then any patent rights granted to you under this License 23 | for this implementation of Tailscale shall terminate as of the date 24 | such litigation is filed. 25 | -------------------------------------------------------------------------------- /android/gradle.properties: -------------------------------------------------------------------------------- 1 | android.defaults.buildfeatures.buildconfig=true 2 | android.nonFinalResIds=false 3 | android.nonTransitiveRClass=true 4 | android.useAndroidX=true 5 | org.gradle.jvmargs=-Xmx2g -XX:MaxMetaspaceSize=512m 6 | -------------------------------------------------------------------------------- /android/gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tailscale/tailscale-android/5f59a367e3a5573b0ce12ec42cd07fa50a98a785/android/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /android/gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionBase=GRADLE_USER_HOME 2 | distributionPath=wrapper/dists 3 | distributionSha256Sum=544c35d6bd849ae8a5ed0bcea39ba677dc40f49df7d1835561582da2009b961d 4 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip 5 | zipStoreBase=GRADLE_USER_HOME 6 | zipStorePath=wrapper/dists 7 | -------------------------------------------------------------------------------- /android/gradlew.bat: -------------------------------------------------------------------------------- 1 | @rem 2 | @rem Copyright 2015 the original author or authors. 3 | @rem 4 | @rem Licensed under the Apache License, Version 2.0 (the "License"); 5 | @rem you may not use this file except in compliance with the License. 6 | @rem You may obtain a copy of the License at 7 | @rem 8 | @rem https://www.apache.org/licenses/LICENSE-2.0 9 | @rem 10 | @rem Unless required by applicable law or agreed to in writing, software 11 | @rem distributed under the License is distributed on an "AS IS" BASIS, 12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | @rem See the License for the specific language governing permissions and 14 | @rem limitations under the License. 15 | @rem 16 | 17 | @if "%DEBUG%" == "" @echo off 18 | @rem ########################################################################## 19 | @rem 20 | @rem Gradle startup script for Windows 21 | @rem 22 | @rem ########################################################################## 23 | 24 | @rem Set local scope for the variables with windows NT shell 25 | if "%OS%"=="Windows_NT" setlocal 26 | 27 | set DIRNAME=%~dp0 28 | if "%DIRNAME%" == "" set DIRNAME=. 29 | set APP_BASE_NAME=%~n0 30 | set APP_HOME=%DIRNAME% 31 | 32 | @rem Resolve any "." and ".." in APP_HOME to make it shorter. 33 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi 34 | 35 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 36 | set DEFAULT_JVM_OPTS="-Xmx80m" "-Xms80m" 37 | 38 | @rem Find java.exe 39 | if defined JAVA_HOME goto findJavaFromJavaHome 40 | 41 | set JAVA_EXE=java.exe 42 | %JAVA_EXE% -version >NUL 2>&1 43 | if "%ERRORLEVEL%" == "0" goto init 44 | 45 | echo. 46 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 47 | echo. 48 | echo Please set the JAVA_HOME variable in your environment to match the 49 | echo location of your Java installation. 50 | 51 | goto fail 52 | 53 | :findJavaFromJavaHome 54 | set JAVA_HOME=%JAVA_HOME:"=% 55 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 56 | 57 | if exist "%JAVA_EXE%" goto init 58 | 59 | echo. 60 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 61 | echo. 62 | echo Please set the JAVA_HOME variable in your environment to match the 63 | echo location of your Java installation. 64 | 65 | goto fail 66 | 67 | :init 68 | @rem Get command-line arguments, handling Windows variants 69 | 70 | if not "%OS%" == "Windows_NT" goto win9xME_args 71 | 72 | :win9xME_args 73 | @rem Slurp the command line arguments. 74 | set CMD_LINE_ARGS= 75 | set _SKIP=2 76 | 77 | :win9xME_args_slurp 78 | if "x%~1" == "x" goto execute 79 | 80 | set CMD_LINE_ARGS=%* 81 | 82 | :execute 83 | @rem Setup the command line 84 | 85 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 86 | 87 | @rem Execute Gradle 88 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% 89 | 90 | :end 91 | @rem End local scope for the variables with windows NT shell 92 | if "%ERRORLEVEL%"=="0" goto mainEnd 93 | 94 | :fail 95 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 96 | rem the _cmd.exe /c_ return code! 97 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 98 | exit /b 1 99 | 100 | :mainEnd 101 | if "%OS%"=="Windows_NT" endlocal 102 | 103 | :omega 104 | -------------------------------------------------------------------------------- /android/proguard-rules.pro: -------------------------------------------------------------------------------- 1 | # Keep all classes with native methods 2 | -keepclasseswithmembernames class * { 3 | native ; 4 | } 5 | 6 | # Keep Tailcale classes for debuggability, but especially 7 | # keep the classes with syspolicy MDM keys, some of which 8 | # get used only by the Go backend. (The second rule is redundant, 9 | # but explicit.) 10 | -keep class com.tailscale.ipn.** { *; } 11 | -keep class com.tailscale.ipn.mdm.** { *; } 12 | 13 | # Keep specific classes from Tink library 14 | -keep class com.google.crypto.tink.** { *; } 15 | 16 | # Ignore warnings about missing Error Prone annotations 17 | -dontwarn com.google.errorprone.annotations.** 18 | 19 | # Keep Error Prone annotations if referenced 20 | -keep class com.google.errorprone.annotations.** { *; } 21 | 22 | # Keep Google HTTP Client classes 23 | -keep class com.google.api.client.http.** { *; } 24 | -dontwarn com.google.api.client.http.** 25 | 26 | # Keep Joda-Time classes 27 | -keep class org.joda.time.** { *; } 28 | -dontwarn org.joda.time.** 29 | -------------------------------------------------------------------------------- /android/src/androidTest/kotlin/com/tailscale/ipn/TestUtil.kt: -------------------------------------------------------------------------------- 1 | // Copyright (c) Tailscale Inc & AUTHORS 2 | // SPDX-License-Identifier: BSD-3-Clause 3 | 4 | package com.tailscale.ipn 5 | 6 | import android.util.Log 7 | import androidx.test.uiautomator.BySelector 8 | import androidx.test.uiautomator.UiDevice 9 | import androidx.test.uiautomator.UiObject 10 | import androidx.test.uiautomator.UiObject2 11 | import androidx.test.uiautomator.UiSelector 12 | import androidx.test.uiautomator.Until 13 | import kotlin.time.Duration 14 | import kotlin.time.Duration.Companion.milliseconds 15 | import kotlin.time.Duration.Companion.seconds 16 | 17 | private val defaultTimeout = 10.seconds 18 | 19 | private val threadLocalTimeout = ThreadLocal() 20 | 21 | /** 22 | * Wait until the specified timeout for the given selector and return the matching UiObject2. 23 | * Timeout defaults to 10 seconds. 24 | * 25 | * @throws Exception if selector is not found within timeout. 26 | */ 27 | fun UiDevice.find( 28 | selector: BySelector, 29 | timeout: Duration = threadLocalTimeout.get() ?: defaultTimeout 30 | ): UiObject2 { 31 | wait(Until.findObject(selector), timeout.inWholeMilliseconds)?.let { 32 | return it 33 | } ?: run { throw Exception("not found") } 34 | } 35 | 36 | /** 37 | * Wait until the specified timeout for the given selector and return the matching UiObject. Timeout 38 | * defaults to 10 seconds. 39 | * 40 | * @throws Exception if selector is not found within timeout. 41 | */ 42 | fun UiDevice.find( 43 | selector: UiSelector, 44 | timeout: Duration = threadLocalTimeout.get() ?: defaultTimeout 45 | ): UiObject { 46 | val obj = findObject(selector) 47 | if (!obj.waitForExists(timeout.inWholeMilliseconds)) { 48 | throw Exception("not found") 49 | } 50 | return obj 51 | } 52 | 53 | /** 54 | * Execute an ordered collection of steps as necessary. If an earlier step fails but a subsequent 55 | * step succeeds, this skips the earlier step. This is useful for interruptible sequences like 56 | * logging in that may resume in an intermediate state. 57 | */ 58 | fun asNecessary(timeout: Duration, vararg steps: () -> Unit) { 59 | val interval = 250.milliseconds 60 | // Use a short timeout to avoid waiting on actions that can be skipped 61 | threadLocalTimeout.set(interval) 62 | try { 63 | val start = System.currentTimeMillis() 64 | var furthestSuccessful = -1 65 | while (System.currentTimeMillis() - start < timeout.inWholeMilliseconds) { 66 | for (i in furthestSuccessful + 1 ..< steps.size) { 67 | val step = steps[i] 68 | try { 69 | step() 70 | furthestSuccessful = i 71 | Log.d("TestUtil.asNecessary", "SUCCESS!") 72 | // Going forward, use the normal timeout on the assumption that subsequent steps will 73 | // succeed. 74 | threadLocalTimeout.remove() 75 | } catch (t: Throwable) { 76 | Log.d("TestUtil.asNecessary", t.toString()) 77 | // Going forward, use a short timeout to avoid waiting on actions that can be skipped 78 | threadLocalTimeout.set(interval) 79 | } 80 | } 81 | if (furthestSuccessful == steps.size - 1) { 82 | // All steps have completed successfully 83 | return 84 | } 85 | // Still some steps left to run 86 | Thread.sleep(interval.inWholeMilliseconds) 87 | } 88 | throw Exception("failed to complete within timeout") 89 | } finally { 90 | threadLocalTimeout.remove() 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /android/src/main/ic_launcher-playstore.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tailscale/tailscale-android/5f59a367e3a5573b0ce12ec42cd07fa50a98a785/android/src/main/ic_launcher-playstore.png -------------------------------------------------------------------------------- /android/src/main/java/com/tailscale/ipn/AppSourceChecker.kt: -------------------------------------------------------------------------------- 1 | // Copyright (c) Tailscale Inc & AUTHORS 2 | // SPDX-License-Identifier: BSD-3-Clause 3 | package com.tailscale.ipn 4 | 5 | import android.content.Context 6 | import android.os.Build 7 | import android.util.Log 8 | 9 | object AppSourceChecker { 10 | 11 | const val TAG = "AppSourceChecker" 12 | 13 | fun getInstallSource(context: Context): String { 14 | val packageManager = context.packageManager 15 | val packageName = context.packageName 16 | Log.d(TAG, "Package name: $packageName") 17 | 18 | val installerPackageName = 19 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { 20 | packageManager.getInstallSourceInfo(packageName).installingPackageName 21 | } else { 22 | @Suppress("deprecation") packageManager.getInstallerPackageName(packageName) 23 | } 24 | 25 | Log.d(TAG, "Installer package name: $installerPackageName") 26 | 27 | return when (installerPackageName) { 28 | "com.android.vending" -> "googleplay" 29 | "org.fdroid.fdroid" -> "fdroid" 30 | "com.amazon.venezia" -> "amazon" 31 | null -> "unknown" 32 | else -> "unknown($installerPackageName)" 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /android/src/main/java/com/tailscale/ipn/DnsConfig.java: -------------------------------------------------------------------------------- 1 | // Copyright (c) Tailscale Inc & AUTHORS 2 | // SPDX-License-Identifier: BSD-3-Clause 3 | 4 | package com.tailscale.ipn; 5 | 6 | // Tailscale DNS Config retrieval 7 | // 8 | // Tailscale's DNS support can either override the local DNS servers with a set of servers 9 | // configured in the admin panel, or supplement the local DNS servers with additional 10 | // servers for specific domains like example.com.beta.tailscale.net. In the non-override mode, 11 | // we need to retrieve the current set of DNS servers from the platform. These will typically 12 | // be the DNS servers received from DHCP. 13 | // 14 | // Importantly, after the Tailscale VPN comes up it will set a DNS server of 100.100.100.100 15 | // but we still want to retrieve the underlying DNS servers received from DHCP. If we roam 16 | // from Wi-Fi to LTE, we want the DNS servers received from LTE. 17 | 18 | public class DnsConfig { 19 | private String dnsConfigs; 20 | 21 | // getDnsConfigAsString returns the current DNS configuration as a multiline string: 22 | // line[0] DNS server addresses separated by spaces 23 | // line[1] search domains separated by spaces 24 | // 25 | // For example: 26 | // 8.8.8.8 8.8.4.4 27 | // example.com 28 | // 29 | // an empty string means the current DNS configuration could not be retrieved. 30 | String getDnsConfigAsString() { 31 | String dnsConfig = getDnsConfigs(); 32 | if (dnsConfig != null) { 33 | return getDnsConfigs().trim(); 34 | } 35 | return ""; 36 | } 37 | 38 | private String getDnsConfigs() { 39 | synchronized (this) { 40 | return this.dnsConfigs; 41 | } 42 | } 43 | 44 | boolean updateDNSFromNetwork(String dnsConfigs) { 45 | synchronized (this) { 46 | if (!dnsConfigs.equals(this.dnsConfigs)) { 47 | this.dnsConfigs = dnsConfigs; 48 | return true; 49 | } else { 50 | return false; 51 | } 52 | } 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /android/src/main/java/com/tailscale/ipn/IPNReceiver.java: -------------------------------------------------------------------------------- 1 | // Copyright (c) Tailscale Inc & AUTHORS 2 | // SPDX-License-Identifier: BSD-3-Clause 3 | 4 | package com.tailscale.ipn; 5 | 6 | import android.content.BroadcastReceiver; 7 | import android.content.Context; 8 | import android.content.Intent; 9 | import androidx.work.Data; 10 | 11 | import androidx.work.OneTimeWorkRequest; 12 | import androidx.work.WorkManager; 13 | 14 | import java.util.Objects; 15 | 16 | /** 17 | * IPNReceiver allows external applications to start the VPN. 18 | */ 19 | public class IPNReceiver extends BroadcastReceiver { 20 | 21 | public static final String INTENT_CONNECT_VPN = "com.tailscale.ipn.CONNECT_VPN"; 22 | public static final String INTENT_DISCONNECT_VPN = "com.tailscale.ipn.DISCONNECT_VPN"; 23 | 24 | private static final String INTENT_USE_EXIT_NODE = "com.tailscale.ipn.USE_EXIT_NODE"; 25 | 26 | @Override 27 | public void onReceive(Context context, Intent intent) { 28 | WorkManager workManager = WorkManager.getInstance(context); 29 | 30 | // On the relevant action, start the relevant worker, which can stay active for longer than this receiver can. 31 | if (Objects.equals(intent.getAction(), INTENT_CONNECT_VPN)) { 32 | workManager.enqueue(new OneTimeWorkRequest.Builder(StartVPNWorker.class).build()); 33 | } else if (Objects.equals(intent.getAction(), INTENT_DISCONNECT_VPN)) { 34 | workManager.enqueue(new OneTimeWorkRequest.Builder(StopVPNWorker.class).build()); 35 | } 36 | else if (Objects.equals(intent.getAction(), INTENT_USE_EXIT_NODE)) { 37 | String exitNode = intent.getStringExtra("exitNode"); 38 | boolean allowLanAccess = intent.getBooleanExtra("allowLanAccess", false); 39 | Data.Builder workData = new Data.Builder(); 40 | workData.putString(UseExitNodeWorker.EXIT_NODE_NAME, exitNode); 41 | workData.putBoolean(UseExitNodeWorker.ALLOW_LAN_ACCESS, allowLanAccess); 42 | workManager.enqueue(new OneTimeWorkRequest.Builder(UseExitNodeWorker.class).setInputData(workData.build()).build()); 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /android/src/main/java/com/tailscale/ipn/QuickToggleService.java: -------------------------------------------------------------------------------- 1 | // Copyright (c) Tailscale Inc & AUTHORS 2 | // SPDX-License-Identifier: BSD-3-Clause 3 | 4 | package com.tailscale.ipn; 5 | 6 | import android.app.PendingIntent; 7 | import android.content.Intent; 8 | import android.os.Build; 9 | import android.service.quicksettings.Tile; 10 | import android.service.quicksettings.TileService; 11 | 12 | public class QuickToggleService extends TileService { 13 | // lock protects the static fields below it. 14 | private static final Object lock = new Object(); 15 | 16 | // isRunning tracks whether the VPN is running. 17 | private static boolean isRunning; 18 | 19 | // currentTile tracks getQsTile while service is listening. 20 | private static Tile currentTile; 21 | 22 | public static void updateTile() { 23 | var app = UninitializedApp.get(); 24 | Tile t; 25 | boolean act; 26 | synchronized (lock) { 27 | t = currentTile; 28 | act = isRunning && app.isAbleToStartVPN(); 29 | } 30 | if (t == null) { 31 | return; 32 | } 33 | t.setLabel("Tailscale"); 34 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { 35 | t.setSubtitle(act ? app.getString(R.string.connected) : app.getString(R.string.not_connected)); 36 | } 37 | t.setState(act ? Tile.STATE_ACTIVE : Tile.STATE_INACTIVE); 38 | t.updateTile(); 39 | } 40 | 41 | static void setVPNRunning(boolean running) { 42 | synchronized (lock) { 43 | isRunning = running; 44 | } 45 | updateTile(); 46 | } 47 | 48 | @Override 49 | public void onStartListening() { 50 | synchronized (lock) { 51 | currentTile = getQsTile(); 52 | } 53 | updateTile(); 54 | } 55 | 56 | @Override 57 | public void onStopListening() { 58 | synchronized (lock) { 59 | currentTile = null; 60 | } 61 | } 62 | 63 | @Override 64 | public void onClick() { 65 | unlockAndRun(this::secureOnClick); 66 | } 67 | 68 | @SuppressWarnings("deprecation") 69 | private void secureOnClick() { 70 | boolean r; 71 | synchronized (lock) { 72 | r = UninitializedApp.get().isAbleToStartVPN(); 73 | } 74 | if (r) { 75 | // Get the application to make sure it initializes 76 | App.get(); 77 | onTileClick(); 78 | } else { 79 | // Start main activity. 80 | Intent i = getPackageManager().getLaunchIntentForPackage(getPackageName()); 81 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { 82 | // Request code for opening activity. 83 | startActivityAndCollapse(PendingIntent.getActivity(this, 0, i, PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE)); 84 | } else { 85 | // Deprecated, but still required for older versions. 86 | startActivityAndCollapse(i); 87 | } 88 | } 89 | } 90 | 91 | private void onTileClick() { 92 | UninitializedApp app = UninitializedApp.get(); 93 | boolean needsToStop; 94 | synchronized (lock) { 95 | needsToStop = app.isAbleToStartVPN() && isRunning; 96 | } 97 | if (needsToStop) { 98 | app.stopVPN(); 99 | } else { 100 | app.startVPN(); 101 | } 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /android/src/main/java/com/tailscale/ipn/StartVPNWorker.java: -------------------------------------------------------------------------------- 1 | // Copyright (c) Tailscale Inc & AUTHORS 2 | // SPDX-License-Identifier: BSD-3-Clause 3 | 4 | package com.tailscale.ipn; 5 | 6 | import android.app.Notification; 7 | import android.app.NotificationManager; 8 | import android.app.PendingIntent; 9 | import android.content.Context; 10 | import android.content.Intent; 11 | import android.net.VpnService; 12 | import android.os.Build; 13 | 14 | import androidx.annotation.NonNull; 15 | import androidx.work.Worker; 16 | import androidx.work.WorkerParameters; 17 | 18 | import com.tailscale.ipn.util.TSLog; 19 | 20 | /** 21 | * A worker that exists to support IPNReceiver. 22 | */ 23 | public final class StartVPNWorker extends Worker { 24 | 25 | public StartVPNWorker(Context appContext, WorkerParameters workerParams) { 26 | super(appContext, workerParams); 27 | } 28 | 29 | @NonNull 30 | @Override 31 | public Result doWork() { 32 | UninitializedApp app = UninitializedApp.get(); 33 | boolean ableToStartVPN = app.isAbleToStartVPN(); 34 | if (ableToStartVPN) { 35 | if (VpnService.prepare(app) == null) { 36 | // We're ready and have permissions, start the VPN 37 | app.startVPN(); 38 | return Result.success(); 39 | } 40 | } 41 | 42 | // We aren't ready to start the VPN or don't have permission, open the Tailscale app. 43 | TSLog.e("StartVPNWorker", "Tailscale isn't ready to start the VPN, notify the user."); 44 | 45 | // Send notification 46 | NotificationManager notificationManager = (NotificationManager) app.getSystemService(Context.NOTIFICATION_SERVICE); 47 | String channelId = "start_vpn_channel"; 48 | 49 | // Use createNotificationChannel method from App.java 50 | app.createNotificationChannel(channelId, getApplicationContext().getString(R.string.vpn_start), getApplicationContext().getString(R.string.notifications_delivered_when_user_interaction_is_required_to_establish_the_vpn_tunnel), NotificationManager.IMPORTANCE_HIGH); 51 | 52 | // Use prepareIntent if available. 53 | Intent intent = app.getPackageManager().getLaunchIntentForPackage(app.getPackageName()); 54 | assert intent != null; 55 | intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); 56 | int pendingIntentFlags = PendingIntent.FLAG_ONE_SHOT | (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S ? PendingIntent.FLAG_IMMUTABLE : 0); 57 | PendingIntent pendingIntent = PendingIntent.getActivity(app, 0, intent, pendingIntentFlags); 58 | 59 | Notification notification = new Notification.Builder(app, channelId).setContentTitle(app.getString(R.string.title_connection_failed)).setContentText(app.getString(R.string.body_open_tailscale)).setSmallIcon(R.drawable.ic_notification).setContentIntent(pendingIntent).setAutoCancel(true).build(); 60 | 61 | notificationManager.notify(1, notification); 62 | 63 | return Result.failure(); 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /android/src/main/java/com/tailscale/ipn/StopVPNWorker.java: -------------------------------------------------------------------------------- 1 | // Copyright (c) Tailscale Inc & AUTHORS 2 | // SPDX-License-Identifier: BSD-3-Clause 3 | 4 | package com.tailscale.ipn; 5 | 6 | import android.content.Context; 7 | 8 | import androidx.annotation.NonNull; 9 | import androidx.work.Worker; 10 | import androidx.work.WorkerParameters; 11 | 12 | /** 13 | * A worker that exists to support IPNReceiver. 14 | */ 15 | public final class StopVPNWorker extends Worker { 16 | 17 | public StopVPNWorker( 18 | Context appContext, 19 | WorkerParameters workerParams) { 20 | super(appContext, workerParams); 21 | } 22 | 23 | @NonNull 24 | @Override 25 | public Result doWork() { 26 | UninitializedApp.get().stopVPN(); 27 | return Result.success(); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /android/src/main/java/com/tailscale/ipn/TaildropDirectoryStore.kt: -------------------------------------------------------------------------------- 1 | // Copyright (c) Tailscale Inc & AUTHORS 2 | // SPDX-License-Identifier: BSD-3-Clause 3 | 4 | package com.tailscale.ipn 5 | 6 | import android.net.Uri 7 | import com.tailscale.ipn.util.TSLog 8 | import java.io.IOException 9 | import java.security.GeneralSecurityException 10 | 11 | object TaildropDirectoryStore { 12 | // Key to store the SAF URI in EncryptedSharedPreferences. 13 | val PREF_KEY_SAF_URI = "saf_directory_uri" 14 | 15 | @Throws(IOException::class, GeneralSecurityException::class) 16 | fun saveFileDirectory(directoryUri: Uri) { 17 | val prefs = App.get().getEncryptedPrefs() 18 | prefs.edit().putString(PREF_KEY_SAF_URI, directoryUri.toString()).commit() 19 | try { 20 | // Must restart Tailscale because a new LocalBackend with the new directory must be created. 21 | App.get().startLibtailscale(directoryUri.toString()) 22 | } catch (e: Exception) { 23 | TSLog.d( 24 | "TaildropDirectoryStore", 25 | "saveFileDirectory: Failed to restart Libtailscale with the new directory: $e") 26 | } 27 | } 28 | 29 | @Throws(IOException::class, GeneralSecurityException::class) 30 | fun loadSavedDir(): Uri? { 31 | val prefs = App.get().getEncryptedPrefs() 32 | val uriString = prefs.getString(PREF_KEY_SAF_URI, null) ?: return null 33 | 34 | return try { 35 | Uri.parse(uriString) 36 | } catch (e: Exception) { 37 | // Malformed URI in prefs ‑‑ log and wipe the bad value 38 | TSLog.w("MainActivity", "loadSavedDir: invalid URI in prefs: $uriString; clearing") 39 | prefs.edit().remove(PREF_KEY_SAF_URI).apply() 40 | null 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /android/src/main/java/com/tailscale/ipn/UseExitNodeWorker.kt: -------------------------------------------------------------------------------- 1 | // Copyright (c) Tailscale Inc & AUTHORS 2 | // SPDX-License-Identifier: BSD-3-Clause 3 | package com.tailscale.ipn 4 | 5 | import android.app.PendingIntent 6 | import android.content.Context 7 | import android.content.Intent 8 | import androidx.core.app.NotificationCompat 9 | import androidx.work.CoroutineWorker 10 | import androidx.work.Data 11 | import androidx.work.WorkerParameters 12 | import com.tailscale.ipn.UninitializedApp.Companion.STATUS_CHANNEL_ID 13 | import com.tailscale.ipn.ui.localapi.Client 14 | import com.tailscale.ipn.ui.model.Ipn 15 | import com.tailscale.ipn.ui.notifier.Notifier 16 | import kotlinx.coroutines.CoroutineScope 17 | import kotlinx.coroutines.Dispatchers 18 | import kotlinx.coroutines.Job 19 | 20 | class UseExitNodeWorker(appContext: Context, workerParams: WorkerParameters) : 21 | CoroutineWorker(appContext, workerParams) { 22 | override suspend fun doWork(): Result { 23 | val app = UninitializedApp.get() 24 | suspend fun runAndGetResult(): String? { 25 | val exitNodeName = inputData.getString(EXIT_NODE_NAME) 26 | 27 | val exitNodeId = 28 | if (exitNodeName.isNullOrEmpty()) { 29 | null 30 | } else { 31 | if (!app.isAbleToStartVPN()) { 32 | return app.getString(R.string.vpn_is_not_ready_to_start) 33 | } 34 | 35 | val peers = 36 | (Notifier.netmap.value 37 | ?: run { 38 | return@runAndGetResult app.getString(R.string.tailscale_is_not_setup) 39 | }) 40 | .Peers 41 | ?: run { 42 | return@runAndGetResult app.getString(R.string.no_peers_found) 43 | } 44 | 45 | val filteredPeers = peers.filter { it.displayName == exitNodeName }.toList() 46 | 47 | if (filteredPeers.isEmpty()) { 48 | return app.getString(R.string.no_peers_with_name_found, exitNodeName) 49 | } else if (filteredPeers.size > 1) { 50 | return app.getString(R.string.multiple_peers_with_name_found, exitNodeName) 51 | } else if (!filteredPeers[0].isExitNode) { 52 | return app.getString(R.string.peer_with_name_is_not_an_exit_node, exitNodeName) 53 | } 54 | 55 | filteredPeers[0].StableID 56 | } 57 | 58 | val allowLanAccess = inputData.getBoolean(ALLOW_LAN_ACCESS, false) 59 | val prefsOut = Ipn.MaskedPrefs() 60 | prefsOut.ExitNodeID = exitNodeId 61 | prefsOut.ExitNodeAllowLANAccess = allowLanAccess 62 | 63 | val scope = CoroutineScope(Dispatchers.Default + Job()) 64 | var result: String? = null 65 | Client(scope).editPrefs(prefsOut) { 66 | result = 67 | if (it.isFailure) { 68 | it.exceptionOrNull()?.message 69 | } else { 70 | null 71 | } 72 | } 73 | 74 | scope.coroutineContext[Job]?.join() 75 | 76 | return result 77 | } 78 | 79 | val result = runAndGetResult() 80 | 81 | return if (result != null) { 82 | val intent = 83 | Intent(app, MainActivity::class.java).apply { 84 | flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK 85 | } 86 | val pendingIntent: PendingIntent = 87 | PendingIntent.getActivity( 88 | app, 1, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE) 89 | 90 | val notification = 91 | NotificationCompat.Builder(app, STATUS_CHANNEL_ID) 92 | .setSmallIcon(R.drawable.ic_notification) 93 | .setContentTitle(app.getString(R.string.use_exit_node_intent_failed)) 94 | .setContentText(result) 95 | .setPriority(NotificationCompat.PRIORITY_DEFAULT) 96 | .setContentIntent(pendingIntent) 97 | .build() 98 | 99 | app.notifyStatus(notification) 100 | 101 | Result.failure(Data.Builder().putString(ERROR_KEY, result).build()) 102 | } else { 103 | Result.success() 104 | } 105 | } 106 | 107 | companion object { 108 | const val EXIT_NODE_NAME = "EXIT_NODE_NAME" 109 | const val ALLOW_LAN_ACCESS = "ALLOW_LAN_ACCESS" 110 | const val ERROR_KEY = "error" 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /android/src/main/java/com/tailscale/ipn/VPNServiceBuilder.kt: -------------------------------------------------------------------------------- 1 | // Copyright (c) Tailscale Inc & AUTHORS 2 | // SPDX-License-Identifier: BSD-3-Clause 3 | 4 | package com.tailscale.ipn 5 | 6 | import android.net.IpPrefix as AndroidIpPrefix 7 | import android.net.VpnService 8 | import android.os.Build 9 | import java.net.InetAddress 10 | import libtailscale.ParcelFileDescriptor 11 | 12 | class VPNServiceBuilder(private val builder: VpnService.Builder) : libtailscale.VPNServiceBuilder { 13 | override fun addAddress(p0: String, p1: Int) { 14 | builder.addAddress(p0, p1) 15 | } 16 | 17 | override fun addDNSServer(p0: String) { 18 | builder.addDnsServer(p0) 19 | } 20 | 21 | override fun addRoute(p0: String, p1: Int) { 22 | builder.addRoute(p0, p1) 23 | } 24 | 25 | override fun excludeRoute(p0: String, p1: Int) { 26 | // Only run this for API level 33 and up 27 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { 28 | val inetAddress = InetAddress.getByName(p0) 29 | val prefix = AndroidIpPrefix(inetAddress, p1) 30 | builder.excludeRoute(prefix) 31 | } 32 | } 33 | 34 | override fun addSearchDomain(p0: String) { 35 | builder.addSearchDomain(p0) 36 | } 37 | 38 | override fun establish(): ParcelFileDescriptor? { 39 | return builder.establish()?.let { ParcelFileDescriptor(it) } 40 | } 41 | 42 | override fun setMTU(p0: Int) { 43 | builder.setMtu(p0) 44 | } 45 | } 46 | 47 | class ParcelFileDescriptor(private val fd: android.os.ParcelFileDescriptor) : ParcelFileDescriptor { 48 | override fun detach(): Int { 49 | return fd.detachFd() 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /android/src/main/java/com/tailscale/ipn/mdm/MDMSettingsChangedReceiver.kt: -------------------------------------------------------------------------------- 1 | // Copyright (c) Tailscale Inc & AUTHORS 2 | // SPDX-License-Identifier: BSD-3-Clause 3 | 4 | package com.tailscale.ipn.mdm 5 | 6 | import android.content.BroadcastReceiver 7 | import android.content.Context 8 | import android.content.Intent 9 | import android.content.RestrictionsManager 10 | import com.tailscale.ipn.App 11 | import com.tailscale.ipn.util.TSLog 12 | 13 | class MDMSettingsChangedReceiver : BroadcastReceiver() { 14 | override fun onReceive(context: Context?, intent: Intent?) { 15 | if (intent?.action == Intent.ACTION_APPLICATION_RESTRICTIONS_CHANGED) { 16 | TSLog.d("syspolicy", "MDM settings changed") 17 | val restrictionsManager = 18 | context?.getSystemService(Context.RESTRICTIONS_SERVICE) as RestrictionsManager 19 | MDMSettings.update(App.get(), restrictionsManager) 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /android/src/main/java/com/tailscale/ipn/ui/Links.kt: -------------------------------------------------------------------------------- 1 | // Copyright (c) Tailscale Inc & AUTHORS 2 | // SPDX-License-Identifier: BSD-3-Clause 3 | 4 | package com.tailscale.ipn.ui 5 | 6 | object Links { 7 | const val DEFAULT_CONTROL_URL = "https://controlplane.tailscale.com" 8 | const val SERVER_URL = "https://login.tailscale.com" 9 | const val ADMIN_URL = SERVER_URL + "/admin" 10 | const val SIGNIN_URL = "https://tailscale.com/login" 11 | const val PRIVACY_POLICY_URL = "https://tailscale.com/privacy-policy/" 12 | const val TERMS_URL = "https://tailscale.com/terms" 13 | const val DOCS_URL = "https://tailscale.com/kb/" 14 | const val START_GUIDE_URL = "https://tailscale.com/kb/1017/install/" 15 | const val LICENSES_URL = "https://tailscale.com/licenses/android" 16 | const val DELETE_ACCOUNT_URL = 17 | "https://login.tailscale.com/login?next_url=%2Fadmin%2Fsettings%2Fgeneral" 18 | const val TAILNET_LOCK_KB_URL = "https://tailscale.com/kb/1226/tailnet-lock/" 19 | const val KEY_EXPIRY_KB_URL = "https://tailscale.com/kb/1028/key-expiry/" 20 | const val INSTALL_TAILSCALE_KB_URL = "https://tailscale.com/kb/installation/" 21 | const val INSTALL_UNSTABLE_KB_URL = "https://tailscale.com/kb/1083/install-unstable" 22 | const val MAGICDNS_KB_URL = "https://tailscale.com/kb/1081/magicdns" 23 | const val TROUBLESHOOTING_KB_URL = "https://tailscale.com/kb/1023/troubleshooting" 24 | const val SUPPORT_URL = "https://tailscale.com/contact/support#support-form" 25 | const val TAILDROP_KB_URL = "https://tailscale.com/kb/1106/taildrop" 26 | const val TAILFS_KB_URL = "https://tailscale.com/kb/1106/taildrop" 27 | const val SUBNET_ROUTERS_KB_URL = "https://tailscale.com/kb/1019/subnets" 28 | } 29 | -------------------------------------------------------------------------------- /android/src/main/java/com/tailscale/ipn/ui/model/Dns.kt: -------------------------------------------------------------------------------- 1 | // Copyright (c) Tailscale Inc & AUTHORS 2 | // SPDX-License-Identifier: BSD-3-Clause 3 | 4 | package com.tailscale.ipn.ui.model 5 | 6 | import kotlinx.serialization.Serializable 7 | 8 | class Dns { 9 | @Serializable data class HostEntry(val addr: Addr?, val hosts: List?) 10 | 11 | @Serializable 12 | data class OSConfig( 13 | val hosts: List? = null, 14 | val nameservers: List? = null, 15 | val searchDomains: List? = null, 16 | val matchDomains: List? = null, 17 | ) { 18 | val isEmpty: Boolean 19 | get() = 20 | (hosts.isNullOrEmpty()) && 21 | (nameservers.isNullOrEmpty()) && 22 | (searchDomains.isNullOrEmpty()) && 23 | (matchDomains.isNullOrEmpty()) 24 | } 25 | } 26 | 27 | class DnsType { 28 | @Serializable 29 | data class Resolver(var Addr: String? = null, var BootstrapResolution: List? = null) 30 | } 31 | -------------------------------------------------------------------------------- /android/src/main/java/com/tailscale/ipn/ui/model/Health.kt: -------------------------------------------------------------------------------- 1 | // Copyright (c) Tailscale Inc & AUTHORS 2 | // SPDX-License-Identifier: BSD-3-Clause 3 | 4 | package com.tailscale.ipn.ui.model 5 | 6 | import androidx.compose.material3.ListItemColors 7 | import androidx.compose.material3.ListItemDefaults 8 | import androidx.compose.material3.MaterialTheme 9 | import androidx.compose.runtime.Composable 10 | import com.tailscale.ipn.ui.theme.warning 11 | import kotlinx.serialization.Serializable 12 | 13 | class Health { 14 | @Serializable 15 | data class State( 16 | // WarnableCode -> UnhealthyState or null 17 | var Warnings: Map? = null, 18 | ) 19 | 20 | @Serializable 21 | data class UnhealthyState( 22 | var WarnableCode: String, 23 | var Severity: Severity, 24 | var Title: String, 25 | var Text: String, 26 | var BrokenSince: String? = null, 27 | var Args: Map? = null, 28 | var ImpactsConnectivity: Boolean? = false, 29 | var DependsOn: List? = null, // an array of WarnableCodes this depends on 30 | ) : Comparable { 31 | fun hiddenByDependencies(currentWarnableCodes: Set): Boolean { 32 | return this.DependsOn?.let { 33 | it.any { depWarnableCode -> currentWarnableCodes.contains(depWarnableCode) } 34 | } == true 35 | } 36 | 37 | override fun compareTo(other: UnhealthyState): Int { 38 | // Compare by severity first 39 | val severityComparison = Severity.compareTo(other.Severity) 40 | if (severityComparison != 0) { 41 | return severityComparison 42 | } 43 | 44 | // If severities are equal, compare by warnableCode 45 | return WarnableCode.compareTo(other.WarnableCode) 46 | } 47 | } 48 | 49 | @Serializable 50 | enum class Severity : Comparable { 51 | low, 52 | medium, 53 | high; 54 | 55 | @Composable 56 | fun listItemColors(): ListItemColors { 57 | val default = ListItemDefaults.colors() 58 | return when (this) { 59 | Severity.low -> 60 | ListItemColors( 61 | containerColor = MaterialTheme.colorScheme.surface, 62 | headlineColor = MaterialTheme.colorScheme.secondary, 63 | leadingIconColor = MaterialTheme.colorScheme.secondary, 64 | overlineColor = MaterialTheme.colorScheme.secondary.copy(alpha = 0.8f), 65 | supportingTextColor = MaterialTheme.colorScheme.secondary, 66 | trailingIconColor = MaterialTheme.colorScheme.secondary, 67 | disabledHeadlineColor = default.disabledHeadlineColor, 68 | disabledLeadingIconColor = default.disabledLeadingIconColor, 69 | disabledTrailingIconColor = default.disabledTrailingIconColor) 70 | Severity.medium, 71 | Severity.high -> 72 | ListItemColors( 73 | containerColor = MaterialTheme.colorScheme.warning, 74 | headlineColor = MaterialTheme.colorScheme.onPrimary, 75 | leadingIconColor = MaterialTheme.colorScheme.onPrimary, 76 | overlineColor = MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.8f), 77 | supportingTextColor = MaterialTheme.colorScheme.onPrimary, 78 | trailingIconColor = MaterialTheme.colorScheme.onPrimary, 79 | disabledHeadlineColor = default.disabledHeadlineColor, 80 | disabledLeadingIconColor = default.disabledLeadingIconColor, 81 | disabledTrailingIconColor = default.disabledTrailingIconColor) 82 | } 83 | } 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /android/src/main/java/com/tailscale/ipn/ui/model/NetMap.kt: -------------------------------------------------------------------------------- 1 | // Copyright (c) Tailscale Inc & AUTHORS 2 | // SPDX-License-Identifier: BSD-3-Clause 3 | 4 | package com.tailscale.ipn.ui.model 5 | 6 | import kotlinx.serialization.Serializable 7 | 8 | class Netmap { 9 | @Serializable 10 | data class NetworkMap( 11 | var SelfNode: Tailcfg.Node, 12 | var NodeKey: KeyNodePublic, 13 | var Peers: List? = null, 14 | var Expiry: Time, 15 | var Domain: String, 16 | var UserProfiles: Map, 17 | var TKAEnabled: Boolean, 18 | var DNS: Tailcfg.DNSConfig? = null 19 | ) { 20 | // Keys are tailcfg.UserIDs thet get stringified 21 | // Helpers 22 | fun currentUserProfile(): Tailcfg.UserProfile? { 23 | return userProfile(User()) 24 | } 25 | 26 | fun User(): UserID { 27 | return SelfNode.User 28 | } 29 | 30 | fun userProfile(id: Long): Tailcfg.UserProfile? { 31 | return UserProfiles[id.toString()] 32 | } 33 | 34 | fun getPeer(id: StableNodeID): Tailcfg.Node? { 35 | if (id == SelfNode.StableID) { 36 | return SelfNode 37 | } 38 | return Peers?.find { it.StableID == id } 39 | } 40 | 41 | override fun equals(other: Any?): Boolean { 42 | if (this === other) return true 43 | if (other !is NetworkMap) return false 44 | 45 | return SelfNode == other.SelfNode && 46 | NodeKey == other.NodeKey && 47 | Peers == other.Peers && 48 | Expiry == other.Expiry && 49 | User() == other.User() && 50 | Domain == other.Domain && 51 | UserProfiles == other.UserProfiles && 52 | TKAEnabled == other.TKAEnabled 53 | } 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /android/src/main/java/com/tailscale/ipn/ui/model/Permissions.kt: -------------------------------------------------------------------------------- 1 | // Copyright (c) Tailscale Inc & AUTHORS 2 | // SPDX-License-Identifier: BSD-3-Clause 3 | 4 | package com.tailscale.ipn.ui.model 5 | 6 | import android.Manifest 7 | import android.os.Build 8 | import androidx.compose.runtime.Composable 9 | import androidx.compose.ui.platform.LocalContext 10 | import androidx.core.app.NotificationManagerCompat 11 | import com.google.accompanist.permissions.ExperimentalPermissionsApi 12 | import com.google.accompanist.permissions.PermissionState 13 | import com.google.accompanist.permissions.isGranted 14 | import com.google.accompanist.permissions.rememberMultiplePermissionsState 15 | import com.google.accompanist.permissions.shouldShowRationale 16 | import com.tailscale.ipn.R 17 | 18 | object Permissions { 19 | /** Permissions to prompt for on MainView. */ 20 | @OptIn(ExperimentalPermissionsApi::class) 21 | val prompt: List> 22 | @Composable 23 | get() { 24 | val permissionStates = rememberMultiplePermissionsState(permissions = all.map { it.name }) 25 | return all.zip(permissionStates.permissions).filter { (_, state) -> 26 | !state.status.isGranted && !state.status.shouldShowRationale 27 | } 28 | } 29 | 30 | /** All permissions with granted status. */ 31 | @OptIn(ExperimentalPermissionsApi::class) 32 | val withGrantedStatus: List> 33 | @Composable 34 | get() { 35 | val permissionStates = rememberMultiplePermissionsState(permissions = all.map { it.name }) 36 | val result = mutableListOf>() 37 | result.addAll( 38 | all.zip(permissionStates.permissions).map { (permission, state) -> 39 | Pair(permission, state.status.isGranted) 40 | }) 41 | if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) { 42 | // On Android versions prior to 13, we have to programmatically check if notifications are 43 | // being allowed. 44 | val notificationsEnabled = 45 | NotificationManagerCompat.from(LocalContext.current).areNotificationsEnabled() 46 | result.add( 47 | Pair( 48 | Permission( 49 | "", 50 | R.string.permission_post_notifications, 51 | R.string.permission_post_notifications_needed), 52 | notificationsEnabled)) 53 | } 54 | return result 55 | } 56 | 57 | /** 58 | * All permissions that Tailscale requires. MainView takes care of prompting for permissions, and 59 | * PermissionsView provides a list of permissions with corresponding statuses and a link to the 60 | * application settings. 61 | * 62 | * When new permissions are needed, just add them to this list and the necessary strings to 63 | * strings.xml and the rest should take care of itself. 64 | */ 65 | private val all: List by lazy { 66 | val result = mutableListOf() 67 | if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) { 68 | result.add( 69 | Permission( 70 | Manifest.permission.WRITE_EXTERNAL_STORAGE, 71 | R.string.permission_write_external_storage, 72 | R.string.permission_write_external_storage_needed, 73 | )) 74 | } 75 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { 76 | result.add( 77 | Permission( 78 | Manifest.permission.POST_NOTIFICATIONS, 79 | R.string.permission_post_notifications, 80 | R.string.permission_post_notifications_needed)) 81 | } 82 | result 83 | } 84 | } 85 | 86 | data class Permission( 87 | val name: String, 88 | val title: Int, 89 | val description: Int, 90 | ) 91 | -------------------------------------------------------------------------------- /android/src/main/java/com/tailscale/ipn/ui/model/Types.kt: -------------------------------------------------------------------------------- 1 | // Copyright (c) Tailscale Inc & AUTHORS 2 | // SPDX-License-Identifier: BSD-3-Clause 3 | 4 | package com.tailscale.ipn.ui.model 5 | 6 | import kotlinx.serialization.Serializable 7 | 8 | typealias Addr = String 9 | 10 | typealias Prefix = String 11 | 12 | typealias NodeID = Long 13 | 14 | typealias KeyNodePublic = String 15 | 16 | typealias MachineKey = String 17 | 18 | typealias UserID = Long 19 | 20 | typealias Time = String 21 | 22 | typealias StableNodeID = String 23 | 24 | typealias BugReportID = String 25 | 26 | val GoZeroTimeString = "0001-01-01T00:00:00Z" 27 | 28 | // Represents and empty message with a single 'property' field. 29 | class Empty { 30 | @Serializable data class Message(val property: String = "") 31 | } 32 | 33 | // Parsable errors returned by localApiService 34 | class Errors { 35 | @Serializable data class GenericError(val error: String) 36 | } 37 | -------------------------------------------------------------------------------- /android/src/main/java/com/tailscale/ipn/ui/theme/Color.kt: -------------------------------------------------------------------------------- 1 | // Copyright (c) Tailscale Inc & AUTHORS 2 | // SPDX-License-Identifier: BSD-3-Clause 3 | 4 | package com.tailscale.ipn.ui.theme 5 | 6 | import androidx.compose.ui.graphics.Color 7 | 8 | // TODO: replace references to these with references to material theme 9 | val ts_color_light_blue = Color(0xFF4B70CC) 10 | -------------------------------------------------------------------------------- /android/src/main/java/com/tailscale/ipn/ui/util/AdvertisedRoutesHelper.kt: -------------------------------------------------------------------------------- 1 | // Copyright (c) Tailscale Inc & AUTHORS 2 | // SPDX-License-Identifier: BSD-3-Clause 3 | 4 | package com.tailscale.ipn.ui.util 5 | 6 | import com.tailscale.ipn.ui.model.Ipn 7 | 8 | class AdvertisedRoutesHelper { 9 | companion object { 10 | fun exitNodeOnFromPrefs(prefs: Ipn.Prefs): Boolean { 11 | var v4 = false 12 | var v6 = false 13 | prefs.AdvertiseRoutes?.forEach { 14 | if (it == "0.0.0.0/0") { 15 | v4 = true 16 | } 17 | if (it == "::/0") { 18 | v6 = true 19 | } 20 | } 21 | return v4 && v6 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /android/src/main/java/com/tailscale/ipn/ui/util/AndroidTVUtil.kt: -------------------------------------------------------------------------------- 1 | // Copyright (c) Tailscale Inc & AUTHORS 2 | // SPDX-License-Identifier: BSD-3-Clause 3 | 4 | package com.tailscale.ipn.ui.util 5 | 6 | import android.content.pm.PackageManager 7 | import androidx.compose.foundation.layout.padding 8 | import androidx.compose.foundation.shape.RoundedCornerShape 9 | import androidx.compose.ui.Modifier 10 | import androidx.compose.ui.draw.clip 11 | import androidx.compose.ui.unit.dp 12 | import com.tailscale.ipn.UninitializedApp 13 | import com.tailscale.ipn.ui.util.AndroidTVUtil.isAndroidTV 14 | 15 | object AndroidTVUtil { 16 | private val FEATURE_FIRETV = "amazon.hardware.fire_tv" 17 | 18 | fun isAndroidTV(): Boolean { 19 | val pm = UninitializedApp.get().packageManager 20 | return (pm.hasSystemFeature(@Suppress("deprecation") PackageManager.FEATURE_TELEVISION) || 21 | pm.hasSystemFeature(PackageManager.FEATURE_LEANBACK) || 22 | pm.hasSystemFeature(FEATURE_FIRETV)) 23 | } 24 | } 25 | 26 | // Applies a letterbox effect iff we're running on Android TV to reduce the overall width 27 | // of the UI. 28 | fun Modifier.universalFit(): Modifier { 29 | return when (isAndroidTV()) { 30 | true -> this.padding(horizontal = 150.dp, vertical = 10.dp).clip(RoundedCornerShape(10.dp)) 31 | false -> this 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /android/src/main/java/com/tailscale/ipn/ui/util/AppVersion.kt: -------------------------------------------------------------------------------- 1 | // Copyright (c) Tailscale Inc & AUTHORS 2 | // SPDX-License-Identifier: BSD-3-Clause 3 | 4 | package com.tailscale.ipn.ui.util 5 | 6 | import com.tailscale.ipn.BuildConfig 7 | 8 | class AppVersion { 9 | companion object { 10 | // Returns the short version of the build version, which is what users typically expect. 11 | // For instance, if the build version is "1.75.80-t8fdffb8da-g2daeee584df", 12 | // this function returns "1.75.80". 13 | fun Short(): String { 14 | // Split the full version string by hyphen (-) 15 | val parts = BuildConfig.VERSION_NAME.split("-") 16 | // Return only the part before the first hyphen 17 | return parts[0] 18 | } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /android/src/main/java/com/tailscale/ipn/ui/util/AutoResizingText.kt: -------------------------------------------------------------------------------- 1 | // Copyright (c) Tailscale Inc & AUTHORS 2 | // SPDX-License-Identifier: BSD-3-Clause 3 | 4 | package com.tailscale.ipn.ui.util 5 | 6 | import androidx.compose.material3.LocalTextStyle 7 | import androidx.compose.material3.Text 8 | import androidx.compose.runtime.Composable 9 | import androidx.compose.runtime.mutableStateOf 10 | import androidx.compose.runtime.remember 11 | import androidx.compose.ui.Modifier 12 | import androidx.compose.ui.draw.drawWithContent 13 | import androidx.compose.ui.graphics.Color 14 | import androidx.compose.ui.text.TextLayoutResult 15 | import androidx.compose.ui.text.TextStyle 16 | import androidx.compose.ui.text.font.FontFamily 17 | import androidx.compose.ui.text.font.FontStyle 18 | import androidx.compose.ui.text.font.FontWeight 19 | import androidx.compose.ui.text.style.TextAlign 20 | import androidx.compose.ui.text.style.TextDecoration 21 | import androidx.compose.ui.text.style.TextOverflow 22 | import androidx.compose.ui.unit.TextUnit 23 | 24 | // AutoResizingText automatically resizes text up to the specified minFontSize in order to avoid 25 | // overflowing. It is based on https://stackoverflow.com/a/66090448 licensed under CC BY-SA 4.0. 26 | @Composable 27 | fun AutoResizingText( 28 | text: String, 29 | minFontSize: TextUnit, 30 | modifier: Modifier = Modifier, 31 | color: Color = Color.Unspecified, 32 | fontSize: TextUnit = TextUnit.Unspecified, 33 | fontStyle: FontStyle? = null, 34 | fontWeight: FontWeight? = null, 35 | fontFamily: FontFamily? = null, 36 | letterSpacing: TextUnit = TextUnit.Unspecified, 37 | textDecoration: TextDecoration? = null, 38 | textAlign: TextAlign? = null, 39 | lineHeight: TextUnit = TextUnit.Unspecified, 40 | overflow: TextOverflow = TextOverflow.Clip, 41 | maxLines: Int = 1, 42 | onTextLayout: ((TextLayoutResult) -> Unit)? = null, 43 | style: TextStyle = LocalTextStyle.current 44 | ) { 45 | var textStyle = remember { mutableStateOf(style) } 46 | var textOverflow = remember { mutableStateOf(TextOverflow.Clip) } 47 | var readyToDraw = remember { mutableStateOf(false) } 48 | 49 | Text( 50 | text = text, 51 | modifier = modifier.drawWithContent { if (readyToDraw.value) drawContent() }, 52 | color = color, 53 | fontSize = fontSize, 54 | fontStyle = fontStyle, 55 | fontWeight = fontWeight, 56 | fontFamily = fontFamily, 57 | letterSpacing = letterSpacing, 58 | textDecoration = textDecoration, 59 | textAlign = textAlign, 60 | lineHeight = lineHeight, 61 | overflow = textOverflow.value, 62 | maxLines = maxLines, 63 | softWrap = false, 64 | style = textStyle.value, 65 | onTextLayout = { result -> 66 | if (result.didOverflowWidth) { 67 | var newSize = textStyle.value.fontSize * 0.9 68 | if (newSize < minFontSize) { 69 | newSize = minFontSize 70 | textOverflow.value = overflow 71 | } 72 | textStyle.value = textStyle.value.copy(fontSize = newSize) 73 | } else { 74 | readyToDraw.value = true 75 | } 76 | onTextLayout?.let { it(result) } 77 | }) 78 | } 79 | -------------------------------------------------------------------------------- /android/src/main/java/com/tailscale/ipn/ui/util/ClipboardValueView.kt: -------------------------------------------------------------------------------- 1 | // Copyright (c) Tailscale Inc & AUTHORS 2 | // SPDX-License-Identifier: BSD-3-Clause 3 | 4 | package com.tailscale.ipn.ui.util 5 | 6 | import androidx.compose.foundation.LocalIndication 7 | import androidx.compose.foundation.background 8 | import androidx.compose.foundation.clickable 9 | import androidx.compose.foundation.focusable 10 | import androidx.compose.foundation.interaction.MutableInteractionSource 11 | import androidx.compose.foundation.layout.padding 12 | import androidx.compose.foundation.layout.size 13 | import androidx.compose.material3.Icon 14 | import androidx.compose.material3.ListItem 15 | import androidx.compose.material3.MaterialTheme 16 | import androidx.compose.material3.Text 17 | import androidx.compose.runtime.Composable 18 | import androidx.compose.runtime.mutableStateOf 19 | import androidx.compose.runtime.remember 20 | import androidx.compose.ui.Modifier 21 | import androidx.compose.ui.focus.onFocusChanged 22 | import androidx.compose.ui.graphics.Color 23 | import androidx.compose.ui.platform.LocalClipboardManager 24 | import androidx.compose.ui.res.painterResource 25 | import androidx.compose.ui.res.stringResource 26 | import androidx.compose.ui.text.AnnotatedString 27 | import androidx.compose.ui.unit.dp 28 | import com.tailscale.ipn.R 29 | 30 | @Composable 31 | fun ClipboardValueView(value: String, title: String? = null, subtitle: String? = null) { 32 | val isFocused = remember { mutableStateOf(false) } 33 | val localClipboardManager = LocalClipboardManager.current 34 | val interactionSource = remember { MutableInteractionSource() } 35 | 36 | ListItem( 37 | modifier = 38 | Modifier.focusable(interactionSource = interactionSource) 39 | .onFocusChanged { focusState -> isFocused.value = focusState.isFocused } 40 | .clickable( 41 | interactionSource = interactionSource, indication = LocalIndication.current) { 42 | localClipboardManager.setText(AnnotatedString(value)) 43 | } 44 | .background( 45 | if (isFocused.value) MaterialTheme.colorScheme.primary.copy(alpha = 0.12f) 46 | else Color.Transparent), 47 | overlineContent = title?.let { { Text(it, style = MaterialTheme.typography.titleMedium) } }, 48 | headlineContent = { Text(text = value, style = MaterialTheme.typography.bodyMedium) }, 49 | supportingContent = 50 | subtitle?.let { 51 | { 52 | Text( 53 | it, 54 | modifier = Modifier.padding(top = 8.dp), 55 | style = MaterialTheme.typography.bodyMedium) 56 | } 57 | }, 58 | trailingContent = { 59 | Icon( 60 | painterResource(R.drawable.clipboard), 61 | contentDescription = stringResource(R.string.copy_to_clipboard), 62 | modifier = Modifier.size(24.dp)) 63 | }) 64 | } 65 | -------------------------------------------------------------------------------- /android/src/main/java/com/tailscale/ipn/ui/util/ComposableStringFormatter.kt: -------------------------------------------------------------------------------- 1 | // Copyright (c) Tailscale Inc & AUTHORS 2 | // SPDX-License-Identifier: BSD-3-Clause 3 | 4 | package com.tailscale.ipn.ui.util 5 | 6 | import androidx.annotation.StringRes 7 | import androidx.compose.runtime.Composable 8 | import androidx.compose.ui.res.stringResource 9 | import com.tailscale.ipn.R 10 | 11 | // Convenience wrapper for passing formatted strings to Composables 12 | class ComposableStringFormatter( 13 | @StringRes val stringRes: Int = R.string.template, 14 | private vararg val params: Any 15 | ) { 16 | 17 | // Convenience constructor for passing a non-formatted string directly 18 | constructor(string: String) : this(stringRes = R.string.template, string) 19 | 20 | // Returns the fully formatted string 21 | @Composable fun getString(): String = stringResource(id = stringRes, *params) 22 | } 23 | -------------------------------------------------------------------------------- /android/src/main/java/com/tailscale/ipn/ui/util/ConnectionMode.kt: -------------------------------------------------------------------------------- 1 | // Copyright (c) Tailscale Inc & AUTHORS 2 | // SPDX-License-Identifier: BSD-3-Clause 3 | 4 | package com.tailscale.ipn.ui.util 5 | 6 | import androidx.compose.material3.MaterialTheme 7 | import androidx.compose.runtime.Composable 8 | import androidx.compose.ui.graphics.Color 9 | import androidx.compose.ui.res.stringResource 10 | import com.tailscale.ipn.R 11 | import com.tailscale.ipn.ui.theme.on 12 | 13 | sealed class ConnectionMode { 14 | class NotConnected : ConnectionMode() 15 | 16 | class Derp(val relayName: String) : ConnectionMode() 17 | 18 | class Direct : ConnectionMode() 19 | 20 | @Composable 21 | fun titleString(): String { 22 | return when (this) { 23 | is NotConnected -> stringResource(id = R.string.not_connected) 24 | is Derp -> stringResource(R.string.relayed_connection, relayName) 25 | is Direct -> stringResource(R.string.direct_connection) 26 | } 27 | } 28 | 29 | fun contentKey(): String { 30 | return when (this) { 31 | is NotConnected -> "NotConnected" 32 | is Derp -> "Derp($relayName)" 33 | is Direct -> "Direct" 34 | } 35 | } 36 | 37 | fun iconDrawable(): Int { 38 | return when (this) { 39 | is NotConnected -> R.drawable.xmark_circle 40 | is Derp -> R.drawable.link_off 41 | is Direct -> R.drawable.link 42 | } 43 | } 44 | 45 | @Composable 46 | fun color(): Color { 47 | return when (this) { 48 | is NotConnected -> MaterialTheme.colorScheme.onPrimary 49 | is Derp -> MaterialTheme.colorScheme.error 50 | is Direct -> MaterialTheme.colorScheme.on 51 | } 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /android/src/main/java/com/tailscale/ipn/ui/util/DisplayAddress.kt: -------------------------------------------------------------------------------- 1 | // Copyright (c) Tailscale Inc & AUTHORS 2 | // SPDX-License-Identifier: BSD-3-Clause 3 | 4 | package com.tailscale.ipn.ui.util 5 | 6 | class DisplayAddress(ip: String) { 7 | enum class addrType { 8 | V4, 9 | V6, 10 | MagicDNS 11 | } 12 | 13 | val type: addrType = 14 | when { 15 | ip.isIPV6() -> addrType.V6 16 | ip.isIPV4() -> addrType.V4 17 | else -> addrType.MagicDNS 18 | } 19 | 20 | val typeString: String = 21 | when (type) { 22 | addrType.V4 -> "IPv4" 23 | addrType.V6 -> "IPv6" 24 | addrType.MagicDNS -> "MagicDNS" 25 | } 26 | 27 | val address: String = 28 | when (type) { 29 | addrType.MagicDNS -> ip 30 | else -> ip.split("/").first() 31 | } 32 | } 33 | 34 | fun String.isIPV6(): Boolean { 35 | return this.contains(":") 36 | } 37 | 38 | fun String.isIPV4(): Boolean { 39 | val parts = this.split("/").first().split(".") 40 | if (parts.size != 4) return false 41 | for (part in parts) { 42 | val value = part.toIntOrNull() ?: return false 43 | if (value !in 0..255) return false 44 | } 45 | return true 46 | } 47 | -------------------------------------------------------------------------------- /android/src/main/java/com/tailscale/ipn/ui/util/Flag.kt: -------------------------------------------------------------------------------- 1 | // Copyright (c) Tailscale Inc & AUTHORS 2 | // SPDX-License-Identifier: BSD-3-Clause 3 | 4 | package com.tailscale.ipn.ui.util 5 | 6 | /** 7 | * Code adapted from 8 | * https://github.com/piashcse/Compose-museum/blob/master/app/src/main/java/com/piashcse/compose_museum/screens/CountryList.kt#L75 9 | */ 10 | 11 | // Copyright 2023 piashcse (Mehedi Hassan Piash) 12 | // 13 | // Licensed under the Apache License, Version 2.0 (the "License"); 14 | // you may not use this file except in compliance with the License. 15 | // You may obtain a copy of the License at 16 | // 17 | // http://www.apache.org/licenses/LICENSE-2.0 18 | // 19 | // Unless required by applicable law or agreed to in writing, software 20 | // distributed under the License is distributed on an "AS IS" BASIS, 21 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 22 | // See the License for the specific language governing permissions and 23 | // limitations under the License. 24 | 25 | /** Flag turns an ISO3166 country code into a flag emoji. */ 26 | fun String.flag(): String { 27 | val caps = this.uppercase() 28 | val flagOffset = 0x1F1E6 29 | val asciiOffset = 0x41 30 | val firstChar = Character.codePointAt(caps, 0) - asciiOffset + flagOffset 31 | val secondChar = Character.codePointAt(caps, 1) - asciiOffset + flagOffset 32 | return String(Character.toChars(firstChar)) + String(Character.toChars(secondChar)) 33 | } 34 | -------------------------------------------------------------------------------- /android/src/main/java/com/tailscale/ipn/ui/util/InputStreamAdapter.kt: -------------------------------------------------------------------------------- 1 | // Copyright (c) Tailscale Inc & AUTHORS 2 | // SPDX-License-Identifier: BSD-3-Clause 3 | 4 | package com.tailscale.ipn.ui.util 5 | 6 | import java.io.InputStream 7 | 8 | class InputStreamAdapter(private val inputStream: InputStream) : libtailscale.InputStream { 9 | override fun read(): ByteArray? { 10 | val b = ByteArray(4096) 11 | val i = inputStream.read(b) 12 | if (i == -1) { 13 | return null 14 | } 15 | return b.sliceArray(0 ..< i) 16 | } 17 | 18 | override fun close() { 19 | inputStream.close() 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /android/src/main/java/com/tailscale/ipn/ui/util/InstalledAppsManager.kt: -------------------------------------------------------------------------------- 1 | // Copyright (c) Tailscale Inc & AUTHORS 2 | // SPDX-License-Identifier: BSD-3-Clause 3 | 4 | package com.tailscale.ipn.ui.util 5 | 6 | import android.Manifest 7 | import android.content.pm.ApplicationInfo 8 | import android.content.pm.PackageManager 9 | 10 | data class InstalledApp(val name: String, val packageName: String) 11 | 12 | class InstalledAppsManager( 13 | val packageManager: PackageManager, 14 | ) { 15 | fun fetchInstalledApps(): List { 16 | return packageManager 17 | .getInstalledApplications(PackageManager.GET_META_DATA) 18 | .filter(appIsIncluded) 19 | .map { 20 | InstalledApp( 21 | name = it.loadLabel(packageManager).toString(), 22 | packageName = it.packageName, 23 | ) 24 | } 25 | .sortedBy { it.name } 26 | } 27 | 28 | private val appIsIncluded: (ApplicationInfo) -> Boolean = { app -> 29 | app.packageName != "com.tailscale.ipn" && 30 | // Only show apps that can access the Internet 31 | packageManager.checkPermission(Manifest.permission.INTERNET, app.packageName) == 32 | PackageManager.PERMISSION_GRANTED 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /android/src/main/java/com/tailscale/ipn/ui/util/LoadingIndicator.kt: -------------------------------------------------------------------------------- 1 | // Copyright (c) Tailscale Inc & AUTHORS 2 | // SPDX-License-Identifier: BSD-3-Clause 3 | 4 | package com.tailscale.ipn.ui.util 5 | 6 | import androidx.compose.foundation.background 7 | import androidx.compose.foundation.clickable 8 | import androidx.compose.foundation.layout.Box 9 | import androidx.compose.foundation.layout.Column 10 | import androidx.compose.foundation.layout.fillMaxWidth 11 | import androidx.compose.foundation.layout.size 12 | import androidx.compose.runtime.Composable 13 | import androidx.compose.runtime.State 14 | import androidx.compose.runtime.collectAsState 15 | import androidx.compose.runtime.getValue 16 | import androidx.compose.runtime.produceState 17 | import androidx.compose.ui.Alignment 18 | import androidx.compose.ui.Modifier 19 | import androidx.compose.ui.draw.alpha 20 | import androidx.compose.ui.graphics.Color 21 | import androidx.compose.ui.unit.dp 22 | import com.tailscale.ipn.ui.view.TailscaleLogoView 23 | import kotlinx.coroutines.delay 24 | import kotlinx.coroutines.flow.MutableStateFlow 25 | 26 | object LoadingIndicator { 27 | private val loading = MutableStateFlow(false) 28 | 29 | fun start() { 30 | loading.value = true 31 | } 32 | 33 | fun stop() { 34 | loading.value = false 35 | } 36 | 37 | @Composable 38 | fun Wrap(content: @Composable () -> Unit) { 39 | Box( 40 | modifier = Modifier.fillMaxWidth(), 41 | contentAlignment = Alignment.Center, 42 | ) { 43 | content() 44 | val isLoading by loading.collectAsState() 45 | if (isLoading) { 46 | Box(Modifier.clickable {}.matchParentSize().background(Color.Gray.copy(alpha = 0.0f))) 47 | 48 | val showSpinner: State = 49 | produceState(initialValue = false) { 50 | delay(300) 51 | value = true 52 | } 53 | 54 | if (showSpinner.value) { 55 | Column( 56 | modifier = Modifier.fillMaxWidth(), 57 | horizontalAlignment = Alignment.CenterHorizontally) { 58 | TailscaleLogoView( 59 | true, usesOnBackgroundColors = false, Modifier.size(72.dp).alpha(0.4f)) 60 | } 61 | } 62 | } 63 | } 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /android/src/main/java/com/tailscale/ipn/ui/util/ModifierUtil.kt: -------------------------------------------------------------------------------- 1 | // Copyright (c) Tailscale Inc & AUTHORS 2 | // SPDX-License-Identifier: BSD-3-Clause 3 | 4 | package com.tailscale.ipn.ui.util 5 | 6 | import androidx.compose.ui.Modifier 7 | 8 | /// Applies different modifiers to the receiver based on a condition. 9 | inline fun Modifier.conditional( 10 | condition: Boolean, 11 | ifTrue: Modifier.() -> Modifier, 12 | ifFalse: Modifier.() -> Modifier = { this }, 13 | ): Modifier = 14 | if (condition) { 15 | then(ifTrue(Modifier)) 16 | } else { 17 | then(ifFalse(Modifier)) 18 | } 19 | -------------------------------------------------------------------------------- /android/src/main/java/com/tailscale/ipn/ui/util/OutputStreamAdapter.kt: -------------------------------------------------------------------------------- 1 | // Copyright (c) Tailscale Inc & AUTHORS 2 | // SPDX-License-Identifier: BSD-3-Clause 3 | 4 | package com.tailscale.ipn.ui.util 5 | 6 | import com.tailscale.ipn.util.TSLog 7 | import java.io.OutputStream 8 | 9 | // This class adapts a Java OutputStream to the libtailscale.OutputStream interface. 10 | class OutputStreamAdapter(private val outputStream: OutputStream) : libtailscale.OutputStream { 11 | // writes data to the outputStream in its entirety. Returns -1 on error. 12 | override fun write(data: ByteArray): Long { 13 | return try { 14 | outputStream.write(data) 15 | outputStream.flush() 16 | data.size.toLong() 17 | } catch (e: Exception) { 18 | TSLog.d("OutputStreamAdapter", "write exception: $e") 19 | -1L 20 | } 21 | } 22 | 23 | override fun close() { 24 | outputStream.close() 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /android/src/main/java/com/tailscale/ipn/ui/util/PermissionsDisplayUtil.kt: -------------------------------------------------------------------------------- 1 | // Copyright (c) Tailscale Inc & AUTHORS 2 | // SPDX-License-Identifier: BSD-3-Clause 3 | 4 | package com.tailscale.ipn.ui.util 5 | 6 | import android.net.Uri 7 | 8 | /** Converts a SAF URI string to a more human-friendly folder display name. */ 9 | fun friendlyDirName(uriStr: String): String { 10 | val uri = Uri.parse(uriStr) 11 | val segment = uri.lastPathSegment ?: return uriStr 12 | 13 | return when { 14 | segment.startsWith("primary:") -> "Internal storage › " + segment.removePrefix("primary:") 15 | segment.contains(":") -> { 16 | val folder = segment.substringAfter(":") 17 | "SD card › $folder" 18 | } 19 | else -> segment 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /android/src/main/java/com/tailscale/ipn/ui/util/StateFlow.kt: -------------------------------------------------------------------------------- 1 | // Copyright (c) Tailscale Inc & AUTHORS 2 | // SPDX-License-Identifier: BSD-3-Clause 3 | 4 | package com.tailscale.ipn.ui.util 5 | 6 | import kotlinx.coroutines.flow.MutableStateFlow 7 | import kotlinx.coroutines.flow.StateFlow 8 | 9 | /** Provides a way to expose a MutableStateFlow as an immutable StateFlow. */ 10 | fun StateFlow.set(v: T) { 11 | (this as MutableStateFlow).value = v 12 | } 13 | -------------------------------------------------------------------------------- /android/src/main/java/com/tailscale/ipn/ui/view/Avatar.kt: -------------------------------------------------------------------------------- 1 | // Copyright (c) Tailscale Inc & AUTHORS 2 | // SPDX-License-Identifier: BSD-3-Clause 3 | 4 | package com.tailscale.ipn.ui.view 5 | 6 | import androidx.compose.foundation.background 7 | import androidx.compose.foundation.clickable 8 | import androidx.compose.foundation.focusable 9 | import androidx.compose.foundation.interaction.MutableInteractionSource 10 | import androidx.compose.foundation.layout.Box 11 | import androidx.compose.foundation.layout.padding 12 | import androidx.compose.foundation.layout.size 13 | import androidx.compose.foundation.shape.CircleShape 14 | import androidx.compose.material.icons.Icons 15 | import androidx.compose.material.icons.filled.Person 16 | import androidx.compose.material3.Icon 17 | import androidx.compose.material3.MaterialTheme 18 | import androidx.compose.material3.ripple 19 | import androidx.compose.runtime.Composable 20 | import androidx.compose.runtime.mutableStateOf 21 | import androidx.compose.runtime.remember 22 | import androidx.compose.ui.Alignment 23 | import androidx.compose.ui.Modifier 24 | import androidx.compose.ui.draw.clip 25 | import androidx.compose.ui.focus.onFocusChanged 26 | import androidx.compose.ui.graphics.Color 27 | import androidx.compose.ui.platform.LocalFocusManager 28 | import androidx.compose.ui.res.stringResource 29 | import androidx.compose.ui.unit.dp 30 | import coil.annotation.ExperimentalCoilApi 31 | import coil.compose.AsyncImage 32 | import com.tailscale.ipn.R 33 | import com.tailscale.ipn.ui.model.IpnLocal 34 | import com.tailscale.ipn.ui.util.AndroidTVUtil 35 | import com.tailscale.ipn.ui.util.conditional 36 | 37 | @OptIn(ExperimentalCoilApi::class) 38 | @Composable 39 | fun Avatar( 40 | profile: IpnLocal.LoginProfile?, 41 | size: Int = 50, 42 | action: (() -> Unit)? = null, 43 | isFocusable: Boolean = false 44 | ) { 45 | val isFocused = remember { mutableStateOf(false) } 46 | val focusManager = LocalFocusManager.current 47 | 48 | // Outer Box for the larger focusable and clickable area 49 | Box( 50 | contentAlignment = Alignment.Center, 51 | modifier = 52 | Modifier.conditional(AndroidTVUtil.isAndroidTV(), { padding(4.dp) }) 53 | .conditional( 54 | AndroidTVUtil.isAndroidTV() && isFocusable, 55 | { 56 | size((size * 1.5f).dp) // Focusable area is larger than the avatar 57 | }) 58 | .clip(CircleShape) // Ensure both the focus and click area are circular 59 | .background( 60 | if (isFocused.value) MaterialTheme.colorScheme.surface else Color.Transparent, 61 | ) 62 | .onFocusChanged { focusState -> isFocused.value = focusState.isFocused } 63 | .focusable() // Make this outer Box focusable (after onFocusChanged) 64 | .clickable( 65 | interactionSource = remember { MutableInteractionSource() }, 66 | indication = ripple(bounded = true), // Apply ripple effect inside circular bounds 67 | onClick = { 68 | action?.invoke() 69 | focusManager.clearFocus() // Clear focus after clicking the avatar 70 | })) { 71 | // Inner Box to hold the avatar content (Icon or AsyncImage) 72 | Box( 73 | contentAlignment = Alignment.Center, 74 | modifier = Modifier.size(size.dp).clip(CircleShape)) { 75 | // Always display the default icon as a background layer 76 | Icon( 77 | imageVector = Icons.Default.Person, 78 | contentDescription = stringResource(R.string.settings_title), 79 | modifier = 80 | Modifier.conditional(AndroidTVUtil.isAndroidTV(), { size((size * 0.8f).dp) }) 81 | .clip(CircleShape) // Icon size slightly smaller than the Box 82 | ) 83 | 84 | // Overlay the profile picture if available 85 | profile?.UserProfile?.ProfilePicURL?.let { url -> 86 | AsyncImage( 87 | model = url, 88 | modifier = Modifier.size(size.dp).clip(CircleShape), 89 | contentDescription = null) 90 | } 91 | } 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /android/src/main/java/com/tailscale/ipn/ui/view/BugReportView.kt: -------------------------------------------------------------------------------- 1 | // Copyright (c) Tailscale Inc & AUTHORS 2 | // SPDX-License-Identifier: BSD-3-Clause 3 | 4 | package com.tailscale.ipn.ui.view 5 | 6 | import androidx.compose.foundation.layout.Column 7 | import androidx.compose.foundation.layout.fillMaxHeight 8 | import androidx.compose.foundation.layout.fillMaxWidth 9 | import androidx.compose.foundation.layout.padding 10 | import androidx.compose.foundation.rememberScrollState 11 | import androidx.compose.foundation.text.ClickableText 12 | import androidx.compose.foundation.verticalScroll 13 | import androidx.compose.material3.MaterialTheme 14 | import androidx.compose.material3.Scaffold 15 | import androidx.compose.runtime.Composable 16 | import androidx.compose.runtime.collectAsState 17 | import androidx.compose.runtime.getValue 18 | import androidx.compose.ui.Modifier 19 | import androidx.compose.ui.platform.LocalUriHandler 20 | import androidx.compose.ui.res.stringResource 21 | import androidx.compose.ui.text.AnnotatedString 22 | import androidx.compose.ui.text.SpanStyle 23 | import androidx.compose.ui.text.buildAnnotatedString 24 | import androidx.compose.ui.text.style.TextDecoration 25 | import androidx.compose.ui.text.withStyle 26 | import androidx.compose.ui.tooling.preview.Preview 27 | import androidx.lifecycle.viewmodel.compose.viewModel 28 | import com.tailscale.ipn.R 29 | import com.tailscale.ipn.ui.Links 30 | import com.tailscale.ipn.ui.theme.defaultTextColor 31 | import com.tailscale.ipn.ui.theme.link 32 | import com.tailscale.ipn.ui.util.ClipboardValueView 33 | import com.tailscale.ipn.ui.util.Lists 34 | import com.tailscale.ipn.ui.util.set 35 | import com.tailscale.ipn.ui.viewModel.BugReportViewModel 36 | 37 | @Composable 38 | fun BugReportView(backToSettings: BackNavigation, model: BugReportViewModel = viewModel()) { 39 | val handler = LocalUriHandler.current 40 | val bugReportID by model.bugReportID.collectAsState() 41 | 42 | Scaffold(topBar = { Header(R.string.bug_report_title, onBack = backToSettings) }) { innerPadding 43 | -> 44 | Column( 45 | modifier = 46 | Modifier.padding(innerPadding) 47 | .fillMaxWidth() 48 | .fillMaxHeight() 49 | .verticalScroll(rememberScrollState())) { 50 | Lists.MultilineDescription { 51 | ClickableText( 52 | text = contactText(), 53 | style = MaterialTheme.typography.bodyMedium, 54 | onClick = { handler.openUri(Links.SUPPORT_URL) }) 55 | } 56 | 57 | ClipboardValueView(bugReportID, title = stringResource(R.string.bug_report_id)) 58 | 59 | Lists.InfoItem(stringResource(id = R.string.bug_report_id_desc)) 60 | } 61 | } 62 | } 63 | 64 | @Composable 65 | fun contactText(): AnnotatedString { 66 | val annotatedString = buildAnnotatedString { 67 | withStyle(SpanStyle(color = MaterialTheme.colorScheme.defaultTextColor)) { 68 | append(stringResource(id = R.string.bug_report_instructions_prefix)) 69 | } 70 | 71 | pushStringAnnotation(tag = "reportLink", annotation = Links.SUPPORT_URL) 72 | withStyle( 73 | style = 74 | SpanStyle( 75 | color = MaterialTheme.colorScheme.link, 76 | textDecoration = TextDecoration.Underline)) { 77 | append(stringResource(id = R.string.bug_report_instructions_linktext)) 78 | } 79 | pop() 80 | 81 | withStyle(SpanStyle(color = MaterialTheme.colorScheme.defaultTextColor)) { 82 | append(stringResource(id = R.string.bug_report_instructions_suffix)) 83 | } 84 | } 85 | return annotatedString 86 | } 87 | 88 | @Preview 89 | @Composable 90 | fun BugReportPreview() { 91 | val vm = BugReportViewModel() 92 | vm.bugReportID.set("12345678ABCDEF-12345678ABCDEF") 93 | BugReportView({}, vm) 94 | } 95 | -------------------------------------------------------------------------------- /android/src/main/java/com/tailscale/ipn/ui/view/Buttons.kt: -------------------------------------------------------------------------------- 1 | // Copyright (c) Tailscale Inc & AUTHORS 2 | // SPDX-License-Identifier: BSD-3-Clause 3 | 4 | package com.tailscale.ipn.ui.view 5 | 6 | import androidx.compose.foundation.layout.PaddingValues 7 | import androidx.compose.foundation.layout.RowScope 8 | import androidx.compose.foundation.layout.fillMaxWidth 9 | import androidx.compose.material3.Button 10 | import androidx.compose.material3.MaterialTheme 11 | import androidx.compose.material3.Text 12 | import androidx.compose.material3.TextButton 13 | import androidx.compose.runtime.Composable 14 | import androidx.compose.ui.Modifier 15 | import androidx.compose.ui.platform.LocalUriHandler 16 | import androidx.compose.ui.text.style.TextDecoration 17 | import androidx.compose.ui.unit.dp 18 | import com.tailscale.ipn.ui.theme.link 19 | 20 | @Composable 21 | fun PrimaryActionButton(onClick: () -> Unit, content: @Composable RowScope.() -> Unit) { 22 | Button( 23 | onClick = onClick, 24 | contentPadding = PaddingValues(vertical = 12.dp), 25 | modifier = Modifier.fillMaxWidth(), 26 | content = content) 27 | } 28 | 29 | @Composable 30 | fun OpenURLButton(title: String, url: String) { 31 | val handler = LocalUriHandler.current 32 | 33 | TextButton(onClick = { handler.openUri(url) }) { 34 | Text( 35 | title, 36 | style = MaterialTheme.typography.bodyMedium, 37 | color = MaterialTheme.colorScheme.link, 38 | textDecoration = TextDecoration.Underline, 39 | ) 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /android/src/main/java/com/tailscale/ipn/ui/view/EditSubnetRouteDialogView.kt: -------------------------------------------------------------------------------- 1 | // Copyright (c) Tailscale Inc & AUTHORS 2 | // SPDX-License-Identifier: BSD-3-Clause 3 | 4 | package com.tailscale.ipn.ui.view 5 | 6 | import androidx.compose.foundation.layout.Column 7 | import androidx.compose.foundation.layout.Row 8 | import androidx.compose.foundation.layout.Spacer 9 | import androidx.compose.foundation.layout.height 10 | import androidx.compose.foundation.layout.padding 11 | import androidx.compose.foundation.layout.width 12 | import androidx.compose.material3.Button 13 | import androidx.compose.material3.ButtonDefaults 14 | import androidx.compose.material3.MaterialTheme 15 | import androidx.compose.material3.Text 16 | import androidx.compose.material3.TextField 17 | import androidx.compose.runtime.Composable 18 | import androidx.compose.runtime.LaunchedEffect 19 | import androidx.compose.runtime.collectAsState 20 | import androidx.compose.runtime.getValue 21 | import androidx.compose.runtime.remember 22 | import androidx.compose.runtime.snapshotFlow 23 | import androidx.compose.ui.Alignment 24 | import androidx.compose.ui.Modifier 25 | import androidx.compose.ui.focus.FocusRequester 26 | import androidx.compose.ui.focus.focusRequester 27 | import androidx.compose.ui.platform.LocalWindowInfo 28 | import androidx.compose.ui.res.stringResource 29 | import androidx.compose.ui.unit.dp 30 | import com.tailscale.ipn.R 31 | import kotlinx.coroutines.flow.MutableStateFlow 32 | import kotlinx.coroutines.flow.StateFlow 33 | 34 | /** 35 | * EditSubnetRouteDialogView is the content of the dialog that allows the user to add or edit a 36 | * subnet route. 37 | */ 38 | @Composable 39 | fun EditSubnetRouteDialogView( 40 | valueFlow: MutableStateFlow, 41 | isValueValidFlow: StateFlow, 42 | onValueChange: (String) -> Unit, 43 | onCommit: (String) -> Unit, 44 | onCancel: () -> Unit 45 | ) { 46 | val value by valueFlow.collectAsState() 47 | val isValueValid by isValueValidFlow.collectAsState() 48 | val focusRequester = remember { FocusRequester() } 49 | 50 | Column( 51 | modifier = Modifier.padding(16.dp), 52 | ) { 53 | Text(text = stringResource(R.string.enter_valid_route)) 54 | 55 | Text( 56 | text = stringResource(R.string.route_help_text), 57 | color = MaterialTheme.colorScheme.secondary, 58 | fontSize = MaterialTheme.typography.bodySmall.fontSize) 59 | 60 | Spacer(modifier = Modifier.height(8.dp)) 61 | 62 | TextField( 63 | value = value, 64 | onValueChange = { onValueChange(it) }, 65 | singleLine = true, 66 | isError = !isValueValid, 67 | modifier = Modifier.focusRequester(focusRequester)) 68 | 69 | Spacer(modifier = Modifier.height(8.dp)) 70 | 71 | Row(modifier = Modifier.align(Alignment.End)) { 72 | Button(colors = ButtonDefaults.outlinedButtonColors(), onClick = { onCancel() }) { 73 | Text(stringResource(R.string.cancel)) 74 | } 75 | 76 | Spacer(modifier = Modifier.width(8.dp)) 77 | 78 | Button(onClick = { onCommit(value) }, enabled = value.isNotEmpty() && isValueValid) { 79 | Text(stringResource(R.string.ok)) 80 | } 81 | } 82 | } 83 | 84 | // When the dialog is opened, focus on the text field to present the keyboard auto-magically. 85 | val windowInfo = LocalWindowInfo.current 86 | LaunchedEffect(windowInfo) { 87 | snapshotFlow { windowInfo.isWindowFocused } 88 | .collect { isWindowFocused -> 89 | if (isWindowFocused) { 90 | focusRequester.requestFocus() 91 | } 92 | } 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /android/src/main/java/com/tailscale/ipn/ui/view/ErrorDialog.kt: -------------------------------------------------------------------------------- 1 | // Copyright (c) Tailscale Inc & AUTHORS 2 | // SPDX-License-Identifier: BSD-3-Clause 3 | 4 | package com.tailscale.ipn.ui.view 5 | 6 | import androidx.annotation.StringRes 7 | import androidx.compose.material3.AlertDialog 8 | import androidx.compose.material3.Text 9 | import androidx.compose.runtime.Composable 10 | import androidx.compose.ui.res.stringResource 11 | import androidx.compose.ui.tooling.preview.Preview 12 | import com.tailscale.ipn.R 13 | import com.tailscale.ipn.ui.theme.AppTheme 14 | 15 | enum class ErrorDialogType { 16 | INVALID_CUSTOM_URL, 17 | LOGOUT_FAILED, 18 | SWITCH_USER_FAILED, 19 | ADD_PROFILE_FAILED, 20 | SHARE_DEVICE_NOT_CONNECTED, 21 | SHARE_FAILED, 22 | INVALID_AUTH_KEY; 23 | 24 | val message: Int 25 | get() { 26 | return when (this) { 27 | INVALID_CUSTOM_URL -> R.string.invalidCustomUrl 28 | LOGOUT_FAILED -> R.string.logout_failed 29 | SWITCH_USER_FAILED -> R.string.switch_user_failed 30 | ADD_PROFILE_FAILED -> R.string.add_profile_failed 31 | SHARE_DEVICE_NOT_CONNECTED -> R.string.share_device_not_connected 32 | SHARE_FAILED -> R.string.taildrop_share_failed 33 | INVALID_AUTH_KEY -> R.string.invalidAuthKey 34 | } 35 | } 36 | 37 | val title: Int 38 | get() { 39 | return when (this) { 40 | INVALID_CUSTOM_URL -> R.string.invalidCustomURLTitle 41 | LOGOUT_FAILED -> R.string.logout_failed_title 42 | SWITCH_USER_FAILED -> R.string.switch_user_failed_title 43 | ADD_PROFILE_FAILED -> R.string.add_profile_failed_title 44 | SHARE_DEVICE_NOT_CONNECTED -> R.string.share_device_not_connected_title 45 | SHARE_FAILED -> R.string.taildrop_share_failed_title 46 | INVALID_AUTH_KEY -> R.string.invalidAuthKeyTitle 47 | } 48 | } 49 | 50 | val buttonText: Int = R.string.ok 51 | } 52 | 53 | @Composable 54 | fun ErrorDialog(type: ErrorDialogType, action: () -> Unit = {}) { 55 | ErrorDialog( 56 | title = type.title, 57 | message = stringResource(id = type.message), 58 | buttonText = type.buttonText, 59 | onDismiss = action) 60 | } 61 | 62 | @Composable 63 | fun ErrorDialog( 64 | @StringRes title: Int = R.string.error, 65 | @StringRes message: Int, 66 | @StringRes buttonText: Int = R.string.ok, 67 | onDismiss: () -> Unit = {} 68 | ) { 69 | ErrorDialog( 70 | title = title, 71 | message = stringResource(id = message), 72 | buttonText = buttonText, 73 | onDismiss = onDismiss) 74 | } 75 | 76 | @Composable 77 | fun ErrorDialog( 78 | @StringRes title: Int = R.string.error, 79 | message: String, 80 | @StringRes buttonText: Int = R.string.ok, 81 | onDismiss: () -> Unit = {} 82 | ) { 83 | AppTheme { 84 | AlertDialog( 85 | onDismissRequest = onDismiss, 86 | title = { Text(text = stringResource(id = title)) }, 87 | text = { Text(text = message) }, 88 | confirmButton = { 89 | PrimaryActionButton(onClick = onDismiss) { Text(text = stringResource(id = buttonText)) } 90 | }) 91 | } 92 | } 93 | 94 | @Preview 95 | @Composable 96 | fun ErrorDialogPreview() { 97 | ErrorDialog(ErrorDialogType.LOGOUT_FAILED) 98 | } 99 | -------------------------------------------------------------------------------- /android/src/main/java/com/tailscale/ipn/ui/view/IntroView.kt: -------------------------------------------------------------------------------- 1 | // Copyright (c) Tailscale Inc & AUTHORS 2 | // SPDX-License-Identifier: BSD-3-Clause 3 | 4 | package com.tailscale.ipn.ui.view 5 | 6 | import androidx.compose.foundation.layout.Arrangement 7 | import androidx.compose.foundation.layout.Box 8 | import androidx.compose.foundation.layout.Column 9 | import androidx.compose.foundation.layout.Spacer 10 | import androidx.compose.foundation.layout.fillMaxHeight 11 | import androidx.compose.foundation.layout.fillMaxWidth 12 | import androidx.compose.foundation.layout.height 13 | import androidx.compose.foundation.layout.padding 14 | import androidx.compose.foundation.layout.width 15 | import androidx.compose.foundation.rememberScrollState 16 | import androidx.compose.foundation.verticalScroll 17 | import androidx.compose.material3.Button 18 | import androidx.compose.material3.MaterialTheme 19 | import androidx.compose.material3.Surface 20 | import androidx.compose.material3.Text 21 | import androidx.compose.runtime.Composable 22 | import androidx.compose.ui.Alignment 23 | import androidx.compose.ui.Modifier 24 | import androidx.compose.ui.res.stringResource 25 | import androidx.compose.ui.text.style.TextAlign 26 | import androidx.compose.ui.tooling.preview.Preview 27 | import androidx.compose.ui.unit.dp 28 | import com.tailscale.ipn.R 29 | import com.tailscale.ipn.ui.theme.AppTheme 30 | 31 | @Composable 32 | fun IntroView(onContinue: () -> Unit) { 33 | Column( 34 | modifier = Modifier.fillMaxHeight().fillMaxWidth().verticalScroll(rememberScrollState()), 35 | horizontalAlignment = Alignment.CenterHorizontally, 36 | verticalArrangement = Arrangement.Center) { 37 | TailscaleLogoView(modifier = Modifier.width(60.dp).height(60.dp)) 38 | Spacer(modifier = Modifier.height(40.dp)) 39 | Text( 40 | modifier = Modifier.padding(start = 40.dp, end = 40.dp, bottom = 40.dp), 41 | text = stringResource(R.string.welcome1), 42 | style = MaterialTheme.typography.bodyLarge, 43 | textAlign = TextAlign.Center) 44 | 45 | Button(onClick = onContinue) { 46 | Text( 47 | text = stringResource(id = R.string.getStarted), 48 | fontSize = MaterialTheme.typography.titleMedium.fontSize) 49 | } 50 | Spacer(modifier = Modifier.height(40.dp)) 51 | 52 | Box( 53 | modifier = Modifier.fillMaxHeight().padding(start = 20.dp, end = 20.dp, bottom = 40.dp), 54 | contentAlignment = Alignment.BottomCenter) { 55 | Text( 56 | text = stringResource(R.string.welcome2), 57 | style = MaterialTheme.typography.bodyMedium, 58 | color = MaterialTheme.colorScheme.onSurfaceVariant, 59 | textAlign = TextAlign.Center) 60 | } 61 | } 62 | } 63 | 64 | @Composable 65 | @Preview 66 | fun IntroViewPreview() { 67 | AppTheme { Surface { IntroView({}) } } 68 | } 69 | -------------------------------------------------------------------------------- /android/src/main/java/com/tailscale/ipn/ui/view/MDMSettingsDebugView.kt: -------------------------------------------------------------------------------- 1 | // Copyright (c) Tailscale Inc & AUTHORS 2 | // SPDX-License-Identifier: BSD-3-Clause 3 | 4 | package com.tailscale.ipn.ui.view 5 | 6 | import androidx.compose.foundation.layout.padding 7 | import androidx.compose.foundation.lazy.LazyColumn 8 | import androidx.compose.material3.ExperimentalMaterial3Api 9 | import androidx.compose.material3.ListItem 10 | import androidx.compose.material3.MaterialTheme 11 | import androidx.compose.material3.Scaffold 12 | import androidx.compose.material3.Text 13 | import androidx.compose.runtime.Composable 14 | import androidx.compose.runtime.collectAsState 15 | import androidx.compose.runtime.getValue 16 | import androidx.compose.ui.Modifier 17 | import androidx.compose.ui.text.font.FontFamily 18 | import androidx.compose.ui.text.font.FontWeight 19 | import androidx.lifecycle.viewmodel.compose.viewModel 20 | import com.tailscale.ipn.R 21 | import com.tailscale.ipn.mdm.MDMSetting 22 | import com.tailscale.ipn.mdm.MDMSettings 23 | import com.tailscale.ipn.ui.util.itemsWithDividers 24 | import com.tailscale.ipn.ui.viewModel.IpnViewModel 25 | 26 | @OptIn(ExperimentalMaterial3Api::class) 27 | @Composable 28 | fun MDMSettingsDebugView( 29 | backToSettings: BackNavigation, 30 | @Suppress("UNUSED_PARAMETER") model: IpnViewModel = viewModel() 31 | ) { 32 | Scaffold(topBar = { Header(R.string.current_mdm_settings, onBack = backToSettings) }) { 33 | innerPadding -> 34 | LazyColumn(modifier = Modifier.padding(innerPadding)) { 35 | itemsWithDividers(MDMSettings.allSettings.sortedBy { "${it::class.java.name}|${it.key}" }) { 36 | setting -> 37 | MDMSettingView(setting) 38 | } 39 | } 40 | } 41 | } 42 | 43 | @Composable 44 | fun MDMSettingView(setting: MDMSetting<*>) { 45 | val value by setting.flow.collectAsState() 46 | ListItem( 47 | headlineContent = { Text(setting.localizedTitle, maxLines = 3) }, 48 | supportingContent = { 49 | Text( 50 | setting.key, 51 | fontSize = MaterialTheme.typography.labelSmall.fontSize, 52 | fontFamily = FontFamily.Monospace) 53 | }, 54 | trailingContent = { 55 | Text( 56 | if (value.isSet) value.value.toString() else "[not set]", 57 | fontFamily = FontFamily.Monospace, 58 | maxLines = 1, 59 | fontWeight = FontWeight.SemiBold) 60 | }) 61 | } 62 | -------------------------------------------------------------------------------- /android/src/main/java/com/tailscale/ipn/ui/view/ManagedByView.kt: -------------------------------------------------------------------------------- 1 | // Copyright (c) Tailscale Inc & AUTHORS 2 | // SPDX-License-Identifier: BSD-3-Clause 3 | 4 | package com.tailscale.ipn.ui.view 5 | 6 | import androidx.compose.foundation.layout.Arrangement 7 | import androidx.compose.foundation.layout.Column 8 | import androidx.compose.foundation.layout.fillMaxWidth 9 | import androidx.compose.foundation.layout.safeContentPadding 10 | import androidx.compose.foundation.rememberScrollState 11 | import androidx.compose.foundation.verticalScroll 12 | import androidx.compose.material3.Scaffold 13 | import androidx.compose.material3.Text 14 | import androidx.compose.runtime.Composable 15 | import androidx.compose.runtime.collectAsState 16 | import androidx.compose.ui.Alignment 17 | import androidx.compose.ui.Modifier 18 | import androidx.compose.ui.res.stringResource 19 | import androidx.compose.ui.tooling.preview.Preview 20 | import androidx.compose.ui.unit.dp 21 | import androidx.lifecycle.viewmodel.compose.viewModel 22 | import com.tailscale.ipn.R 23 | import com.tailscale.ipn.mdm.MDMSettings 24 | import com.tailscale.ipn.ui.viewModel.IpnViewModel 25 | 26 | @Suppress("UNUSED_PARAMETER") 27 | @Composable 28 | fun ManagedByView(backToSettings: BackNavigation, model: IpnViewModel = viewModel()) { 29 | Scaffold(topBar = { Header(R.string.managed_by, onBack = backToSettings) }) { _ -> 30 | Column( 31 | verticalArrangement = 32 | Arrangement.spacedBy(space = 20.dp, alignment = Alignment.CenterVertically), 33 | horizontalAlignment = Alignment.Start, 34 | modifier = 35 | Modifier.fillMaxWidth().safeContentPadding().verticalScroll(rememberScrollState())) { 36 | val managedByOrganization = 37 | MDMSettings.managedByOrganizationName.flow.collectAsState().value.value 38 | val managedByCaption = MDMSettings.managedByCaption.flow.collectAsState().value.value 39 | val managedByURL = MDMSettings.managedByURL.flow.collectAsState().value.value 40 | managedByOrganization?.let { 41 | Text(stringResource(R.string.managed_by_explainer_orgName, it)) 42 | } ?: run { Text(stringResource(R.string.managed_by_explainer)) } 43 | managedByCaption?.let { 44 | if (it.isNotEmpty()) { 45 | Text(it) 46 | } 47 | } 48 | managedByURL?.let { OpenURLButton(stringResource(R.string.open_support), it) } 49 | } 50 | } 51 | } 52 | 53 | @Preview 54 | @Composable 55 | fun ManagedByViewPreview() { 56 | val vm = IpnViewModel() 57 | ManagedByView(backToSettings = {}, vm) 58 | } 59 | -------------------------------------------------------------------------------- /android/src/main/java/com/tailscale/ipn/ui/view/MullvadExitNodePicker.kt: -------------------------------------------------------------------------------- 1 | // Copyright (c) Tailscale Inc & AUTHORS 2 | // SPDX-License-Identifier: BSD-3-Clause 3 | 4 | package com.tailscale.ipn.ui.view 5 | 6 | import androidx.compose.foundation.layout.padding 7 | import androidx.compose.foundation.lazy.LazyColumn 8 | import androidx.compose.material3.ExperimentalMaterial3Api 9 | import androidx.compose.material3.Scaffold 10 | import androidx.compose.material3.Text 11 | import androidx.compose.runtime.Composable 12 | import androidx.compose.runtime.collectAsState 13 | import androidx.compose.runtime.getValue 14 | import androidx.compose.ui.Modifier 15 | import androidx.compose.ui.res.stringResource 16 | import androidx.lifecycle.viewmodel.compose.viewModel 17 | import com.tailscale.ipn.R 18 | import com.tailscale.ipn.ui.util.Lists 19 | import com.tailscale.ipn.ui.util.LoadingIndicator 20 | import com.tailscale.ipn.ui.util.flag 21 | import com.tailscale.ipn.ui.util.itemsWithDividers 22 | import com.tailscale.ipn.ui.viewModel.ExitNodePickerNav 23 | import com.tailscale.ipn.ui.viewModel.ExitNodePickerViewModel 24 | import com.tailscale.ipn.ui.viewModel.ExitNodePickerViewModelFactory 25 | 26 | @OptIn(ExperimentalMaterial3Api::class) 27 | @Composable 28 | fun MullvadExitNodePicker( 29 | countryCode: String, 30 | nav: ExitNodePickerNav, 31 | model: ExitNodePickerViewModel = viewModel(factory = ExitNodePickerViewModelFactory(nav)) 32 | ) { 33 | val mullvadExitNodes by model.mullvadExitNodesByCountryCode.collectAsState() 34 | val bestAvailableByCountry by model.mullvadBestAvailableByCountry.collectAsState() 35 | 36 | mullvadExitNodes[countryCode]?.toList()?.let { nodes -> 37 | val any = nodes.first() 38 | 39 | LoadingIndicator.Wrap { 40 | Scaffold( 41 | topBar = { 42 | Header( 43 | title = { Text("${countryCode.flag()} ${any.country}") }, 44 | onBack = nav.onNavigateBackToMullvad) 45 | }) { innerPadding -> 46 | LazyColumn(modifier = Modifier.padding(innerPadding)) { 47 | if (nodes.size > 1) { 48 | val bestAvailableNode = bestAvailableByCountry[countryCode]!! 49 | item { 50 | ExitNodeItem( 51 | model, 52 | ExitNodePickerViewModel.ExitNode( 53 | id = bestAvailableNode.id, 54 | label = stringResource(R.string.best_available), 55 | online = bestAvailableNode.online, 56 | selected = false, 57 | )) 58 | Lists.SectionDivider() 59 | } 60 | } 61 | 62 | itemsWithDividers(nodes) { node -> ExitNodeItem(model, node) } 63 | } 64 | } 65 | } 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /android/src/main/java/com/tailscale/ipn/ui/view/MullvadInfoView.kt: -------------------------------------------------------------------------------- 1 | // Copyright (c) Tailscale Inc & AUTHORS 2 | // SPDX-License-Identifier: BSD-3-Clause 3 | 4 | package com.tailscale.ipn.ui.view 5 | 6 | import androidx.compose.foundation.Image 7 | import androidx.compose.foundation.layout.Arrangement 8 | import androidx.compose.foundation.layout.PaddingValues 9 | import androidx.compose.foundation.layout.padding 10 | import androidx.compose.foundation.lazy.LazyColumn 11 | import androidx.compose.material3.MaterialTheme 12 | import androidx.compose.material3.Scaffold 13 | import androidx.compose.material3.Text 14 | import androidx.compose.runtime.Composable 15 | import androidx.compose.ui.Alignment 16 | import androidx.compose.ui.Modifier 17 | import androidx.compose.ui.res.painterResource 18 | import androidx.compose.ui.res.stringResource 19 | import androidx.compose.ui.text.font.FontWeight 20 | import androidx.compose.ui.text.style.TextAlign 21 | import androidx.compose.ui.unit.dp 22 | import com.tailscale.ipn.R 23 | import com.tailscale.ipn.ui.viewModel.ExitNodePickerNav 24 | 25 | @Composable 26 | fun MullvadInfoView(nav: ExitNodePickerNav) { 27 | Scaffold( 28 | topBar = { 29 | Header(R.string.choose_mullvad_exit_node, onBack = nav.onNavigateBackToExitNodes) 30 | }) { innerPadding -> 31 | LazyColumn( 32 | horizontalAlignment = Alignment.CenterHorizontally, 33 | verticalArrangement = Arrangement.spacedBy(20.dp), 34 | contentPadding = PaddingValues(horizontal = 16.dp, vertical = 48.dp), 35 | modifier = Modifier.padding(innerPadding)) { 36 | item { 37 | Image( 38 | painter = painterResource(id = R.drawable.mullvad_logo), 39 | contentDescription = stringResource(R.string.the_mullvad_vpn_logo)) 40 | } 41 | item { 42 | Text( 43 | stringResource(R.string.mullvad_info_title), 44 | fontFamily = MaterialTheme.typography.titleLarge.fontFamily, 45 | fontSize = MaterialTheme.typography.titleLarge.fontSize, 46 | fontWeight = FontWeight.SemiBold) 47 | } 48 | item { 49 | Text( 50 | stringResource(R.string.mullvad_info_explainer), 51 | color = MaterialTheme.colorScheme.secondary, 52 | textAlign = TextAlign.Center) 53 | } 54 | } 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /android/src/main/java/com/tailscale/ipn/ui/view/NotificationsView.kt: -------------------------------------------------------------------------------- 1 | // Copyright (c) Tailscale Inc & AUTHORS 2 | // SPDX-License-Identifier: BSD-3-Clause 3 | 4 | package com.tailscale.ipn.ui.view 5 | 6 | import androidx.compose.foundation.layout.Column 7 | import androidx.compose.foundation.layout.Spacer 8 | import androidx.compose.foundation.layout.fillMaxWidth 9 | import androidx.compose.foundation.layout.height 10 | import androidx.compose.foundation.layout.padding 11 | import androidx.compose.foundation.lazy.LazyColumn 12 | import androidx.compose.material3.Button 13 | import androidx.compose.material3.ListItem 14 | import androidx.compose.material3.MaterialTheme 15 | import androidx.compose.material3.Scaffold 16 | import androidx.compose.material3.Text 17 | import androidx.compose.runtime.Composable 18 | import androidx.compose.ui.Modifier 19 | import androidx.compose.ui.res.stringResource 20 | import androidx.compose.ui.unit.dp 21 | import com.tailscale.ipn.R 22 | import com.tailscale.ipn.ui.model.Permissions 23 | import com.tailscale.ipn.ui.theme.exitNodeToggleButton 24 | 25 | @Composable 26 | fun NotificationsView(backToPermissionsView: BackNavigation, openApplicationSettings: () -> Unit) { 27 | val permissions = Permissions.withGrantedStatus 28 | 29 | // Find the notification permission 30 | val notificationPermission = 31 | permissions.find { (permission, _) -> 32 | permission.title == R.string.permission_post_notifications 33 | } 34 | val granted = notificationPermission?.second ?: false 35 | val permission = notificationPermission?.first 36 | 37 | Scaffold( 38 | topBar = { 39 | Header(titleRes = R.string.permission_post_notifications, onBack = backToPermissionsView) 40 | }) { innerPadding -> 41 | LazyColumn(modifier = Modifier.padding(innerPadding)) { 42 | item { 43 | if (permission != null) { 44 | ListItem( 45 | headlineContent = { 46 | Text( 47 | stringResource(permission.title), 48 | style = MaterialTheme.typography.titleMedium) 49 | }, 50 | supportingContent = { 51 | Column(modifier = Modifier.fillMaxWidth()) { 52 | Text( 53 | text = stringResource(permission.description), 54 | style = MaterialTheme.typography.bodyMedium) 55 | Spacer(modifier = Modifier.height(12.dp)) 56 | Text( 57 | text = stringResource(R.string.notification_settings_explanation), 58 | style = MaterialTheme.typography.bodyMedium) 59 | } 60 | }) 61 | } 62 | } 63 | 64 | item("spacer") { 65 | Spacer(modifier = Modifier.height(16.dp)) // soft break instead of divider 66 | } 67 | 68 | item { 69 | ListItem( 70 | headlineContent = { 71 | Text( 72 | text = stringResource(R.string.permission_post_notifications), 73 | style = MaterialTheme.typography.titleMedium) 74 | }, 75 | supportingContent = { 76 | Column(modifier = Modifier.fillMaxWidth()) { 77 | Text( 78 | text = 79 | if (granted) stringResource(R.string.on) 80 | else stringResource(R.string.off), 81 | style = MaterialTheme.typography.bodyMedium) 82 | Button( 83 | colors = MaterialTheme.colorScheme.exitNodeToggleButton, 84 | onClick = openApplicationSettings, 85 | modifier = Modifier.fillMaxWidth().padding(top = 12.dp)) { 86 | Text(stringResource(R.string.open_notification_settings)) 87 | } 88 | } 89 | }) 90 | } 91 | } 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /android/src/main/java/com/tailscale/ipn/ui/view/PeerView.kt: -------------------------------------------------------------------------------- 1 | // Copyright (c) Tailscale Inc & AUTHORS 2 | // SPDX-License-Identifier: BSD-3-Clause 3 | 4 | package com.tailscale.ipn.ui.view 5 | 6 | import androidx.compose.foundation.background 7 | import androidx.compose.foundation.clickable 8 | import androidx.compose.foundation.layout.Box 9 | import androidx.compose.foundation.layout.Row 10 | import androidx.compose.foundation.layout.Spacer 11 | import androidx.compose.foundation.layout.size 12 | import androidx.compose.foundation.shape.RoundedCornerShape 13 | import androidx.compose.material3.ListItem 14 | import androidx.compose.material3.MaterialTheme 15 | import androidx.compose.material3.Text 16 | import androidx.compose.runtime.Composable 17 | import androidx.compose.ui.Alignment 18 | import androidx.compose.ui.Modifier 19 | import androidx.compose.ui.graphics.Color 20 | import androidx.compose.ui.unit.dp 21 | import com.tailscale.ipn.ui.model.Ipn 22 | import com.tailscale.ipn.ui.model.Tailcfg 23 | import com.tailscale.ipn.ui.theme.off 24 | import com.tailscale.ipn.ui.theme.on 25 | 26 | @Composable 27 | fun PeerView( 28 | peer: Tailcfg.Node, 29 | selfPeer: String? = null, 30 | stateVal: Ipn.State? = null, 31 | subtitle: () -> String = { peer.primaryIPv4Address ?: peer.primaryIPv6Address ?: "" }, 32 | onClick: (Tailcfg.Node) -> Unit = {}, 33 | trailingContent: @Composable () -> Unit = {} 34 | ) { 35 | val disabled = !(peer.Online ?: false) 36 | val textColor = if (disabled) MaterialTheme.colorScheme.onSurfaceVariant else Color.Unspecified 37 | 38 | ListItem( 39 | modifier = Modifier.clickable { onClick(peer) }, 40 | headlineContent = { 41 | Row(verticalAlignment = Alignment.CenterVertically) { 42 | // By definition, SelfPeer is online since we will not show the peer list 43 | // unless you're connected. 44 | val isSelfAndRunning = (peer.StableID == selfPeer && stateVal == Ipn.State.Running) 45 | val color: Color = 46 | if ((peer.Online == true) || isSelfAndRunning) { 47 | MaterialTheme.colorScheme.on 48 | } else { 49 | MaterialTheme.colorScheme.off 50 | } 51 | Box( 52 | modifier = 53 | Modifier.size(8.dp) 54 | .background(color = color, shape = RoundedCornerShape(percent = 50))) {} 55 | Spacer(modifier = Modifier.size(8.dp)) 56 | Text( 57 | text = peer.displayName, 58 | style = MaterialTheme.typography.titleMedium, 59 | color = textColor) 60 | } 61 | }, 62 | supportingContent = { 63 | Text(text = subtitle(), style = MaterialTheme.typography.bodyMedium, color = textColor) 64 | }, 65 | trailingContent = trailingContent) 66 | } 67 | -------------------------------------------------------------------------------- /android/src/main/java/com/tailscale/ipn/ui/view/PermissionsView.kt: -------------------------------------------------------------------------------- 1 | // Copyright (c) Tailscale Inc & AUTHORS 2 | // SPDX-License-Identifier: BSD-3-Clause 3 | 4 | package com.tailscale.ipn.ui.view 5 | 6 | import androidx.compose.foundation.clickable 7 | import androidx.compose.foundation.layout.padding 8 | import androidx.compose.foundation.layout.size 9 | import androidx.compose.foundation.lazy.LazyColumn 10 | import androidx.compose.material3.Icon 11 | import androidx.compose.material3.ListItem 12 | import androidx.compose.material3.MaterialTheme 13 | import androidx.compose.material3.Scaffold 14 | import androidx.compose.material3.Text 15 | import androidx.compose.runtime.Composable 16 | import androidx.compose.runtime.collectAsState 17 | import androidx.compose.ui.Modifier 18 | import androidx.compose.ui.res.painterResource 19 | import androidx.compose.ui.res.stringResource 20 | import androidx.compose.ui.unit.dp 21 | import androidx.lifecycle.viewmodel.compose.viewModel 22 | import com.tailscale.ipn.R 23 | import com.tailscale.ipn.ui.model.Permissions 24 | import com.tailscale.ipn.ui.util.friendlyDirName 25 | import com.tailscale.ipn.ui.util.itemsWithDividers 26 | import com.tailscale.ipn.ui.viewModel.PermissionsViewModel 27 | 28 | @Composable 29 | fun PermissionsView( 30 | backToSettings: BackNavigation, 31 | navToTaildropDirView: () -> Unit, 32 | navToNotificationsView: () -> Unit, 33 | permissionsViewModel: PermissionsViewModel = viewModel() 34 | ) { 35 | val permissions = Permissions.withGrantedStatus 36 | 37 | Scaffold(topBar = { Header(titleRes = R.string.permissions, onBack = backToSettings) }) { 38 | innerPadding -> 39 | LazyColumn(modifier = Modifier.padding(innerPadding)) { 40 | // Existing Android runtime permissions 41 | itemsWithDividers(permissions) { (permission, granted) -> 42 | ListItem( 43 | modifier = Modifier.clickable { navToNotificationsView() }, 44 | leadingContent = { 45 | Icon( 46 | painterResource(R.drawable.baseline_notifications_none_24), 47 | tint = MaterialTheme.colorScheme.onSurfaceVariant, 48 | modifier = Modifier.size(24.dp), 49 | contentDescription = 50 | stringResource(if (granted) R.string.ok else R.string.warning)) 51 | }, 52 | headlineContent = { 53 | Text(stringResource(permission.title), style = MaterialTheme.typography.titleMedium) 54 | }, 55 | supportingContent = { 56 | if (granted) Text(stringResource(R.string.on)) else Text(stringResource(R.string.off)) 57 | }) 58 | } 59 | 60 | item { 61 | ListItem( 62 | modifier = Modifier.clickable { navToTaildropDirView() }, 63 | leadingContent = { 64 | Icon( 65 | painterResource(R.drawable.baseline_drive_folder_upload_24), 66 | tint = MaterialTheme.colorScheme.onSurfaceVariant, 67 | modifier = Modifier.size(24.dp), 68 | contentDescription = stringResource(R.string.taildrop_dir)) 69 | }, 70 | headlineContent = { 71 | Text( 72 | stringResource(R.string.taildrop_dir_access), 73 | style = MaterialTheme.typography.titleMedium) 74 | }, 75 | supportingContent = { 76 | val displayPath = 77 | permissionsViewModel.currentDir.collectAsState().value?.let { 78 | friendlyDirName(it) 79 | } ?: "No access" 80 | 81 | Text(displayPath) 82 | }) 83 | } 84 | } 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /android/src/main/java/com/tailscale/ipn/ui/view/SubnetRouteRowView.kt: -------------------------------------------------------------------------------- 1 | // Copyright (c) Tailscale Inc & AUTHORS 2 | // SPDX-License-Identifier: BSD-3-Clause 3 | 4 | package com.tailscale.ipn.ui.view 5 | 6 | import androidx.compose.foundation.layout.Row 7 | import androidx.compose.foundation.layout.size 8 | import androidx.compose.material3.Icon 9 | import androidx.compose.material3.IconButton 10 | import androidx.compose.material3.IconButtonDefaults 11 | import androidx.compose.material3.ListItem 12 | import androidx.compose.material3.MaterialTheme 13 | import androidx.compose.material3.Text 14 | import androidx.compose.runtime.Composable 15 | import androidx.compose.ui.Modifier 16 | import androidx.compose.ui.res.painterResource 17 | import androidx.compose.ui.res.stringResource 18 | import androidx.compose.ui.unit.dp 19 | import com.tailscale.ipn.R 20 | 21 | /** 22 | * SubnetRouteRowView is a row in RunSubnetRouterView, representing a subnet route. It provides 23 | * options to edit or delete the route. 24 | * 25 | * @param route The subnet route itself (e.g., "192.168.1.0/24"). 26 | * @param onEdit A callback invoked when the edit icon is clicked. 27 | * @param onDelete A callback invoked when the delete icon is clicked. 28 | */ 29 | @Composable 30 | fun SubnetRouteRowView( 31 | route: String, 32 | onEdit: () -> Unit, 33 | onDelete: () -> Unit, 34 | modifier: Modifier = Modifier 35 | ) { 36 | ListItem( 37 | headlineContent = { Text(text = route, style = MaterialTheme.typography.bodyMedium) }, 38 | trailingContent = { 39 | Row { 40 | IconButton(onClick = onEdit) { 41 | Icon( 42 | painterResource(R.drawable.pencil), 43 | contentDescription = stringResource(R.string.edit_route), 44 | modifier = Modifier.size(24.dp)) 45 | } 46 | IconButton( 47 | onClick = onDelete, 48 | colors = 49 | IconButtonDefaults.iconButtonColors( 50 | contentColor = MaterialTheme.colorScheme.error)) { 51 | Icon( 52 | painterResource(R.drawable.xmark), 53 | contentDescription = stringResource(R.string.delete_route), 54 | modifier = Modifier.size(24.dp)) 55 | } 56 | } 57 | }, 58 | modifier = modifier) 59 | } 60 | -------------------------------------------------------------------------------- /android/src/main/java/com/tailscale/ipn/ui/view/TaildropDirView.kt: -------------------------------------------------------------------------------- 1 | // Copyright (c) Tailscale Inc & AUTHORS 2 | // SPDX-License-Identifier: BSD-3-Clause 3 | 4 | package com.tailscale.ipn.ui.view 5 | 6 | import android.net.Uri 7 | import androidx.activity.result.ActivityResultLauncher 8 | import androidx.compose.foundation.layout.Column 9 | import androidx.compose.foundation.layout.fillMaxWidth 10 | import androidx.compose.foundation.layout.padding 11 | import androidx.compose.foundation.lazy.LazyColumn 12 | import androidx.compose.material3.Button 13 | import androidx.compose.material3.ListItem 14 | import androidx.compose.material3.MaterialTheme 15 | import androidx.compose.material3.Scaffold 16 | import androidx.compose.material3.Text 17 | import androidx.compose.runtime.Composable 18 | import androidx.compose.runtime.collectAsState 19 | import androidx.compose.runtime.getValue 20 | import androidx.compose.ui.Modifier 21 | import androidx.compose.ui.res.stringResource 22 | import androidx.compose.ui.unit.dp 23 | import com.tailscale.ipn.R 24 | import com.tailscale.ipn.ui.theme.exitNodeToggleButton 25 | import com.tailscale.ipn.ui.util.Lists 26 | import com.tailscale.ipn.ui.util.friendlyDirName 27 | import com.tailscale.ipn.ui.viewModel.PermissionsViewModel 28 | import com.tailscale.ipn.util.TSLog 29 | 30 | @Composable 31 | fun TaildropDirView( 32 | backToPermissionsView: BackNavigation, 33 | openDirectoryLauncher: ActivityResultLauncher, 34 | permissionsViewModel: PermissionsViewModel 35 | ) { 36 | Scaffold( 37 | topBar = { 38 | Header(titleRes = R.string.taildrop_dir_access, onBack = backToPermissionsView) 39 | }) { innerPadding -> 40 | LazyColumn(modifier = Modifier.padding(innerPadding)) { 41 | item { 42 | ListItem( 43 | headlineContent = { 44 | Text( 45 | stringResource(R.string.taildrop_dir_access), 46 | style = MaterialTheme.typography.titleMedium) 47 | }, 48 | supportingContent = { 49 | Text( 50 | text = stringResource(R.string.permission_taildrop_dir), 51 | style = MaterialTheme.typography.bodyMedium) 52 | }) 53 | } 54 | 55 | item("divider0") { Lists.SectionDivider() } 56 | 57 | item { 58 | val currentDir by permissionsViewModel.currentDir.collectAsState() 59 | TSLog.d("TaildropDirView", "currentDir in UI: $currentDir") 60 | val displayPath = currentDir?.let { friendlyDirName(it) } ?: "No access" 61 | 62 | ListItem( 63 | headlineContent = { 64 | Text( 65 | text = stringResource(R.string.dir_access), 66 | style = MaterialTheme.typography.titleMedium) 67 | }, 68 | supportingContent = { 69 | Column(modifier = Modifier.fillMaxWidth()) { 70 | Text(text = displayPath, style = MaterialTheme.typography.bodyMedium) 71 | Button( 72 | colors = MaterialTheme.colorScheme.exitNodeToggleButton, 73 | onClick = { openDirectoryLauncher.launch(null) }, 74 | modifier = Modifier.fillMaxWidth().padding(top = 12.dp)) { 75 | Text(stringResource(R.string.pick_dir)) 76 | } 77 | } 78 | }) 79 | } 80 | } 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /android/src/main/java/com/tailscale/ipn/ui/view/TintedSwitch.kt: -------------------------------------------------------------------------------- 1 | // Copyright (c) Tailscale Inc & AUTHORS 2 | // SPDX-License-Identifier: BSD-3-Clause 3 | 4 | package com.tailscale.ipn.ui.view 5 | 6 | import androidx.compose.material3.Switch 7 | import androidx.compose.runtime.Composable 8 | 9 | @Composable 10 | fun TintedSwitch(checked: Boolean, onCheckedChange: ((Boolean) -> Unit)?, enabled: Boolean = true) { 11 | Switch(checked = checked, onCheckedChange = onCheckedChange, enabled = enabled) 12 | } 13 | -------------------------------------------------------------------------------- /android/src/main/java/com/tailscale/ipn/ui/view/UserView.kt: -------------------------------------------------------------------------------- 1 | // Copyright (c) Tailscale Inc & AUTHORS 2 | // SPDX-License-Identifier: BSD-3-Clause 3 | 4 | package com.tailscale.ipn.ui.view 5 | 6 | import androidx.compose.foundation.clickable 7 | import androidx.compose.foundation.layout.Box 8 | import androidx.compose.foundation.layout.Column 9 | import androidx.compose.foundation.layout.offset 10 | import androidx.compose.material.icons.Icons 11 | import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight 12 | import androidx.compose.material3.Icon 13 | import androidx.compose.material3.ListItem 14 | import androidx.compose.material3.ListItemColors 15 | import androidx.compose.material3.ListItemDefaults 16 | import androidx.compose.material3.MaterialTheme 17 | import androidx.compose.material3.Text 18 | import androidx.compose.runtime.Composable 19 | import androidx.compose.ui.Modifier 20 | import androidx.compose.ui.res.stringResource 21 | import androidx.compose.ui.text.style.TextOverflow 22 | import androidx.compose.ui.unit.dp 23 | import com.tailscale.ipn.R 24 | import com.tailscale.ipn.ui.model.IpnLocal 25 | import com.tailscale.ipn.ui.theme.minTextSize 26 | import com.tailscale.ipn.ui.theme.short 27 | import com.tailscale.ipn.ui.util.AutoResizingText 28 | 29 | // Used to decorate UserViews. 30 | // NONE indicates no decoration 31 | // CURRENT indicates the user is the current user and will be "checked" 32 | // SWITCHING indicates the user is being switched to and will be "loading" 33 | // NAV will show a chevron 34 | enum class UserActionState { 35 | CURRENT, 36 | SWITCHING, 37 | NAV, 38 | NONE 39 | } 40 | 41 | @Composable 42 | fun UserView( 43 | profile: IpnLocal.LoginProfile?, 44 | onClick: (() -> Unit)? = null, 45 | colors: ListItemColors = ListItemDefaults.colors(), 46 | actionState: UserActionState = UserActionState.NONE, 47 | ) { 48 | Box { 49 | var modifier: Modifier = Modifier 50 | onClick?.let { modifier = modifier.clickable { it() } } 51 | profile?.let { 52 | ListItem( 53 | modifier = modifier, 54 | colors = colors, 55 | leadingContent = { Avatar(profile = profile, size = 36) }, 56 | headlineContent = { 57 | AutoResizingText( 58 | text = profile.UserProfile.LoginName, 59 | style = MaterialTheme.typography.titleMedium.short, 60 | minFontSize = MaterialTheme.typography.minTextSize, 61 | overflow = TextOverflow.Ellipsis) 62 | }, 63 | supportingContent = { 64 | Column { 65 | AutoResizingText( 66 | text = profile.NetworkProfile?.DomainName ?: "", 67 | style = MaterialTheme.typography.bodyMedium.short, 68 | minFontSize = MaterialTheme.typography.minTextSize, 69 | overflow = TextOverflow.Ellipsis) 70 | 71 | profile.customControlServerHostname()?.let { 72 | AutoResizingText( 73 | text = it, 74 | style = MaterialTheme.typography.bodyMedium.short, 75 | minFontSize = MaterialTheme.typography.minTextSize, 76 | overflow = TextOverflow.Ellipsis) 77 | } 78 | } 79 | }, 80 | trailingContent = { 81 | when (actionState) { 82 | UserActionState.CURRENT -> CheckedIndicator() 83 | UserActionState.SWITCHING -> SimpleActivityIndicator(size = 26) 84 | UserActionState.NAV -> 85 | Icon( 86 | Icons.AutoMirrored.Filled.KeyboardArrowRight, null, Modifier.offset(x = 6.dp)) 87 | UserActionState.NONE -> Unit 88 | } 89 | }) 90 | } 91 | ?: run { 92 | ListItem( 93 | modifier = modifier, 94 | colors = colors, 95 | headlineContent = { 96 | Text( 97 | text = stringResource(id = R.string.accounts), 98 | style = MaterialTheme.typography.titleMedium) 99 | }) 100 | } 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /android/src/main/java/com/tailscale/ipn/ui/viewModel/BugReportViewModel.kt: -------------------------------------------------------------------------------- 1 | // Copyright (c) Tailscale Inc & AUTHORS 2 | // SPDX-License-Identifier: BSD-3-Clause 3 | 4 | package com.tailscale.ipn.ui.viewModel 5 | 6 | import androidx.lifecycle.ViewModel 7 | import androidx.lifecycle.viewModelScope 8 | import com.tailscale.ipn.ui.localapi.Client 9 | import com.tailscale.ipn.ui.util.set 10 | import kotlinx.coroutines.flow.MutableStateFlow 11 | import kotlinx.coroutines.flow.StateFlow 12 | 13 | class BugReportViewModel : ViewModel() { 14 | val bugReportID: StateFlow = MutableStateFlow("") 15 | 16 | init { 17 | Client(viewModelScope).bugReportId { result -> 18 | result 19 | .onSuccess { bugReportID.set(it.trim()) } 20 | .onFailure { bugReportID.set("(Error fetching ID)") } 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /android/src/main/java/com/tailscale/ipn/ui/viewModel/CustomLoginViewModel.kt: -------------------------------------------------------------------------------- 1 | // Copyright (c) Tailscale Inc & AUTHORS 2 | // SPDX-License-Identifier: BSD-3-Clause 3 | 4 | package com.tailscale.ipn.ui.viewModel 5 | 6 | import com.tailscale.ipn.ui.util.set 7 | import com.tailscale.ipn.ui.view.ErrorDialogType 8 | import kotlinx.coroutines.flow.MutableStateFlow 9 | import kotlinx.coroutines.flow.StateFlow 10 | 11 | const val AUTH_KEY_LENGTH = 16 12 | 13 | open class CustomLoginViewModel : IpnViewModel() { 14 | val errorDialog: StateFlow = MutableStateFlow(null) 15 | } 16 | 17 | class LoginWithAuthKeyViewModel : CustomLoginViewModel() { 18 | // Sets the auth key and invokes the login flow 19 | fun setAuthKey(authKey: String, onSuccess: () -> Unit) { 20 | // The most basic of checks for auth key syntax 21 | if (authKey.isEmpty()) { 22 | errorDialog.set(ErrorDialogType.INVALID_AUTH_KEY) 23 | return 24 | } 25 | loginWithAuthKey(authKey) { 26 | it.onFailure { errorDialog.set(ErrorDialogType.ADD_PROFILE_FAILED) } 27 | it.onSuccess { onSuccess() } 28 | } 29 | } 30 | } 31 | 32 | class LoginWithCustomControlURLViewModel : CustomLoginViewModel() { 33 | // Sets the custom control URL and invokes the login flow 34 | fun setControlURL(urlStr: String, onSuccess: () -> Unit) { 35 | // Some basic checks that the entered URL is "reasonable". The underlying 36 | // localAPIClient will use the default server if we give it a broken URL, 37 | // but we can make sure we can construct a URL from the input string and 38 | // ensure it has an http/https scheme 39 | when (urlStr.startsWith("http", ignoreCase = true) && 40 | urlStr.contains("://") && 41 | urlStr.length > 7) { 42 | false -> { 43 | errorDialog.set(ErrorDialogType.INVALID_CUSTOM_URL) 44 | return 45 | } 46 | true -> { 47 | loginWithCustomControlURL(urlStr) { 48 | it.onFailure { errorDialog.set(ErrorDialogType.ADD_PROFILE_FAILED) } 49 | it.onSuccess { onSuccess() } 50 | } 51 | } 52 | } 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /android/src/main/java/com/tailscale/ipn/ui/viewModel/DNSSettingsViewModel.kt: -------------------------------------------------------------------------------- 1 | // Copyright (c) Tailscale Inc & AUTHORS 2 | // SPDX-License-Identifier: BSD-3-Clause 3 | 4 | package com.tailscale.ipn.ui.viewModel 5 | 6 | import androidx.annotation.StringRes 7 | import androidx.compose.material3.MaterialTheme 8 | import androidx.compose.runtime.Composable 9 | import androidx.compose.ui.graphics.Color 10 | import androidx.lifecycle.ViewModel 11 | import androidx.lifecycle.ViewModelProvider 12 | import androidx.lifecycle.viewModelScope 13 | import com.tailscale.ipn.R 14 | import com.tailscale.ipn.ui.localapi.Client 15 | import com.tailscale.ipn.ui.model.Ipn 16 | import com.tailscale.ipn.ui.model.Tailcfg 17 | import com.tailscale.ipn.ui.notifier.Notifier 18 | import com.tailscale.ipn.ui.theme.off 19 | import com.tailscale.ipn.ui.theme.success 20 | import com.tailscale.ipn.ui.util.set 21 | import com.tailscale.ipn.util.TSLog 22 | import kotlinx.coroutines.flow.MutableStateFlow 23 | import kotlinx.coroutines.flow.StateFlow 24 | import kotlinx.coroutines.flow.combine 25 | import kotlinx.coroutines.flow.stateIn 26 | import kotlinx.coroutines.launch 27 | 28 | class DNSSettingsViewModelFactory : ViewModelProvider.Factory { 29 | @Suppress("UNCHECKED_CAST") 30 | override fun create(modelClass: Class): T { 31 | return DNSSettingsViewModel() as T 32 | } 33 | } 34 | 35 | class DNSSettingsViewModel : IpnViewModel() { 36 | val enablementState: StateFlow = 37 | MutableStateFlow(DNSEnablementState.NOT_RUNNING) 38 | val dnsConfig: StateFlow = MutableStateFlow(null) 39 | 40 | init { 41 | viewModelScope.launch { 42 | Notifier.netmap 43 | .combine(Notifier.prefs) { netmap, prefs -> Pair(netmap, prefs) } 44 | .stateIn(viewModelScope) 45 | .collect { (netmap, prefs) -> 46 | TSLog.d("DNSSettingsViewModel", "prefs: CorpDNS=" + prefs?.CorpDNS.toString()) 47 | prefs?.let { 48 | if (it.CorpDNS) { 49 | enablementState.set(DNSEnablementState.ENABLED) 50 | } else { 51 | enablementState.set(DNSEnablementState.DISABLED) 52 | } 53 | } ?: run { enablementState.set(DNSEnablementState.NOT_RUNNING) } 54 | netmap?.let { dnsConfig.set(netmap.DNS) } 55 | } 56 | } 57 | } 58 | 59 | fun toggleCorpDNS(callback: (Result) -> Unit) { 60 | val prefs = 61 | Notifier.prefs.value 62 | ?: run { 63 | callback(Result.failure(Exception("no prefs"))) 64 | return@toggleCorpDNS 65 | } 66 | 67 | val prefsOut = Ipn.MaskedPrefs() 68 | prefsOut.CorpDNS = !prefs.CorpDNS 69 | Client(viewModelScope).editPrefs(prefsOut, callback) 70 | } 71 | } 72 | 73 | enum class DNSEnablementState( 74 | @StringRes val title: Int, 75 | @StringRes val caption: Int, 76 | val symbolDrawable: Int, 77 | val tint: @Composable () -> Color 78 | ) { 79 | NOT_RUNNING( 80 | R.string.not_running, 81 | R.string.tailscale_is_not_running_this_device_is_using_the_system_dns_resolver, 82 | R.drawable.xmark_circle, 83 | { MaterialTheme.colorScheme.off }), 84 | ENABLED( 85 | R.string.using_tailscale_dns, 86 | R.string.this_device_is_using_tailscale_to_resolve_dns_names, 87 | R.drawable.check_circle, 88 | { MaterialTheme.colorScheme.success }), 89 | DISABLED( 90 | R.string.not_using_tailscale_dns, 91 | R.string.this_device_is_using_the_system_dns_resolver, 92 | R.drawable.xmark_circle, 93 | { MaterialTheme.colorScheme.error }) 94 | } 95 | -------------------------------------------------------------------------------- /android/src/main/java/com/tailscale/ipn/ui/viewModel/HealthViewModel.kt: -------------------------------------------------------------------------------- 1 | // Copyright (c) Tailscale Inc & AUTHORS 2 | // SPDX-License-Identifier: BSD-3-Clause 3 | 4 | package com.tailscale.ipn.ui.viewModel 5 | 6 | import androidx.lifecycle.ViewModel 7 | import androidx.lifecycle.viewModelScope 8 | import com.tailscale.ipn.App 9 | import com.tailscale.ipn.ui.model.Health 10 | import com.tailscale.ipn.ui.util.set 11 | import kotlinx.coroutines.flow.MutableStateFlow 12 | import kotlinx.coroutines.flow.StateFlow 13 | import kotlinx.coroutines.launch 14 | 15 | class HealthViewModel : ViewModel() { 16 | val warnings: StateFlow> = MutableStateFlow(listOf()) 17 | 18 | init { 19 | viewModelScope.launch { 20 | App.get().healthNotifier?.currentWarnings?.collect { set -> warnings.set(set.sorted()) } 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /android/src/main/java/com/tailscale/ipn/ui/viewModel/LoginQRViewModel.kt: -------------------------------------------------------------------------------- 1 | // Copyright (c) Tailscale Inc & AUTHORS 2 | // SPDX-License-Identifier: BSD-3-Clause 3 | 4 | package com.tailscale.ipn.ui.viewModel 5 | 6 | import android.graphics.Bitmap 7 | import android.graphics.Color 8 | import androidx.compose.ui.graphics.ImageBitmap 9 | import androidx.compose.ui.graphics.asImageBitmap 10 | import androidx.lifecycle.viewModelScope 11 | import com.google.zxing.BarcodeFormat 12 | import com.google.zxing.EncodeHintType 13 | import com.google.zxing.WriterException 14 | import com.google.zxing.qrcode.QRCodeWriter 15 | import com.tailscale.ipn.ui.notifier.Notifier 16 | import com.tailscale.ipn.ui.util.set 17 | import kotlinx.coroutines.flow.MutableStateFlow 18 | import kotlinx.coroutines.flow.StateFlow 19 | import kotlinx.coroutines.launch 20 | 21 | class LoginQRViewModel : IpnViewModel() { 22 | 23 | val numCode: StateFlow = MutableStateFlow(null) 24 | val qrCode: StateFlow = MutableStateFlow(null) 25 | // Remove this once changes to admin console allowing input code to be entered are made. 26 | 27 | init { 28 | viewModelScope.launch { 29 | Notifier.browseToURL.collect { url -> 30 | url?.let { 31 | qrCode.set(generateQRCode(url, 200, 0)) 32 | 33 | // Extract the string after "https://login.tailscale.com/a/" 34 | val prefix = "https://login.tailscale.com/a/" 35 | val code = 36 | if (it.startsWith(prefix)) { 37 | it.removePrefix(prefix) 38 | } else { 39 | null 40 | } 41 | numCode.set(code) 42 | } 43 | ?: run { 44 | qrCode.set(null) 45 | numCode.set(null) 46 | } 47 | } 48 | } 49 | } 50 | 51 | fun generateQRCode(content: String, size: Int, padding: Int): ImageBitmap? { 52 | val qrCodeWriter = QRCodeWriter() 53 | 54 | val encodeHints = mapOf(EncodeHintType.MARGIN to padding) 55 | 56 | val bitmapMatrix = 57 | try { 58 | qrCodeWriter.encode(content, BarcodeFormat.QR_CODE, size, size, encodeHints) 59 | } catch (ex: WriterException) { 60 | return null 61 | } 62 | 63 | val qrCode = 64 | Bitmap.createBitmap( 65 | size, 66 | size, 67 | Bitmap.Config.ARGB_8888, 68 | ) 69 | 70 | for (x in 0 until size) { 71 | for (y in 0 until size) { 72 | val shouldColorPixel = bitmapMatrix?.get(x, y) ?: false 73 | val pixelColor = if (shouldColorPixel) Color.BLACK else Color.WHITE 74 | qrCode.setPixel(x, y, pixelColor) 75 | } 76 | } 77 | 78 | return qrCode.asImageBitmap() 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /android/src/main/java/com/tailscale/ipn/ui/viewModel/PeerDetailsViewModel.kt: -------------------------------------------------------------------------------- 1 | // Copyright (c) Tailscale Inc & AUTHORS 2 | // SPDX-License-Identifier: BSD-3-Clause 3 | 4 | package com.tailscale.ipn.ui.viewModel 5 | 6 | import androidx.lifecycle.ViewModel 7 | import androidx.lifecycle.ViewModelProvider 8 | import androidx.lifecycle.viewModelScope 9 | import com.tailscale.ipn.ui.model.StableNodeID 10 | import com.tailscale.ipn.ui.model.Tailcfg 11 | import com.tailscale.ipn.ui.notifier.Notifier 12 | import com.tailscale.ipn.ui.util.ComposableStringFormatter 13 | import com.tailscale.ipn.ui.util.set 14 | import java.io.File 15 | import kotlinx.coroutines.flow.MutableStateFlow 16 | import kotlinx.coroutines.flow.StateFlow 17 | import kotlinx.coroutines.launch 18 | 19 | data class PeerSettingInfo(val titleRes: Int, val value: ComposableStringFormatter) 20 | 21 | class PeerDetailsViewModelFactory( 22 | private val nodeId: StableNodeID, 23 | private val filesDir: File, 24 | private val pingViewModel: PingViewModel 25 | ) : ViewModelProvider.Factory { 26 | @Suppress("UNCHECKED_CAST") 27 | override fun create(modelClass: Class): T { 28 | return PeerDetailsViewModel(nodeId, filesDir, pingViewModel) as T 29 | } 30 | } 31 | 32 | class PeerDetailsViewModel( 33 | val nodeId: StableNodeID, 34 | val filesDir: File, 35 | val pingViewModel: PingViewModel 36 | ) : IpnViewModel() { 37 | val node: StateFlow = MutableStateFlow(null) 38 | val isPinging: StateFlow = MutableStateFlow(false) 39 | 40 | init { 41 | viewModelScope.launch { 42 | Notifier.netmap.collect { nm -> 43 | netmap.set(nm) 44 | nm?.getPeer(nodeId)?.let { peer -> node.set(peer) } 45 | } 46 | } 47 | } 48 | 49 | fun startPing() { 50 | isPinging.set(true) 51 | node.value?.let { this.pingViewModel.startPing(it) } 52 | } 53 | 54 | fun onPingDismissal() { 55 | isPinging.set(false) 56 | this.pingViewModel.handleDismissal() 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /android/src/main/java/com/tailscale/ipn/ui/viewModel/PermissionsViewModel.kt: -------------------------------------------------------------------------------- 1 | // Copyright (c) Tailscale Inc & AUTHORS 2 | // SPDX-License-Identifier: BSD-3-Clause 3 | 4 | package com.tailscale.ipn.ui.viewModel 5 | 6 | import androidx.lifecycle.ViewModel 7 | import com.tailscale.ipn.TaildropDirectoryStore 8 | import com.tailscale.ipn.util.TSLog 9 | import kotlinx.coroutines.flow.MutableStateFlow 10 | import kotlinx.coroutines.flow.StateFlow 11 | 12 | class PermissionsViewModel : ViewModel() { 13 | private val _currentDir = 14 | MutableStateFlow(TaildropDirectoryStore.loadSavedDir()?.toString()) 15 | val currentDir: StateFlow = _currentDir 16 | 17 | fun refreshCurrentDir() { 18 | val newUri = TaildropDirectoryStore.loadSavedDir()?.toString() 19 | TSLog.d("PermissionsViewModel", "refreshCurrentDir: $newUri") 20 | _currentDir.value = newUri 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /android/src/main/java/com/tailscale/ipn/ui/viewModel/SettingsViewModel.kt: -------------------------------------------------------------------------------- 1 | // Copyright (c) Tailscale Inc & AUTHORS 2 | // SPDX-License-Identifier: BSD-3-Clause 3 | 4 | package com.tailscale.ipn.ui.viewModel 5 | 6 | import androidx.lifecycle.viewModelScope 7 | import com.tailscale.ipn.ui.localapi.Client 8 | import com.tailscale.ipn.ui.notifier.Notifier 9 | import com.tailscale.ipn.ui.util.LoadingIndicator 10 | import com.tailscale.ipn.ui.util.set 11 | import kotlinx.coroutines.flow.MutableStateFlow 12 | import kotlinx.coroutines.flow.StateFlow 13 | import kotlinx.coroutines.launch 14 | 15 | data class SettingsNav( 16 | val onNavigateToBugReport: () -> Unit, 17 | val onNavigateToAbout: () -> Unit, 18 | val onNavigateToDNSSettings: () -> Unit, 19 | val onNavigateToSplitTunneling: () -> Unit, 20 | val onNavigateToTailnetLock: () -> Unit, 21 | val onNavigateToSubnetRouting: () -> Unit, 22 | val onNavigateToMDMSettings: () -> Unit, 23 | val onNavigateToManagedBy: () -> Unit, 24 | val onNavigateToUserSwitcher: () -> Unit, 25 | val onNavigateToPermissions: () -> Unit, 26 | val onNavigateBackHome: () -> Unit, 27 | val onBackToSettings: () -> Unit, 28 | ) 29 | 30 | class SettingsViewModel : IpnViewModel() { 31 | // Display name for the logged in user 32 | val isAdmin: StateFlow = MutableStateFlow(false) 33 | // True if tailnet lock is enabled. nil if not yet known. 34 | val tailNetLockEnabled: StateFlow = MutableStateFlow(null) 35 | // True if tailscaleDNS is enabled. nil if not yet known. 36 | val corpDNSEnabled: StateFlow = MutableStateFlow(null) 37 | 38 | init { 39 | viewModelScope.launch { 40 | Notifier.netmap.collect { netmap -> isAdmin.set(netmap?.SelfNode?.isAdmin ?: false) } 41 | } 42 | 43 | Client(viewModelScope).tailnetLockStatus { result -> 44 | result.onSuccess { status -> tailNetLockEnabled.set(status.Enabled) } 45 | 46 | LoadingIndicator.stop() 47 | } 48 | 49 | viewModelScope.launch { 50 | Notifier.prefs.collect { 51 | it?.let { corpDNSEnabled.set(it.CorpDNS) } ?: run { corpDNSEnabled.set(null) } 52 | } 53 | } 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /android/src/main/java/com/tailscale/ipn/ui/viewModel/SplitTunnelAppPickerViewModel.kt: -------------------------------------------------------------------------------- 1 | // Copyright (c) Tailscale Inc & AUTHORS 2 | // SPDX-License-Identifier: BSD-3-Clause 3 | 4 | package com.tailscale.ipn.ui.viewModel 5 | 6 | import androidx.lifecycle.ViewModel 7 | import androidx.lifecycle.viewModelScope 8 | import com.tailscale.ipn.App 9 | import com.tailscale.ipn.mdm.MDMSettings 10 | import com.tailscale.ipn.mdm.SettingState 11 | import com.tailscale.ipn.ui.util.InstalledApp 12 | import com.tailscale.ipn.ui.util.InstalledAppsManager 13 | import com.tailscale.ipn.ui.util.set 14 | import kotlinx.coroutines.Job 15 | import kotlinx.coroutines.delay 16 | import kotlinx.coroutines.flow.MutableStateFlow 17 | import kotlinx.coroutines.flow.StateFlow 18 | import kotlinx.coroutines.launch 19 | 20 | class SplitTunnelAppPickerViewModel : ViewModel() { 21 | val installedAppsManager = InstalledAppsManager(packageManager = App.get().packageManager) 22 | val excludedPackageNames: StateFlow> = MutableStateFlow(listOf()) 23 | val installedApps: StateFlow> = MutableStateFlow(listOf()) 24 | val mdmExcludedPackages: StateFlow> = MDMSettings.excludedPackages.flow 25 | val mdmIncludedPackages: StateFlow> = MDMSettings.includedPackages.flow 26 | 27 | private var saveJob: Job? = null 28 | 29 | init { 30 | installedApps.set(installedAppsManager.fetchInstalledApps()) 31 | excludedPackageNames.set( 32 | App.get() 33 | .disallowedPackageNames() 34 | .intersect(installedApps.value.map { it.packageName }.toSet()) 35 | .toList()) 36 | } 37 | 38 | fun exclude(packageName: String) { 39 | if (excludedPackageNames.value.contains(packageName)) return 40 | excludedPackageNames.set(excludedPackageNames.value + packageName) 41 | debounceSave() 42 | } 43 | 44 | fun unexclude(packageName: String) { 45 | excludedPackageNames.set(excludedPackageNames.value - packageName) 46 | debounceSave() 47 | } 48 | 49 | private fun debounceSave() { 50 | saveJob?.cancel() 51 | saveJob = 52 | viewModelScope.launch { 53 | delay(500) // Wait to batch multiple rapid updates 54 | App.get().updateUserDisallowedPackageNames(excludedPackageNames.value) 55 | } 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /android/src/main/java/com/tailscale/ipn/ui/viewModel/TailnetLockSetupViewModel.kt: -------------------------------------------------------------------------------- 1 | // Copyright (c) Tailscale Inc & AUTHORS 2 | // SPDX-License-Identifier: BSD-3-Clause 3 | 4 | package com.tailscale.ipn.ui.viewModel 5 | 6 | import androidx.annotation.DrawableRes 7 | import androidx.annotation.StringRes 8 | import androidx.lifecycle.ViewModel 9 | import androidx.lifecycle.ViewModelProvider 10 | import androidx.lifecycle.viewModelScope 11 | import com.tailscale.ipn.R 12 | import com.tailscale.ipn.ui.localapi.Client 13 | import com.tailscale.ipn.ui.model.IpnState 14 | import com.tailscale.ipn.ui.util.LoadingIndicator 15 | import com.tailscale.ipn.ui.util.set 16 | import kotlinx.coroutines.flow.MutableStateFlow 17 | import kotlinx.coroutines.flow.StateFlow 18 | 19 | class TailnetLockSetupViewModelFactory : ViewModelProvider.Factory { 20 | @Suppress("UNCHECKED_CAST") 21 | override fun create(modelClass: Class): T { 22 | return TailnetLockSetupViewModel() as T 23 | } 24 | } 25 | 26 | data class StatusItem(@StringRes val title: Int, @DrawableRes val icon: Int) 27 | 28 | class TailnetLockSetupViewModel : IpnViewModel() { 29 | 30 | val statusItems: StateFlow> = MutableStateFlow(emptyList()) 31 | val nodeKey: StateFlow = MutableStateFlow("unknown") 32 | val tailnetLockKey: StateFlow = MutableStateFlow("unknown") 33 | 34 | init { 35 | LoadingIndicator.start() 36 | Client(viewModelScope).tailnetLockStatus { result -> 37 | statusItems.set(generateStatusItems(result.getOrNull())) 38 | nodeKey.set(result.getOrNull()?.NodeKey ?: "unknown") 39 | tailnetLockKey.set(result.getOrNull()?.PublicKey ?: "unknown") 40 | LoadingIndicator.stop() 41 | } 42 | } 43 | 44 | fun generateStatusItems(networkLockStatus: IpnState.NetworkLockStatus?): List { 45 | networkLockStatus?.let { status -> 46 | val items = emptyList().toMutableList() 47 | if (status.Enabled == true) { 48 | items.add(StatusItem(title = R.string.tailnet_lock_enabled, icon = R.drawable.check_circle)) 49 | } else { 50 | items.add( 51 | StatusItem(title = R.string.tailnet_lock_disabled, icon = R.drawable.xmark_circle)) 52 | } 53 | 54 | if (status.NodeKeySigned == true) { 55 | items.add( 56 | StatusItem(title = R.string.this_node_has_been_signed, icon = R.drawable.check_circle)) 57 | } else { 58 | items.add( 59 | StatusItem( 60 | title = R.string.this_node_has_not_been_signed, icon = R.drawable.xmark_circle)) 61 | } 62 | 63 | if (status.IsPublicKeyTrusted()) { 64 | items.add(StatusItem(title = R.string.this_node_is_trusted, icon = R.drawable.check_circle)) 65 | } else { 66 | items.add( 67 | StatusItem(title = R.string.this_node_is_not_trusted, icon = R.drawable.xmark_circle)) 68 | } 69 | 70 | return items 71 | } 72 | ?: run { 73 | return emptyList() 74 | } 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /android/src/main/java/com/tailscale/ipn/ui/viewModel/UserSwitcherViewModel.kt: -------------------------------------------------------------------------------- 1 | // Copyright (c) Tailscale Inc & AUTHORS 2 | // SPDX-License-Identifier: BSD-3-Clause 3 | 4 | package com.tailscale.ipn.ui.viewModel 5 | 6 | import com.tailscale.ipn.ui.view.ErrorDialogType 7 | import kotlinx.coroutines.flow.MutableStateFlow 8 | import kotlinx.coroutines.flow.StateFlow 9 | 10 | class UserSwitcherViewModel : IpnViewModel() { 11 | 12 | // Set to a non-null value to show the appropriate error dialog 13 | val errorDialog: StateFlow = MutableStateFlow(null) 14 | 15 | // True if we should render the kebab menu 16 | val showHeaderMenu: StateFlow = MutableStateFlow(false) 17 | } 18 | -------------------------------------------------------------------------------- /android/src/main/java/com/tailscale/ipn/ui/viewModel/VpnViewModel.kt: -------------------------------------------------------------------------------- 1 | // Copyright (c) Tailscale Inc & AUTHORS 2 | // SPDX-License-Identifier: BSD-3-Clause 3 | 4 | package com.tailscale.ipn.ui.viewModel 5 | 6 | import android.app.Application 7 | import android.net.VpnService 8 | import android.util.Log 9 | import androidx.lifecycle.AndroidViewModel 10 | import androidx.lifecycle.ViewModel 11 | import androidx.lifecycle.ViewModelProvider 12 | import kotlinx.coroutines.flow.MutableStateFlow 13 | import kotlinx.coroutines.flow.StateFlow 14 | 15 | class VpnViewModelFactory(private val application: Application) : ViewModelProvider.Factory { 16 | @Suppress("UNCHECKED_CAST") 17 | override fun create(modelClass: Class): T { 18 | if (modelClass.isAssignableFrom(VpnViewModel::class.java)) { 19 | return VpnViewModel(application) as T 20 | } 21 | throw IllegalArgumentException("Unknown ViewModel class") 22 | } 23 | } 24 | 25 | // Application context aware view model that tracks whether the VPN has been prepared. This must be 26 | // application scoped because Tailscale might be toggled on and off outside of the activity 27 | // lifecycle. 28 | class VpnViewModel(application: Application) : AndroidViewModel(application) { 29 | // Whether the VPN is prepared. This is set to true if the VPN application is already prepared, or 30 | // if the user has previously consented to the VPN application. This is used to determine whether 31 | // a VPN permission launcher needs to be shown. 32 | val _vpnPrepared = MutableStateFlow(false) 33 | val vpnPrepared: StateFlow = _vpnPrepared 34 | // Whether a VPN interface has been established. This is set by net.updateTUN upon 35 | // VpnServiceBuilder.establish, and consumed by UI to reflect VPN state. 36 | val _vpnActive = MutableStateFlow(false) 37 | val vpnActive: StateFlow = _vpnActive 38 | val TAG = "VpnViewModel" 39 | 40 | init { 41 | prepareVpn() 42 | } 43 | 44 | private fun prepareVpn() { 45 | // Check if the user has granted permission yet. 46 | if (!vpnPrepared.value) { 47 | val vpnIntent = VpnService.prepare(getApplication()) 48 | if (vpnIntent != null) { 49 | setVpnPrepared(false) 50 | Log.d(TAG, "VpnService.prepare returned non-null intent") 51 | } else { 52 | setVpnPrepared(true) 53 | Log.d(TAG, "VpnService.prepare returned null intent, VPN is already prepared") 54 | } 55 | } 56 | } 57 | 58 | fun setVpnActive(isActive: Boolean) { 59 | _vpnActive.value = isActive 60 | } 61 | 62 | fun setVpnPrepared(isPrepared: Boolean) { 63 | _vpnPrepared.value = isPrepared 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /android/src/main/java/com/tailscale/ipn/util/FeatureFlags.kt: -------------------------------------------------------------------------------- 1 | // Copyright (c) Tailscale Inc & AUTHORS 2 | // SPDX-License-Identifier: BSD-3-Clause 3 | package com.tailscale.ipn.util 4 | 5 | object FeatureFlags { 6 | 7 | // Map to hold the feature flags 8 | private val flags: MutableMap = mutableMapOf() 9 | 10 | fun initialize(defaults: Map) { 11 | flags.clear() 12 | flags.putAll(defaults) 13 | } 14 | 15 | fun enable(feature: String) { 16 | flags[feature] = true 17 | } 18 | 19 | fun disable(feature: String) { 20 | flags[feature] = false 21 | } 22 | 23 | fun isEnabled(feature: String): Boolean { 24 | return flags[feature] ?: false 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /android/src/main/java/com/tailscale/ipn/util/TSLog.kt: -------------------------------------------------------------------------------- 1 | // Copyright (c) Tailscale Inc & AUTHORS 2 | // SPDX-License-Identifier: BSD-3-Clause 3 | package com.tailscale.ipn.util 4 | 5 | import android.content.Context 6 | import android.util.Log 7 | import libtailscale.Libtailscale 8 | 9 | object TSLog { 10 | private lateinit var appContext: Context 11 | var libtailscaleWrapper = LibtailscaleWrapper() 12 | 13 | fun init(context: Context) { 14 | appContext = context.applicationContext 15 | } 16 | 17 | fun d(tag: String?, message: String) { 18 | Log.d(tag, message) 19 | libtailscaleWrapper.sendLog(tag, message) 20 | } 21 | 22 | fun w(tag: String, message: String) { 23 | Log.w(tag, message) 24 | libtailscaleWrapper.sendLog(tag, message) 25 | } 26 | 27 | fun v(tag: String?, message: String) { 28 | if (isUnstableRelease()) { 29 | Log.v(tag, message) 30 | libtailscaleWrapper.sendLog(tag, message) 31 | } 32 | } 33 | 34 | // Overloaded function without Throwable because Java does not support default parameters 35 | @JvmStatic 36 | fun e(tag: String?, message: String) { 37 | Log.e(tag, message) 38 | libtailscaleWrapper.sendLog(tag, message) 39 | } 40 | 41 | fun e(tag: String?, message: String, throwable: Throwable? = null) { 42 | if (throwable == null) { 43 | Log.e(tag, message) 44 | libtailscaleWrapper.sendLog(tag, message) 45 | } else { 46 | Log.e(tag, message, throwable) 47 | libtailscaleWrapper.sendLog(tag, "$message ${throwable?.localizedMessage}") 48 | } 49 | } 50 | 51 | private fun isUnstableRelease(): Boolean { 52 | val versionName = 53 | appContext.packageManager.getPackageInfo(appContext.packageName, 0).versionName 54 | 55 | // Extract the middle number and check if it's odd 56 | val middleNumber = versionName.split(".").getOrNull(1)?.toIntOrNull() 57 | return middleNumber?.let { it % 2 == 1 } ?: false 58 | } 59 | 60 | class LibtailscaleWrapper { 61 | public fun sendLog(tag: String?, message: String) { 62 | val logTag = tag ?: "" 63 | Libtailscale.sendLog((logTag + ": " + message).toByteArray(Charsets.UTF_8)) 64 | } 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /android/src/main/res/drawable-xhdpi/tv_banner.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tailscale/tailscale-android/5f59a367e3a5573b0ce12ec42cd07fa50a98a785/android/src/main/res/drawable-xhdpi/tv_banner.png -------------------------------------------------------------------------------- /android/src/main/res/drawable/android.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /android/src/main/res/drawable/baseline_drive_folder_upload_24.xml: -------------------------------------------------------------------------------- 1 | 7 | 8 | 9 | 12 | 13 | 14 | 16 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /android/src/main/res/drawable/baseline_folder_open_24.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /android/src/main/res/drawable/baseline_notifications_none_24.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /android/src/main/res/drawable/check_circle.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /android/src/main/res/drawable/clipboard.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /android/src/main/res/drawable/computer.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /android/src/main/res/drawable/globe.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /android/src/main/res/drawable/ic_launcher_foreground.xml: -------------------------------------------------------------------------------- 1 | 6 | 10 | 14 | 18 | 21 | 24 | 27 | 31 | 34 | 38 | 39 | -------------------------------------------------------------------------------- /android/src/main/res/drawable/ic_notification.xml: -------------------------------------------------------------------------------- 1 | 6 | 10 | 14 | 18 | 21 | 24 | 27 | 31 | 34 | 38 | 39 | -------------------------------------------------------------------------------- /android/src/main/res/drawable/ic_notification_disabled.xml: -------------------------------------------------------------------------------- 1 | 6 | 10 | 14 | 18 | 23 | 28 | 33 | 37 | 42 | 46 | 47 | -------------------------------------------------------------------------------- /android/src/main/res/drawable/ic_tile.xml: -------------------------------------------------------------------------------- 1 | 6 | 7 | 10 | 13 | 16 | 19 | 22 | 25 | 28 | 31 | 34 | 35 | 36 | -------------------------------------------------------------------------------- /android/src/main/res/drawable/info.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /android/src/main/res/drawable/link.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /android/src/main/res/drawable/link_off.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /android/src/main/res/drawable/mullvad_logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tailscale/tailscale-android/5f59a367e3a5573b0ce12ec42cd07fa50a98a785/android/src/main/res/drawable/mullvad_logo.png -------------------------------------------------------------------------------- /android/src/main/res/drawable/pencil.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /android/src/main/res/drawable/power.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /android/src/main/res/drawable/single_file.xml: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /android/src/main/res/drawable/timer.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /android/src/main/res/drawable/warning.xml: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /android/src/main/res/drawable/warning_rounded.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /android/src/main/res/drawable/xmark.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /android/src/main/res/drawable/xmark_circle.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /android/src/main/res/mipmap-anydpi-v26/ic_launcher.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /android/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /android/src/main/res/mipmap-hdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tailscale/tailscale-android/5f59a367e3a5573b0ce12ec42cd07fa50a98a785/android/src/main/res/mipmap-hdpi/ic_launcher.webp -------------------------------------------------------------------------------- /android/src/main/res/mipmap-hdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tailscale/tailscale-android/5f59a367e3a5573b0ce12ec42cd07fa50a98a785/android/src/main/res/mipmap-hdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /android/src/main/res/mipmap-mdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tailscale/tailscale-android/5f59a367e3a5573b0ce12ec42cd07fa50a98a785/android/src/main/res/mipmap-mdpi/ic_launcher.webp -------------------------------------------------------------------------------- /android/src/main/res/mipmap-mdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tailscale/tailscale-android/5f59a367e3a5573b0ce12ec42cd07fa50a98a785/android/src/main/res/mipmap-mdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /android/src/main/res/mipmap-xhdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tailscale/tailscale-android/5f59a367e3a5573b0ce12ec42cd07fa50a98a785/android/src/main/res/mipmap-xhdpi/ic_launcher.webp -------------------------------------------------------------------------------- /android/src/main/res/mipmap-xhdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tailscale/tailscale-android/5f59a367e3a5573b0ce12ec42cd07fa50a98a785/android/src/main/res/mipmap-xhdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /android/src/main/res/mipmap-xxhdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tailscale/tailscale-android/5f59a367e3a5573b0ce12ec42cd07fa50a98a785/android/src/main/res/mipmap-xxhdpi/ic_launcher.webp -------------------------------------------------------------------------------- /android/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tailscale/tailscale-android/5f59a367e3a5573b0ce12ec42cd07fa50a98a785/android/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /android/src/main/res/mipmap-xxxhdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tailscale/tailscale-android/5f59a367e3a5573b0ce12ec42cd07fa50a98a785/android/src/main/res/mipmap-xxxhdpi/ic_launcher.webp -------------------------------------------------------------------------------- /android/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tailscale/tailscale-android/5f59a367e3a5573b0ce12ec42cd07fa50a98a785/android/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /android/src/main/res/values/ic_launcher_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | #1F1E1E 4 | -------------------------------------------------------------------------------- /android/src/main/res/values/splash.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 8 | 9 | -------------------------------------------------------------------------------- /android/src/main/res/values/string-arrays.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | current-user 5 | other-users 6 | tagged-devices 7 | 8 | 9 | Current user devices 10 | Other users devices 11 | Tagged devices 12 | 13 | 14 | always 15 | never 16 | user-decides 17 | 18 | 19 | Always 20 | Never 21 | User Decides 22 | 23 | 24 | show 25 | hide 26 | 27 | 28 | Show 29 | Hide 30 | 31 | -------------------------------------------------------------------------------- /android/src/test/java/android/util/Log.java: -------------------------------------------------------------------------------- 1 | // Copyright (c) Tailscale Inc & AUTHORS 2 | // SPDX-License-Identifier: BSD-3-Clause 3 | 4 | package android.util; 5 | 6 | /** 7 | * This is a mock class for the android.util.Log class. It is used to print log messages to the console. 8 | */ 9 | public class Log { 10 | public static int d(String tag, String msg) { 11 | System.out.println("DEBUG: " + tag + ": " + msg); 12 | return 0; 13 | } 14 | 15 | public static int i(String tag, String msg) { 16 | System.out.println("INFO: " + tag + ": " + msg); 17 | return 0; 18 | } 19 | 20 | public static int w(String tag, String msg) { 21 | System.out.println("WARN: " + tag + ": " + msg); 22 | return 0; 23 | } 24 | 25 | public static int e(String tag, String msg) { 26 | System.out.println("ERROR: " + tag + ": " + msg); 27 | return 0; 28 | } 29 | 30 | } 31 | -------------------------------------------------------------------------------- /android/src/test/kotlin/com/tailcale/ipn/ui/util/.TimeUtilTest.kt.swp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tailscale/tailscale-android/5f59a367e3a5573b0ce12ec42cd07fa50a98a785/android/src/test/kotlin/com/tailcale/ipn/ui/util/.TimeUtilTest.kt.swp -------------------------------------------------------------------------------- /android/src/test/kotlin/com/tailcale/ipn/ui/util/TimeUtilTest.kt: -------------------------------------------------------------------------------- 1 | // Copyright (c) Tailscale Inc & AUTHORS 2 | // SPDX-License-Identifier: BSD-3-Clause 3 | 4 | package com.tailcale.ipn.ui.util 5 | 6 | import com.tailscale.ipn.ui.util.TimeUtil 7 | import com.tailscale.ipn.util.TSLog 8 | import com.tailscale.ipn.util.TSLog.LibtailscaleWrapper 9 | import java.time.Duration 10 | import org.junit.After 11 | import org.junit.Assert.assertEquals 12 | import org.junit.Assert.assertNull 13 | import org.junit.Before 14 | import org.junit.Test 15 | import org.mockito.ArgumentMatchers.anyString 16 | import org.mockito.Mockito.doNothing 17 | import org.mockito.Mockito.mock 18 | 19 | class TimeUtilTest { 20 | 21 | private lateinit var libtailscaleWrapperMock: LibtailscaleWrapper 22 | private lateinit var originalWrapper: LibtailscaleWrapper 23 | 24 | @Before 25 | fun setUp() { 26 | libtailscaleWrapperMock = mock(LibtailscaleWrapper::class.java) 27 | doNothing().`when`(libtailscaleWrapperMock).sendLog(anyString(), anyString()) 28 | 29 | // Store the original wrapper so we can reset it later 30 | originalWrapper = TSLog.libtailscaleWrapper 31 | // Inject mock into TSLog 32 | TSLog.libtailscaleWrapper = libtailscaleWrapperMock 33 | } 34 | 35 | @After 36 | fun tearDown() { 37 | // Reset TSLog after each test to avoid side effects 38 | TSLog.libtailscaleWrapper = originalWrapper 39 | } 40 | 41 | @Test 42 | fun durationInvalidMsUnits() { 43 | val input = "5s10ms" 44 | val actual = TimeUtil.duration(input) 45 | assertNull("Should return null", actual) 46 | } 47 | 48 | @Test 49 | fun durationInvalidUsUnits() { 50 | val input = "5s10us" 51 | val actual = TimeUtil.duration(input) 52 | assertNull("Should return null", actual) 53 | } 54 | 55 | @Test 56 | fun durationTestHappyPath() { 57 | val input = arrayOf("1.0y1.0w1.0d1.0h1.0m1.0s", "1s", "1m", "1h", "1d", "1w", "1y") 58 | val expectedSeconds = 59 | arrayOf((31536000 + 604800 + 86400 + 3600 + 60 + 1), 1, 60, 3600, 86400, 604800, 31536000) 60 | val expected = expectedSeconds.map { Duration.ofSeconds(it.toLong()) } 61 | val actual = input.map { TimeUtil.duration(it) } 62 | assertEquals("Incorrect conversion", expected, actual) 63 | } 64 | 65 | @Test 66 | fun testBadDurationString() { 67 | val input = "1..0y1.0w1.0d1.0h1.0m1.0s" 68 | val actual = TimeUtil.duration(input) 69 | assertNull("Should return null", actual) 70 | } 71 | 72 | @Test 73 | fun testBadDInputString() { 74 | val libtailscaleWrapperMock = mock(LibtailscaleWrapper::class.java) 75 | doNothing().`when`(libtailscaleWrapperMock).sendLog(anyString(), anyString()) 76 | 77 | val input = "1.0yy1.0w1.0d1.0h1.0m1.0s" 78 | val actual = TimeUtil.duration(input) 79 | assertNull("Should return null", actual) 80 | } 81 | 82 | @Test 83 | fun testIgnoreFractionalSeconds() { 84 | val input = "10.9s" 85 | val expectedSeconds = 10 86 | val expected = Duration.ofSeconds(expectedSeconds.toLong()) 87 | val actual = TimeUtil.duration(input) 88 | assertEquals("Should return $expectedSeconds seconds", expected, actual) 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /build-tags.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | if [[ -z "$TOOLCHAIN_DIR" ]]; then 4 | # By default, if TOOLCHAIN_DIR is unset, we assume we're 5 | # using the Tailscale Go toolchain (github.com/tailscale/go) 6 | # at the revision specified by go.toolchain.rev. If so, 7 | # we tell our caller to use the "tailscale_go" build tag. 8 | echo "tailscale_go" 9 | else 10 | # Otherwise, if TOOLCHAIN_DIR is specified, we assume 11 | # we're F-Droid or something using a stock Go toolchain. 12 | # That's fine. But we don't set the tailscale_go build tag. 13 | # Return some no-op build tag that's non-empty for clarity 14 | # when debugging. 15 | echo "not_tailscale_go" 16 | fi 17 | -------------------------------------------------------------------------------- /docker/DockerFile.amd64-build: -------------------------------------------------------------------------------- 1 | # This is a Dockerfile for creating a build environment for 2 | # tailscale-android. 3 | 4 | FROM --platform=linux/amd64 eclipse-temurin:21 5 | 6 | ENV HOME /build 7 | ENV ANDROID_HOME $HOME/android-sdk 8 | ENV ANDROID_SDK_ROOT $ANDROID_HOME 9 | ENV PATH $PATH:$HOME/bin:$ANDROID_HOME/platform-tools 10 | 11 | RUN mkdir -p \ 12 | ${HOME} \ 13 | /android-sdk \ 14 | ${ANDROID_HOME} \ 15 | $HOME/tailscale-android 16 | 17 | # To enable running android tools such as aapt 18 | COPY scripts/docker-build-apt-get.sh /tmp 19 | RUN chmod 755 /tmp/docker-build-apt-get.sh && \ 20 | /tmp/docker-build-apt-get.sh && \ 21 | rm -f /tmp/docker-build-apt-get.sh 22 | 23 | # We need some version of Go new enough to support the "embed" package 24 | # to run "go run tailscale.com/cmd/printdep" to figure out which Tailscale Go 25 | # version we need later, but otherwise this toolchain isn't used: 26 | RUN \ 27 | curl -L https://go.dev/dl/go1.24.1.linux-amd64.tar.gz | tar -C /usr/local -zxv && \ 28 | ln -s /usr/local/go/bin/go /usr/bin 29 | 30 | RUN git config --global --add safe.directory $HOME/tailscale-android 31 | WORKDIR $HOME/tailscale-android 32 | 33 | COPY Makefile Makefile 34 | 35 | # Get android sdk, ndk, and rest of the stuff needed to build the android app. 36 | RUN make androidsdk 37 | 38 | # Preload Gradle 39 | COPY android/gradlew android/build.gradle android/ 40 | COPY android/gradle android/gradle 41 | 42 | RUN chmod 755 android/gradlew && \ 43 | ./android/gradlew 44 | 45 | # Build the android app, bump the playstore version code, and make the tv release 46 | CMD make clean && make release && make bump_version_code && make release-tv 47 | -------------------------------------------------------------------------------- /flake.lock: -------------------------------------------------------------------------------- 1 | { 2 | "nodes": { 3 | "android": { 4 | "inputs": { 5 | "devshell": "devshell", 6 | "flake-utils": "flake-utils_2", 7 | "nixpkgs": [ 8 | "nixpkgs" 9 | ] 10 | }, 11 | "locked": { 12 | "lastModified": 1648412532, 13 | "narHash": "sha256-zh0rLcppJ5i2Bh8oBWjBhDvgOrMhDGdXINbp3bhrs0U=", 14 | "owner": "tadfisher", 15 | "repo": "android-nixpkgs", 16 | "rev": "b1318b23926260685dbb09dd127f38c917fc7441", 17 | "type": "github" 18 | }, 19 | "original": { 20 | "owner": "tadfisher", 21 | "repo": "android-nixpkgs", 22 | "type": "github" 23 | } 24 | }, 25 | "devshell": { 26 | "inputs": { 27 | "flake-utils": "flake-utils", 28 | "nixpkgs": "nixpkgs" 29 | }, 30 | "locked": { 31 | "lastModified": 1647857022, 32 | "narHash": "sha256-Aw70NWLOIwKhT60MHDGjgWis3DP3faCzr6ap9CSayek=", 33 | "owner": "numtide", 34 | "repo": "devshell", 35 | "rev": "0a5ff74dacb9ea22614f64e61aeb3ca0bf0e7311", 36 | "type": "github" 37 | }, 38 | "original": { 39 | "owner": "numtide", 40 | "repo": "devshell", 41 | "type": "github" 42 | } 43 | }, 44 | "flake-utils": { 45 | "locked": { 46 | "lastModified": 1642700792, 47 | "narHash": "sha256-XqHrk7hFb+zBvRg6Ghl+AZDq03ov6OshJLiSWOoX5es=", 48 | "owner": "numtide", 49 | "repo": "flake-utils", 50 | "rev": "846b2ae0fc4cc943637d3d1def4454213e203cba", 51 | "type": "github" 52 | }, 53 | "original": { 54 | "owner": "numtide", 55 | "repo": "flake-utils", 56 | "type": "github" 57 | } 58 | }, 59 | "flake-utils_2": { 60 | "locked": { 61 | "lastModified": 1648297722, 62 | "narHash": "sha256-W+qlPsiZd8F3XkzXOzAoR+mpFqzm3ekQkJNa+PIh1BQ=", 63 | "owner": "numtide", 64 | "repo": "flake-utils", 65 | "rev": "0f8662f1319ad6abf89b3380dd2722369fc51ade", 66 | "type": "github" 67 | }, 68 | "original": { 69 | "owner": "numtide", 70 | "repo": "flake-utils", 71 | "type": "github" 72 | } 73 | }, 74 | "nixpkgs": { 75 | "locked": { 76 | "lastModified": 1643381941, 77 | "narHash": "sha256-pHTwvnN4tTsEKkWlXQ8JMY423epos8wUOhthpwJjtpc=", 78 | "owner": "NixOS", 79 | "repo": "nixpkgs", 80 | "rev": "5efc8ca954272c4376ac929f4c5ffefcc20551d5", 81 | "type": "github" 82 | }, 83 | "original": { 84 | "owner": "NixOS", 85 | "ref": "nixpkgs-unstable", 86 | "repo": "nixpkgs", 87 | "type": "github" 88 | } 89 | }, 90 | "nixpkgs_2": { 91 | "locked": { 92 | "lastModified": 1648484058, 93 | "narHash": "sha256-YJFeh59/HMassannh6JJuy+0l305xFhvUPxWLITRgWk=", 94 | "owner": "NixOS", 95 | "repo": "nixpkgs", 96 | "rev": "e4b25c3f0a8828e39e6194c3f7b142a937a9d51f", 97 | "type": "github" 98 | }, 99 | "original": { 100 | "owner": "NixOS", 101 | "repo": "nixpkgs", 102 | "type": "github" 103 | } 104 | }, 105 | "root": { 106 | "inputs": { 107 | "android": "android", 108 | "nixpkgs": "nixpkgs_2" 109 | } 110 | } 111 | }, 112 | "root": "root", 113 | "version": 7 114 | } 115 | -------------------------------------------------------------------------------- /flake.nix: -------------------------------------------------------------------------------- 1 | { 2 | description = "Tailscale build environment"; 3 | 4 | inputs = { 5 | nixpkgs.url = "github:NixOS/nixpkgs"; 6 | android.url = "github:tadfisher/android-nixpkgs"; 7 | android.inputs.nixpkgs.follows = "nixpkgs"; 8 | }; 9 | 10 | outputs = { self, nixpkgs, android }: 11 | let 12 | supportedSystems = [ "x86_64-linux" "x86_64-darwin" "aarch64-darwin" ]; 13 | forAllSystems = f: nixpkgs.lib.genAttrs supportedSystems (system: f system); 14 | in 15 | { 16 | devShells = forAllSystems 17 | (system: 18 | let 19 | pkgs = import nixpkgs { 20 | inherit system; 21 | }; 22 | android-sdk = android.sdk.${system} (sdkPkgs: with sdkPkgs; 23 | [ 24 | build-tools-30-0-2 25 | cmdline-tools-latest 26 | platform-tools 27 | platforms-android-31 28 | platforms-android-30 29 | ndk-23-1-7779620 30 | patcher-v4 31 | ]); 32 | in 33 | { 34 | default = (with pkgs; buildFHSUserEnv { 35 | name = "tailscale"; 36 | profile = '' 37 | export ANDROID_SDK_ROOT="${android-sdk}/share/android-sdk" 38 | export JAVA_HOME="${jdk8.home}" 39 | ''; 40 | targetPkgs = pkgs: with pkgs; [ 41 | android-sdk 42 | jdk8 43 | clang 44 | ] ++ (if stdenv.isLinux then [ 45 | vulkan-headers 46 | libxkbcommon 47 | wayland 48 | xorg.libX11 49 | xorg.libXcursor 50 | xorg.libXfixes 51 | libGL 52 | pkgconfig 53 | ] else [ ]); 54 | }).env; 55 | } 56 | ); 57 | }; 58 | } 59 | -------------------------------------------------------------------------------- /go.toolchain.rev: -------------------------------------------------------------------------------- 1 | 98e8c99c256a5aeaa13725d2e43fdd7f465ba200 2 | -------------------------------------------------------------------------------- /libtailscale/callbacks.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) Tailscale Inc & AUTHORS 2 | // SPDX-License-Identifier: BSD-3-Clause 3 | 4 | package libtailscale 5 | 6 | import ( 7 | "sync" 8 | ) 9 | 10 | var ( 11 | // onVPNRequested receives global IPNService references when 12 | // a VPN connection is requested. 13 | onVPNRequested = make(chan IPNService) 14 | // onDisconnect receives global IPNService references when 15 | // disconnecting. 16 | onDisconnect = make(chan IPNService) 17 | 18 | // onGoogleToken receives google ID tokens. 19 | onGoogleToken = make(chan string) 20 | 21 | // onDNSConfigChanged is notified when the network changes and the DNS config needs to be updated. It receives the updated interface name. 22 | onDNSConfigChanged = make(chan string, 1) 23 | 24 | // onLog receives Android logs to be sent to the logger 25 | onLog = make(chan string, 10) 26 | 27 | // onShareFileHelper receives ShareFileHelper references when the app is initialized so that files can be received via Storage Access Framework 28 | onShareFileHelper = make(chan ShareFileHelper, 1) 29 | 30 | // onFilePath receives the SAF path used for Taildrop 31 | onFilePath = make(chan string) 32 | ) 33 | 34 | // ifname is the interface name retrieved from LinkProperties on network change. An empty string is used if there is no network available. 35 | func OnDNSConfigChanged(ifname string) { 36 | select { 37 | case onDNSConfigChanged <- ifname: 38 | default: 39 | } 40 | } 41 | 42 | var android struct { 43 | // mu protects all fields of this structure. However, once a 44 | // non-nil jvm is returned from javaVM, all the other fields may 45 | // be accessed unlocked. 46 | mu sync.Mutex 47 | 48 | // appCtx is the global Android App context. 49 | appCtx AppContext 50 | } 51 | -------------------------------------------------------------------------------- /libtailscale/fileops.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) Tailscale Inc & AUTHORS 2 | // SPDX-License-Identifier: BSD-3-Clause 3 | package libtailscale 4 | 5 | import ( 6 | "fmt" 7 | "io" 8 | ) 9 | 10 | // AndroidFileOps implements the ShareFileHelper interface using the Android helper. 11 | type AndroidFileOps struct { 12 | helper ShareFileHelper 13 | } 14 | 15 | func NewAndroidFileOps(helper ShareFileHelper) *AndroidFileOps { 16 | return &AndroidFileOps{helper: helper} 17 | } 18 | 19 | func (ops *AndroidFileOps) OpenFileURI(filename string) string { 20 | return ops.helper.OpenFileURI(filename) 21 | } 22 | 23 | func (ops *AndroidFileOps) OpenFileWriter(filename string) (io.WriteCloser, string, error) { 24 | uri := ops.helper.OpenFileURI(filename) 25 | outputStream := ops.helper.OpenFileWriter(filename) 26 | if outputStream == nil { 27 | return nil, uri, fmt.Errorf("failed to open SAF output stream for %s", filename) 28 | } 29 | return outputStream, uri, nil 30 | } 31 | 32 | func (ops *AndroidFileOps) RenamePartialFile(partialUri, targetDirUri, targetName string) (string, error) { 33 | newURI := ops.helper.RenamePartialFile(partialUri, targetDirUri, targetName) 34 | if newURI == "" { 35 | return "", fmt.Errorf("failed to rename partial file via SAF") 36 | } 37 | return newURI, nil 38 | } 39 | -------------------------------------------------------------------------------- /libtailscale/log.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) Tailscale Inc & AUTHORS 2 | // SPDX-License-Identifier: BSD-3-Clause 3 | 4 | // Gratefully borrowed from Gio UI https://gioui.org/ under MIT license 5 | 6 | package libtailscale 7 | 8 | /* 9 | #cgo LDFLAGS: -llog 10 | 11 | #include 12 | #include 13 | */ 14 | import "C" 15 | 16 | import ( 17 | "bufio" 18 | "log" 19 | "os" 20 | "path/filepath" 21 | "runtime" 22 | "runtime/debug" 23 | "syscall" 24 | "unsafe" 25 | ) 26 | 27 | // 1024 is the truncation limit from android/log.h, plus a \n. 28 | const logLineLimit = 1024 29 | 30 | var ID = filepath.Base(os.Args[0]) 31 | 32 | var logTag = C.CString(ID) 33 | 34 | func initLogging(appCtx AppContext) { 35 | // Android's logcat already includes timestamps. 36 | log.SetFlags(log.Flags() &^ log.LstdFlags) 37 | log.SetOutput(&androidLogWriter{ 38 | appCtx: appCtx, 39 | }) 40 | 41 | // Redirect stdout and stderr to the Android logger. 42 | logFd(os.Stdout.Fd()) 43 | logFd(os.Stderr.Fd()) 44 | } 45 | 46 | type androidLogWriter struct { 47 | appCtx AppContext 48 | } 49 | 50 | func (w *androidLogWriter) Write(data []byte) (int, error) { 51 | n := 0 52 | for len(data) > 0 { 53 | msg := data 54 | // Truncate the buffer 55 | if len(msg) > logLineLimit { 56 | msg = msg[:logLineLimit] 57 | } 58 | w.appCtx.Log(ID, string(msg)) 59 | n += len(msg) 60 | data = data[len(msg):] 61 | } 62 | return n, nil 63 | } 64 | 65 | func logFd(fd uintptr) { 66 | r, w, err := os.Pipe() 67 | if err != nil { 68 | panic(err) 69 | } 70 | if err := syscall.Dup3(int(w.Fd()), int(fd), syscall.O_CLOEXEC); err != nil { 71 | panic(err) 72 | } 73 | go func() { 74 | defer func() { 75 | if p := recover(); p != nil { 76 | log.Printf("panic in logFd %s: %s", p, debug.Stack()) 77 | panic(p) 78 | } 79 | }() 80 | 81 | lineBuf := bufio.NewReaderSize(r, logLineLimit) 82 | // The buffer to pass to C, including the terminating '\0'. 83 | buf := make([]byte, lineBuf.Size()+1) 84 | cbuf := (*C.char)(unsafe.Pointer(&buf[0])) 85 | for { 86 | line, _, err := lineBuf.ReadLine() 87 | if err != nil { 88 | break 89 | } 90 | copy(buf, line) 91 | buf[len(line)] = 0 92 | C.__android_log_write(C.ANDROID_LOG_INFO, logTag, cbuf) 93 | } 94 | // The garbage collector doesn't know that w's fd was dup'ed. 95 | // Avoid finalizing w, and thereby avoid its finalizer closing its fd. 96 | runtime.KeepAlive(w) 97 | }() 98 | } 99 | -------------------------------------------------------------------------------- /libtailscale/notifier.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) Tailscale Inc & AUTHORS 2 | // SPDX-License-Identifier: BSD-3-Clause 3 | 4 | package libtailscale 5 | 6 | import ( 7 | "context" 8 | "encoding/json" 9 | "log" 10 | "runtime/debug" 11 | 12 | "tailscale.com/ipn" 13 | ) 14 | 15 | func (app *App) WatchNotifications(mask int, cb NotificationCallback) NotificationManager { 16 | app.ready.Wait() 17 | 18 | ctx, cancel := context.WithCancel(context.Background()) 19 | go app.backend.WatchNotifications(ctx, ipn.NotifyWatchOpt(mask), func() {}, func(notify *ipn.Notify) bool { 20 | defer func() { 21 | if p := recover(); p != nil { 22 | log.Printf("panic in WatchNotifications %s: %s", p, debug.Stack()) 23 | panic(p) 24 | } 25 | }() 26 | 27 | b, err := json.Marshal(notify) 28 | if err != nil { 29 | log.Printf("error: WatchNotifications: marshal notify: %s", err) 30 | return true 31 | } 32 | err = cb.OnNotify(b) 33 | if err != nil { 34 | log.Printf("error: WatchNotifications: OnNotify: %s", err) 35 | return true 36 | } 37 | return true 38 | }) 39 | return ¬ificationManager{cancel} 40 | } 41 | 42 | type notificationManager struct { 43 | cancel func() 44 | } 45 | 46 | func (nm *notificationManager) Stop() { 47 | nm.cancel() 48 | } 49 | -------------------------------------------------------------------------------- /libtailscale/store.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) Tailscale Inc & AUTHORS 2 | // SPDX-License-Identifier: BSD-3-Clause 3 | 4 | package libtailscale 5 | 6 | import ( 7 | "encoding/base64" 8 | 9 | "tailscale.com/ipn" 10 | ) 11 | 12 | // stateStore is the Go interface for a persistent storage 13 | // backend by androidx.security.crypto.EncryptedSharedPreferences (see 14 | // App.java). 15 | type stateStore struct { 16 | // appCtx is the global Android app context. 17 | appCtx AppContext 18 | } 19 | 20 | func newStateStore(appCtx AppContext) *stateStore { 21 | return &stateStore{ 22 | appCtx: appCtx, 23 | } 24 | } 25 | 26 | func prefKeyFor(id ipn.StateKey) string { 27 | return "statestore-" + string(id) 28 | } 29 | 30 | func (s *stateStore) ReadString(key string, def string) (string, error) { 31 | data, err := s.read(key) 32 | if err != nil { 33 | return def, err 34 | } 35 | if data == nil { 36 | return def, nil 37 | } 38 | return string(data), nil 39 | } 40 | 41 | func (s *stateStore) WriteString(key string, val string) error { 42 | return s.write(key, []byte(val)) 43 | } 44 | 45 | func (s *stateStore) ReadBool(key string, def bool) (bool, error) { 46 | data, err := s.read(key) 47 | if err != nil { 48 | return def, err 49 | } 50 | if data == nil { 51 | return def, nil 52 | } 53 | return string(data) == "true", nil 54 | } 55 | 56 | func (s *stateStore) WriteBool(key string, val bool) error { 57 | data := []byte("false") 58 | if val { 59 | data = []byte("true") 60 | } 61 | return s.write(key, data) 62 | } 63 | 64 | func (s *stateStore) ReadState(id ipn.StateKey) ([]byte, error) { 65 | state, err := s.read(prefKeyFor(id)) 66 | if err != nil { 67 | return nil, err 68 | } 69 | if state == nil { 70 | return nil, ipn.ErrStateNotExist 71 | } 72 | return state, nil 73 | } 74 | 75 | func (s *stateStore) WriteState(id ipn.StateKey, bs []byte) error { 76 | prefKey := prefKeyFor(id) 77 | return s.write(prefKey, bs) 78 | } 79 | 80 | func (s *stateStore) read(key string) ([]byte, error) { 81 | b64, err := s.appCtx.DecryptFromPref(key) 82 | if err != nil { 83 | return nil, err 84 | } 85 | if b64 == "" { 86 | return nil, nil 87 | } 88 | return base64.RawStdEncoding.DecodeString(b64) 89 | } 90 | 91 | func (s *stateStore) write(key string, value []byte) error { 92 | bs64 := base64.RawStdEncoding.EncodeToString(value) 93 | return s.appCtx.EncryptToPref(key, bs64) 94 | } 95 | -------------------------------------------------------------------------------- /libtailscale/syspolicy_handler.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) Tailscale Inc & AUTHORS 2 | // SPDX-License-Identifier: BSD-3-Clause 3 | 4 | package libtailscale 5 | 6 | import ( 7 | "encoding/json" 8 | "errors" 9 | "sync" 10 | 11 | "tailscale.com/util/set" 12 | "tailscale.com/util/syspolicy" 13 | ) 14 | 15 | // syspolicyHandler is a syspolicy handler for the Android version of the Tailscale client, 16 | // which lets the main networking code read values set via the Android RestrictionsManager. 17 | type syspolicyHandler struct { 18 | a *App 19 | mu sync.RWMutex 20 | cbs set.HandleSet[func()] 21 | } 22 | 23 | func (h *syspolicyHandler) ReadString(key string) (string, error) { 24 | if key == "" { 25 | return "", syspolicy.ErrNoSuchKey 26 | } 27 | retVal, err := h.a.appCtx.GetSyspolicyStringValue(key) 28 | return retVal, translateHandlerError(err) 29 | } 30 | 31 | func (h *syspolicyHandler) ReadBoolean(key string) (bool, error) { 32 | if key == "" { 33 | return false, syspolicy.ErrNoSuchKey 34 | } 35 | retVal, err := h.a.appCtx.GetSyspolicyBooleanValue(key) 36 | return retVal, translateHandlerError(err) 37 | } 38 | 39 | func (h *syspolicyHandler) ReadUInt64(key string) (uint64, error) { 40 | if key == "" { 41 | return 0, syspolicy.ErrNoSuchKey 42 | } 43 | // We don't have any UInt64 policy settings as of 2024-08-06. 44 | return 0, errors.New("ReadUInt64 is not implemented on Android") 45 | } 46 | 47 | func (h *syspolicyHandler) ReadStringArray(key string) ([]string, error) { 48 | if key == "" { 49 | return nil, syspolicy.ErrNoSuchKey 50 | } 51 | retVal, err := h.a.appCtx.GetSyspolicyStringArrayJSONValue(key) 52 | if err := translateHandlerError(err); err != nil { 53 | return nil, err 54 | } 55 | if retVal == "" { 56 | return nil, syspolicy.ErrNoSuchKey 57 | } 58 | var arr []string 59 | jsonErr := json.Unmarshal([]byte(retVal), &arr) 60 | if jsonErr != nil { 61 | return nil, jsonErr 62 | } 63 | return arr, err 64 | } 65 | 66 | func (h *syspolicyHandler) RegisterChangeCallback(cb func()) (unregister func(), err error) { 67 | h.mu.Lock() 68 | handle := h.cbs.Add(cb) 69 | h.mu.Unlock() 70 | return func() { 71 | h.mu.Lock() 72 | delete(h.cbs, handle) 73 | h.mu.Unlock() 74 | }, nil 75 | } 76 | 77 | func (h *syspolicyHandler) notifyChanged() { 78 | h.mu.RLock() 79 | for _, cb := range h.cbs { 80 | go cb() 81 | } 82 | h.mu.RUnlock() 83 | } 84 | 85 | func translateHandlerError(err error) error { 86 | if err != nil && !errors.Is(err, syspolicy.ErrNoSuchKey) && err.Error() == syspolicy.ErrNoSuchKey.Error() { 87 | return syspolicy.ErrNoSuchKey 88 | } 89 | return err // may be nil or non-nil 90 | } 91 | -------------------------------------------------------------------------------- /libtailscale/tailscale.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) Tailscale Inc & AUTHORS 2 | // SPDX-License-Identifier: BSD-3-Clause 3 | 4 | package libtailscale 5 | 6 | import ( 7 | "context" 8 | "log" 9 | "net/http" 10 | "path/filepath" 11 | "runtime/debug" 12 | "time" 13 | 14 | "tailscale.com/health" 15 | "tailscale.com/logpolicy" 16 | "tailscale.com/logtail" 17 | "tailscale.com/logtail/filch" 18 | "tailscale.com/net/netmon" 19 | "tailscale.com/types/logger" 20 | "tailscale.com/types/logid" 21 | "tailscale.com/util/clientmetric" 22 | "tailscale.com/util/syspolicy" 23 | ) 24 | 25 | const defaultMTU = 1280 // minimalMTU from wgengine/userspace.go 26 | 27 | const ( 28 | logPrefKey = "privatelogid" 29 | loginMethodPrefKey = "loginmethod" 30 | customLoginServerPrefKey = "customloginserver" 31 | ) 32 | 33 | func newApp(dataDir, directFileRoot string, appCtx AppContext) Application { 34 | a := &App{ 35 | directFileRoot: directFileRoot, 36 | dataDir: dataDir, 37 | appCtx: appCtx, 38 | backendRestartCh: make(chan struct{}, 1), 39 | } 40 | a.ready.Add(2) 41 | 42 | a.store = newStateStore(a.appCtx) 43 | a.policyStore = &syspolicyHandler{a: a} 44 | netmon.RegisterInterfaceGetter(a.getInterfaces) 45 | syspolicy.RegisterHandler(a.policyStore) 46 | go a.watchFileOpsChanges() 47 | 48 | go func() { 49 | defer func() { 50 | if p := recover(); p != nil { 51 | log.Printf("panic in runBackend %s: %s", p, debug.Stack()) 52 | panic(p) 53 | } 54 | }() 55 | 56 | ctx := context.Background() 57 | if err := a.runBackend(ctx); err != nil { 58 | fatalErr(err) 59 | } 60 | }() 61 | 62 | return a 63 | } 64 | 65 | func fatalErr(err error) { 66 | // TODO: expose in UI. 67 | log.Printf("fatal error: %v", err) 68 | } 69 | 70 | // osVersion returns android.os.Build.VERSION.RELEASE. " [nogoogle]" is appended 71 | // if Google Play services are not compiled in. 72 | func (a *App) osVersion() string { 73 | version, err := a.appCtx.GetOSVersion() 74 | if err != nil { 75 | panic(err) 76 | } 77 | return version 78 | } 79 | 80 | // modelName return the MANUFACTURER + MODEL from 81 | // android.os.Build. 82 | func (a *App) modelName() string { 83 | model, err := a.appCtx.GetModelName() 84 | if err != nil { 85 | panic(err) 86 | } 87 | return model 88 | } 89 | 90 | func (a *App) isChromeOS() bool { 91 | isChromeOS, err := a.appCtx.IsChromeOS() 92 | if err != nil { 93 | panic(err) 94 | } 95 | return isChromeOS 96 | } 97 | 98 | // SetupLogs sets up remote logging. 99 | func (b *backend) setupLogs(logDir string, logID logid.PrivateID, logf logger.Logf, health *health.Tracker) { 100 | if b.netMon == nil { 101 | panic("netMon must be created prior to SetupLogs") 102 | } 103 | transport := logpolicy.NewLogtailTransport(logtail.DefaultHost, b.netMon, health, log.Printf) 104 | 105 | logcfg := logtail.Config{ 106 | Collection: logtail.CollectionNode, 107 | PrivateID: logID, 108 | Stderr: log.Writer(), 109 | MetricsDelta: clientmetric.EncodeLogTailMetricsDelta, 110 | IncludeProcID: true, 111 | IncludeProcSequence: true, 112 | HTTPC: &http.Client{Transport: transport}, 113 | CompressLogs: true, 114 | } 115 | logcfg.FlushDelayFn = func() time.Duration { return 2 * time.Minute } 116 | 117 | filchOpts := filch.Options{ 118 | ReplaceStderr: true, 119 | } 120 | 121 | var filchErr error 122 | if logDir != "" { 123 | logPath := filepath.Join(logDir, "ipn.log.") 124 | logcfg.Buffer, filchErr = filch.New(logPath, filchOpts) 125 | } 126 | 127 | b.logger = logtail.NewLogger(logcfg, logf) 128 | 129 | log.SetFlags(0) 130 | log.SetOutput(b.logger) 131 | 132 | log.Printf("goSetupLogs: success") 133 | 134 | if logDir == "" { 135 | log.Printf("SetupLogs: no logDir, storing logs in memory") 136 | } 137 | if filchErr != nil { 138 | log.Printf("SetupLogs: filch setup failed: %v", filchErr) 139 | } 140 | 141 | go func() { 142 | for { 143 | select { 144 | case logstr := <-onLog: 145 | b.logger.Logf(logstr) 146 | } 147 | } 148 | }() 149 | } 150 | -------------------------------------------------------------------------------- /libtailscale/vpnfacade.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) Tailscale Inc & AUTHORS 2 | // SPDX-License-Identifier: BSD-3-Clause 3 | 4 | package libtailscale 5 | 6 | import ( 7 | "sync" 8 | 9 | "tailscale.com/net/dns" 10 | "tailscale.com/wgengine/router" 11 | ) 12 | 13 | var ( 14 | _ router.Router = (*VPNFacade)(nil) 15 | _ dns.OSConfigurator = (*VPNFacade)(nil) 16 | ) 17 | 18 | // VPNFacade is an implementation of both wgengine.Router and 19 | // dns.OSConfigurator. When ReconfigureVPN is called by the backend, SetBoth 20 | // gets called. 21 | type VPNFacade struct { 22 | SetBoth func(rcfg *router.Config, dcfg *dns.OSConfig) error 23 | 24 | // GetBaseConfigFunc optionally specifies a function to return the current DNS 25 | // config in response to GetBaseConfig. 26 | // 27 | // If nil, reading the current config isn't supported and GetBaseConfig() 28 | // will return ErrGetBaseConfigNotSupported. 29 | GetBaseConfigFunc func() (dns.OSConfig, error) 30 | 31 | // InitialMTU is the MTU the tun should be initialized with. 32 | // Zero means don't change the MTU from the default. This MTU 33 | // is applied only once, shortly after the TUN is created, and 34 | // ignored thereaftef. 35 | InitialMTU uint32 36 | 37 | mu sync.Mutex // protects all the following 38 | didSetMTU bool // if we set the MTU already 39 | rcfg *router.Config // last applied router config 40 | dcfg *dns.OSConfig // last applied DNS config 41 | } 42 | 43 | // Up implements wgengine.router. 44 | func (vf *VPNFacade) Up() error { 45 | return nil // TODO: check that all callers have no need for initialization 46 | } 47 | 48 | // Set implements wgengine.router. 49 | func (vf *VPNFacade) Set(rcfg *router.Config) error { 50 | vf.mu.Lock() 51 | defer vf.mu.Unlock() 52 | if vf.rcfg.Equal(rcfg) { 53 | return nil 54 | } 55 | if vf.didSetMTU == false { 56 | vf.didSetMTU = true 57 | rcfg.NewMTU = int(vf.InitialMTU) 58 | } 59 | vf.rcfg = rcfg 60 | return nil 61 | } 62 | 63 | // UpdateMagicsockPort implements wgengine.Router. This implementation 64 | // does nothing and returns nil because this router does not currently need 65 | // to know what the magicsock UDP port is. 66 | func (vf *VPNFacade) UpdateMagicsockPort(_ uint16, _ string) error { 67 | return nil 68 | } 69 | 70 | // SetDNS implements dns.OSConfigurator. 71 | func (vf *VPNFacade) SetDNS(dcfg dns.OSConfig) error { 72 | vf.mu.Lock() 73 | defer vf.mu.Unlock() 74 | if vf.dcfg != nil && vf.dcfg.Equal(dcfg) { 75 | return nil 76 | } 77 | vf.dcfg = &dcfg 78 | return nil 79 | } 80 | 81 | // Implements dns.OSConfigurator. 82 | func (vf *VPNFacade) SupportsSplitDNS() bool { 83 | return false 84 | } 85 | 86 | // Implements dns.OSConfigurator. 87 | func (vf *VPNFacade) GetBaseConfig() (dns.OSConfig, error) { 88 | if vf.GetBaseConfigFunc == nil { 89 | return dns.OSConfig{}, dns.ErrGetBaseConfigNotSupported 90 | } 91 | return vf.GetBaseConfigFunc() 92 | } 93 | 94 | // Implements wgengine.router and dns.OSConfigurator. 95 | func (vf *VPNFacade) Close() error { 96 | return vf.SetBoth(nil, nil) // TODO: check if makes sense 97 | } 98 | 99 | // ReconfigureVPN is the method value passed to wgengine.Config.ReconfigureVPN. 100 | func (vf *VPNFacade) ReconfigureVPN() error { 101 | vf.mu.Lock() 102 | defer vf.mu.Unlock() 103 | 104 | return vf.SetBoth(vf.rcfg, vf.dcfg) 105 | } 106 | -------------------------------------------------------------------------------- /metadata/en-US/full_description.txt: -------------------------------------------------------------------------------- 1 | Tailscale is a mesh VPN alternative that makes it easy to connect your devices, wherever they are. No more fighting configuration or firewall ports. Built on WireGuard®, Tailscale enables an incremental shift to zero-trust networking by implementing “always-on” remote access. This guarantees a consistent, portable, and secure experience independent of physical location. 2 | 3 | WireGuard is a registered trademark of Jason A. Donenfeld. 4 | -------------------------------------------------------------------------------- /metadata/en-US/images/featureGraphic.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tailscale/tailscale-android/5f59a367e3a5573b0ce12ec42cd07fa50a98a785/metadata/en-US/images/featureGraphic.png -------------------------------------------------------------------------------- /metadata/en-US/images/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tailscale/tailscale-android/5f59a367e3a5573b0ce12ec42cd07fa50a98a785/metadata/en-US/images/icon.png -------------------------------------------------------------------------------- /metadata/en-US/images/phoneScreenshots/1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tailscale/tailscale-android/5f59a367e3a5573b0ce12ec42cd07fa50a98a785/metadata/en-US/images/phoneScreenshots/1.png -------------------------------------------------------------------------------- /metadata/en-US/images/phoneScreenshots/2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tailscale/tailscale-android/5f59a367e3a5573b0ce12ec42cd07fa50a98a785/metadata/en-US/images/phoneScreenshots/2.png -------------------------------------------------------------------------------- /metadata/en-US/images/phoneScreenshots/3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tailscale/tailscale-android/5f59a367e3a5573b0ce12ec42cd07fa50a98a785/metadata/en-US/images/phoneScreenshots/3.png -------------------------------------------------------------------------------- /metadata/en-US/images/phoneScreenshots/4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tailscale/tailscale-android/5f59a367e3a5573b0ce12ec42cd07fa50a98a785/metadata/en-US/images/phoneScreenshots/4.png -------------------------------------------------------------------------------- /metadata/en-US/images/tvScreenshots/1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tailscale/tailscale-android/5f59a367e3a5573b0ce12ec42cd07fa50a98a785/metadata/en-US/images/tvScreenshots/1.png -------------------------------------------------------------------------------- /metadata/en-US/images/tvScreenshots/2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tailscale/tailscale-android/5f59a367e3a5573b0ce12ec42cd07fa50a98a785/metadata/en-US/images/tvScreenshots/2.png -------------------------------------------------------------------------------- /metadata/en-US/images/tvScreenshots/3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tailscale/tailscale-android/5f59a367e3a5573b0ce12ec42cd07fa50a98a785/metadata/en-US/images/tvScreenshots/3.png -------------------------------------------------------------------------------- /metadata/en-US/short_description.txt: -------------------------------------------------------------------------------- 1 | Mesh VPN based on WireGuard 2 | -------------------------------------------------------------------------------- /scripts/check_license_headers.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # 3 | # Copyright (c) Tailscale Inc & AUTHORS 4 | # SPDX-License-Identifier: BSD-3-Clause 5 | # 6 | # check_license_headers.sh checks that source files in the given 7 | # directory tree have a correct-looking Tailscale license header. 8 | 9 | check_file() { 10 | got=$1 11 | 12 | want=$( 13 | cat <&2 26 | exit 1 27 | fi 28 | 29 | fail=0 30 | for file in $(find $1 \( -name '*.go' -or -name '*.tsx' -or -name '*.ts' -or -name '*.kt' -or -name '*.java' -not -name '*.config.ts' \) -not -path '*/.git/*' -not -path '*/node_modules/*'); do 31 | case $file in 32 | *) 33 | header="$(head -2 $file)" 34 | ;; 35 | esac 36 | if [ ! -z "$header" ]; then 37 | if ! check_file "$header"; then 38 | fail=1 39 | echo "${file#$1/} doesn't have the right copyright header:" 40 | echo "$header" | sed -e 's/^/ /g' 41 | fi 42 | fi 43 | done 44 | 45 | if [ $fail -ne 0 ]; then 46 | exit 1 47 | fi 48 | -------------------------------------------------------------------------------- /scripts/docker-build-apt-get.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # 3 | # Copyright (c) Tailscale Inc & AUTHORS 4 | # SPDX-License-Identifier: BSD-3-Clause 5 | # 6 | # docker-build-apt-get.sh runs 'apt'-related commands inside 7 | # the environment that /builds the docker image/ 8 | set -x 9 | set -e 10 | 11 | apt-get update 12 | apt-get -y upgrade 13 | 14 | apt-get -y install \ 15 | \ 16 | libstdc++6 \ 17 | libz1 \ 18 | make \ 19 | unzip \ 20 | zip \ 21 | \ 22 | # end of sort region 23 | 24 | apt-get -y --no-install-recommends install \ 25 | \ 26 | ca-certificates \ 27 | curl \ 28 | gcc \ 29 | git \ 30 | libc6-dev \ 31 | \ 32 | # end of sort region 33 | 34 | apt-get -y clean 35 | 36 | rm -rf \ 37 | /var/cache/debconf \ 38 | /var/lib/apt/lists \ 39 | /var/lib/apt/dpkg 40 | -------------------------------------------------------------------------------- /tool/go: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # Copyright (c) Tailscale Inc & AUTHORS 3 | # SPDX-License-Identifier: BSD-3-Clause 4 | 5 | set -eo pipefail 6 | 7 | if [[ "${CI:-}" == "true" && "${NOBASHDEBUG:-}" != "true" ]]; then 8 | set -x 9 | fi 10 | 11 | tsandroid=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )/.." &> /dev/null && pwd ) 12 | 13 | # Allow TOOLCHAINDIR to be overridden, as a special case for the fdroid build 14 | if [[ -z "${TOOLCHAINDIR}" ]]; then 15 | toolchain="$HOME/.cache/tailscale-go" 16 | 17 | if [[ -d "$toolchain" ]]; then 18 | # A toolchain exists, but is it recent enough to compile gocross? If not, 19 | # wipe it out so that the next if block fetches a usable one. 20 | want_go_minor=$(grep -E '^go ' "go.mod" | cut -f2 -d'.') 21 | have_go_minor="" 22 | if [[ -f "$toolchain/VERSION" ]]; then 23 | have_go_minor=$(head -1 "$toolchain/VERSION" | cut -f2 -d'.') 24 | fi 25 | # Shortly before stable releases, we run release candidate 26 | # toolchains, which have a non-numeric suffix on the version 27 | # number. Remove the rc qualifier, we just care about the minor 28 | # version. 29 | have_go_minor="${have_go_minor%rc*}" 30 | if [[ -z "$have_go_minor" || "$have_go_minor" -lt "$want_go_minor" ]]; then 31 | rm -rf "$toolchain" "$toolchain.extracted" 32 | fi 33 | fi 34 | 35 | REV="$(<${tsandroid}/go.toolchain.rev)" 36 | EREV="" 37 | [[ -f ${toolchain}.extracted ]] && EREV="$(<${toolchain}.extracted)" 38 | 39 | if [[ ! -d "$toolchain" || "$EREV" != "$REV" ]]; then 40 | mkdir -p "$HOME/.cache" 41 | 42 | case "$REV" in 43 | /*) 44 | toolchain="$REV" 45 | ;; 46 | *) 47 | # This works for linux and darwin, which is sufficient 48 | # (we do not build tailscale-go for other targets). 49 | HOST_OS=$(uname -s | tr A-Z a-z) 50 | HOST_ARCH="$(uname -m)" 51 | if [[ "$HOST_ARCH" == "aarch64" ]]; then 52 | # Go uses the name "arm64". 53 | HOST_ARCH="arm64" 54 | elif [[ "$HOST_ARCH" == "x86_64" ]]; then 55 | # Go uses the name "amd64". 56 | HOST_ARCH="amd64" 57 | fi 58 | 59 | rm -rf "$toolchain" "$toolchain.extracted" 60 | curl -f -L -o "$toolchain.tar.gz" "https://github.com/tailscale/go/releases/download/build-${REV}/${HOST_OS}-${HOST_ARCH}.tar.gz" 61 | mkdir -p "$toolchain" 62 | (cd "$toolchain" && tar --strip-components=1 -xf "$toolchain.tar.gz") 63 | echo "$REV" >"$toolchain.extracted" 64 | rm -f "$toolchain.tar.gz" 65 | ;; 66 | esac 67 | fi 68 | else 69 | # fdroid supplies its own toolchain, rather than using ours. 70 | toolchain="${TOOLCHAINDIR}" 71 | fi 72 | 73 | exec "${toolchain}/bin/go" "$@" 74 | -------------------------------------------------------------------------------- /version-ldflags.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | source tailscale.version || echo >&2 "no tailscale.version file found" 4 | if [[ -z "${VERSION_LONG}" ]]; then 5 | exit 1 6 | fi 7 | echo "-X tailscale.com/version.longStamp=${VERSION_LONG}" 8 | echo "-X tailscale.com/version.shortStamp=${VERSION_SHORT}" 9 | echo "-X tailscale.com/version.gitCommitStamp=${VERSION_GIT_HASH}" 10 | echo "-X tailscale.com/version.extraGitCommitStamp=${VERSION_EXTRA_HASH}" 11 | --------------------------------------------------------------------------------