diff --git a/.gitignore b/.gitignore
index fef2cbf..c4a19a2 100644
--- a/.gitignore
+++ b/.gitignore
@@ -8,6 +8,8 @@ build
android/libs
# Android Studio files
+android_legacy/.idea
+android_legacy/local.properties
android/.idea
android/local.properties
@@ -15,6 +17,8 @@ android/local.properties
tailscale-debug.apk
tailscale-release.aab
tailscale-fdroid.apk
+tailscale-new-fdroid.apk
+tailscale-new-debug.apk
# Signing key
tailscale.jks
diff --git a/Makefile b/Makefile
index daa2f38..850087d 100644
--- a/Makefile
+++ b/Makefile
@@ -5,7 +5,7 @@
DEBUG_APK=tailscale-debug.apk
RELEASE_AAB=tailscale-release.aab
APPID=com.tailscale.ipn
-AAR=android/libs/ipn.aar
+AAR=android_legacy/libs/ipn.aar
KEYSTORE=tailscale.jks
KEYSTORE_ALIAS=tailscale
TAILSCALE_VERSION=$(shell ./version/tailscale-version.sh 200)
@@ -14,12 +14,12 @@ TAILSCALE_VERSION_ABBREV=$(shell ./version/tailscale-version.sh 11)
OUR_VERSION_ABBREV=$(shell git describe --dirty --exclude "*" --always --abbrev=11)
VERSION_LONG=$(TAILSCALE_VERSION_ABBREV)-g$(OUR_VERSION_ABBREV)
# Extract the long version build.gradle's versionName and strip quotes.
-VERSIONNAME=$(patsubst "%",%,$(lastword $(shell grep versionName android/build.gradle)))
+VERSIONNAME=$(patsubst "%",%,$(lastword $(shell grep versionName android_legacy/build.gradle)))
# Extract the x.y.z part for the short version.
VERSIONNAME_SHORT=$(shell echo $(VERSIONNAME) | cut -d - -f 1)
TAILSCALE_COMMIT=$(shell echo $(TAILSCALE_VERSION) | cut -d - -f 2 | cut -d t -f 2)
# Extract the version code from build.gradle.
-VERSIONCODE=$(lastword $(shell grep versionCode android/build.gradle))
+VERSIONCODE=$(lastword $(shell grep versionCode android_legacy/build.gradle))
VERSIONCODE_PLUSONE=$(shell expr $(VERSIONCODE) + 1)
ifeq ($(shell uname),Linux)
ANDROID_TOOLS_URL="https://dl.google.com/android/repository/commandlinetools-linux-9477386_latest.zip"
@@ -77,9 +77,9 @@ env:
@echo TOOLCHAINDIR=$(TOOLCHAINDIR)
tag_release:
- sed -i'.bak' 's/versionCode $(VERSIONCODE)/versionCode $(VERSIONCODE_PLUSONE)/' android/build.gradle && rm android/build.gradle.bak
- sed -i'.bak' 's/versionName .*/versionName "$(VERSION_LONG)"/' android/build.gradle && rm android/build.gradle.bak
- git commit -sm "android: bump version code" android/build.gradle
+ sed -i'.bak' 's/versionCode $(VERSIONCODE)/versionCode $(VERSIONCODE_PLUSONE)/' android_legacy/build.gradle && rm android_legacy/build.gradle.bak
+ sed -i'.bak' 's/versionName .*/versionName "$(VERSION_LONG)"/' android_legacy/build.gradle && rm android_legacy/build.gradle.bak
+ git commit -sm "android: bump version code" android_legacy/build.gradle
git tag -a "$(VERSION_LONG)"
bumposs: toolchain
@@ -130,7 +130,7 @@ androidpath:
toolchain: $(TOOLCHAINDIR)/bin/go
android/libs:
- mkdir -p android/libs
+ mkdir -p android_legacy/libs
$(AAR): toolchain checkandroidsdk android/libs
go run gioui.org/cmd/gogio \
@@ -139,8 +139,8 @@ $(AAR): toolchain checkandroidsdk android/libs
# tailscale-debug.apk builds a debuggable APK with the Google Play SDK.
$(DEBUG_APK): $(AAR)
- (cd android && ./gradlew test assemblePlayDebug)
- mv android/build/outputs/apk/play/debug/android-play-debug.apk $@
+ (cd android_legacy && ./gradlew test assemblePlayDebug)
+ mv android_legacy/build/outputs/apk/play/debug/android_legacy-play-debug.apk $@
apk: $(DEBUG_APK)
@@ -151,12 +151,20 @@ run: install
# This is effectively what the F-Droid build definition produces.
# This is useful for testing on e.g. Amazon Fire Stick devices.
tailscale-fdroid.apk: $(AAR)
+ (cd android_legacy && ./gradlew test assembleFdroidDebug)
+ mv android_legacy/build/outputs/apk/fdroid/debug/android_legacy-fdroid-debug.apk $@
+
+tailscale-new-fdroid.apk: $(AAR)
(cd android && ./gradlew test assembleFdroidDebug)
mv android/build/outputs/apk/fdroid/debug/android-fdroid-debug.apk $@
+tailscale-new-debug: $(AAR)
+ (cd android && ./gradlew test assemblePlayDebug)
+ mv android/build/outputs/apk/play/debug/android-play-debug.apk $@
+
$(RELEASE_AAB): $(AAR)
- (cd android && ./gradlew test bundlePlayRelease)
- mv ./android/build/outputs/bundle/playRelease/android-play-release.aab $@
+ (cd android_legacy && ./gradlew test bundlePlayRelease)
+ mv ./android_legacy/build/outputs/bundle/playRelease/android-play-release.aab $@
release: $(RELEASE_AAB)
jarsigner -sigalg SHA256withRSA -digestalg SHA-256 -keystore $(KEYSTORE) $(RELEASE_AAB) $(KEYSTORE_ALIAS)
@@ -169,7 +177,7 @@ dockershell:
docker run -v $(CURDIR):/build/tailscale-android -it --rm tailscale-android
clean:
- -rm -rf android/build $(DEBUG_APK) $(RELEASE_AAB) $(AAR) tailscale-fdroid.apk
+ -rm -rf android_legacy/build $(DEBUG_APK) $(RELEASE_AAB) $(AAR) tailscale-fdroid.apk
-pkill -f gradle
-.PHONY: all clean install android/lib $(DEBUG_APK) $(RELEASE_AAB) $(AAR) release bump_version dockershell
+.PHONY: all clean install android_legacy/lib $(DEBUG_APK) $(RELEASE_AAB) $(AAR) release bump_version dockershell
diff --git a/android_legacy/build.gradle b/android_legacy/build.gradle
new file mode 100644
index 0000000..055eea4
--- /dev/null
+++ b/android_legacy/build.gradle
@@ -0,0 +1,60 @@
+
+buildscript {
+ ext.kotlin_version = "1.9.22"
+
+ repositories {
+ google()
+ mavenCentral()
+ }
+ dependencies {
+ classpath "com.android.tools.build:gradle:8.1.0"
+ }
+}
+
+repositories {
+ google()
+ mavenCentral()
+ flatDir {
+ dirs 'libs'
+ }
+}
+
+apply plugin: 'com.android.application'
+
+android {
+ ndkVersion "23.1.7779620"
+ compileSdk 33
+ defaultConfig {
+ minSdkVersion 22
+ targetSdkVersion 33
+ versionCode 198
+ versionName "1.59.53-t0f042b981-g1017015de26"
+ }
+ compileOptions {
+ sourceCompatibility JavaVersion.VERSION_17
+ targetCompatibility JavaVersion.VERSION_17
+ }
+ flavorDimensions "version"
+ productFlavors {
+ fdroid {
+ // The fdroid flavor contains only free dependencies and is suitable
+ // for the F-Droid app store.
+ }
+ play {
+ // The play flavor contains all features and is for the Play Store.
+ }
+ }
+ namespace 'com.tailscale.ipn'
+}
+
+dependencies {
+ implementation "androidx.core:core:1.9.0"
+ implementation "androidx.browser:browser:1.5.0"
+ implementation "androidx.security:security-crypto:1.1.0-alpha06"
+ implementation "androidx.work:work-runtime:2.8.1"
+ implementation ':ipn@aar'
+ testImplementation "junit:junit:4.12"
+
+ // Non-free dependencies.
+ playImplementation 'com.google.android.gms:play-services-auth:20.7.0'
+}
diff --git a/android_legacy/gradle.properties b/android_legacy/gradle.properties
new file mode 100644
index 0000000..eb5eece
--- /dev/null
+++ b/android_legacy/gradle.properties
@@ -0,0 +1,5 @@
+android.defaults.buildfeatures.buildconfig=true
+android.nonFinalResIds=false
+android.nonTransitiveRClass=false
+android.useAndroidX=true
+org.gradle.jvmargs=-Xmx2g -XX:MaxMetaspaceSize=512m
diff --git a/android_legacy/gradle/wrapper/gradle-wrapper.jar b/android_legacy/gradle/wrapper/gradle-wrapper.jar
new file mode 100644
index 0000000..490fda8
Binary files /dev/null and b/android_legacy/gradle/wrapper/gradle-wrapper.jar differ
diff --git a/android_legacy/gradle/wrapper/gradle-wrapper.properties b/android_legacy/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 0000000..dd1d9c0
--- /dev/null
+++ b/android_legacy/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,6 @@
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+distributionSha256Sum=38f66cd6eef217b4c35855bb11ea4e9fbc53594ccccb5fb82dfd317ef8c2c5a3
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.2-bin.zip
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
diff --git a/android_legacy/gradlew b/android_legacy/gradlew
new file mode 100755
index 0000000..3368417
--- /dev/null
+++ b/android_legacy/gradlew
@@ -0,0 +1,183 @@
+#!/usr/bin/env sh
+
+#
+# Copyright 2015 the original author or authors.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+
+##############################################################################
+##
+## Gradle start up script for UN*X
+##
+##############################################################################
+
+# Attempt to set APP_HOME
+# Resolve links: $0 may be a link
+PRG="$0"
+# Need this for relative symlinks.
+while [ -h "$PRG" ] ; do
+ ls=`ls -ld "$PRG"`
+ link=`expr "$ls" : '.*-> \(.*\)$'`
+ if expr "$link" : '/.*' > /dev/null; then
+ PRG="$link"
+ else
+ PRG=`dirname "$PRG"`"/$link"
+ fi
+done
+SAVED="`pwd`"
+cd "`dirname \"$PRG\"`/" >/dev/null
+APP_HOME="`pwd -P`"
+cd "$SAVED" >/dev/null
+
+APP_NAME="Gradle"
+APP_BASE_NAME=`basename "$0"`
+
+# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+DEFAULT_JVM_OPTS='"-Xmx80m" "-Xms80m"'
+
+# Use the maximum available, or set MAX_FD != -1 to use that value.
+MAX_FD="maximum"
+
+warn () {
+ echo "$*"
+}
+
+die () {
+ echo
+ echo "$*"
+ echo
+ exit 1
+}
+
+# OS specific support (must be 'true' or 'false').
+cygwin=false
+msys=false
+darwin=false
+nonstop=false
+case "`uname`" in
+ CYGWIN* )
+ cygwin=true
+ ;;
+ Darwin* )
+ darwin=true
+ ;;
+ MINGW* )
+ msys=true
+ ;;
+ NONSTOP* )
+ nonstop=true
+ ;;
+esac
+
+CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
+
+# Determine the Java command to use to start the JVM.
+if [ -n "$JAVA_HOME" ] ; then
+ if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
+ # IBM's JDK on AIX uses strange locations for the executables
+ JAVACMD="$JAVA_HOME/jre/sh/java"
+ else
+ JAVACMD="$JAVA_HOME/bin/java"
+ fi
+ if [ ! -x "$JAVACMD" ] ; then
+ die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+else
+ JAVACMD="java"
+ which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+fi
+
+# Increase the maximum file descriptors if we can.
+if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
+ MAX_FD_LIMIT=`ulimit -H -n`
+ if [ $? -eq 0 ] ; then
+ if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
+ MAX_FD="$MAX_FD_LIMIT"
+ fi
+ ulimit -n $MAX_FD
+ if [ $? -ne 0 ] ; then
+ warn "Could not set maximum file descriptor limit: $MAX_FD"
+ fi
+ else
+ warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
+ fi
+fi
+
+# For Darwin, add options to specify how the application appears in the dock
+if $darwin; then
+ GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
+fi
+
+# For Cygwin or MSYS, switch paths to Windows format before running java
+if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then
+ APP_HOME=`cygpath --path --mixed "$APP_HOME"`
+ CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
+ JAVACMD=`cygpath --unix "$JAVACMD"`
+
+ # We build the pattern for arguments to be converted via cygpath
+ ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
+ SEP=""
+ for dir in $ROOTDIRSRAW ; do
+ ROOTDIRS="$ROOTDIRS$SEP$dir"
+ SEP="|"
+ done
+ OURCYGPATTERN="(^($ROOTDIRS))"
+ # Add a user-defined pattern to the cygpath arguments
+ if [ "$GRADLE_CYGPATTERN" != "" ] ; then
+ OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
+ fi
+ # Now convert the arguments - kludge to limit ourselves to /bin/sh
+ i=0
+ for arg in "$@" ; do
+ CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
+ CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
+
+ if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
+ eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
+ else
+ eval `echo args$i`="\"$arg\""
+ fi
+ i=`expr $i + 1`
+ done
+ case $i in
+ 0) set -- ;;
+ 1) set -- "$args0" ;;
+ 2) set -- "$args0" "$args1" ;;
+ 3) set -- "$args0" "$args1" "$args2" ;;
+ 4) set -- "$args0" "$args1" "$args2" "$args3" ;;
+ 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
+ 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
+ 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
+ 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
+ 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
+ esac
+fi
+
+# Escape application args
+save () {
+ for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
+ echo " "
+}
+APP_ARGS=`save "$@"`
+
+# Collect all arguments for the java command, following the shell quoting and substitution rules
+eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
+
+exec "$JAVACMD" "$@"
diff --git a/android_legacy/gradlew.bat b/android_legacy/gradlew.bat
new file mode 100644
index 0000000..22fc358
--- /dev/null
+++ b/android_legacy/gradlew.bat
@@ -0,0 +1,103 @@
+@rem
+@rem Copyright 2015 the original author or authors.
+@rem
+@rem Licensed under the Apache License, Version 2.0 (the "License");
+@rem you may not use this file except in compliance with the License.
+@rem You may obtain a copy of the License at
+@rem
+@rem https://www.apache.org/licenses/LICENSE-2.0
+@rem
+@rem Unless required by applicable law or agreed to in writing, software
+@rem distributed under the License is distributed on an "AS IS" BASIS,
+@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+@rem See the License for the specific language governing permissions and
+@rem limitations under the License.
+@rem
+
+@if "%DEBUG%" == "" @echo off
+@rem ##########################################################################
+@rem
+@rem Gradle startup script for Windows
+@rem
+@rem ##########################################################################
+
+@rem Set local scope for the variables with windows NT shell
+if "%OS%"=="Windows_NT" setlocal
+
+set DIRNAME=%~dp0
+if "%DIRNAME%" == "" set DIRNAME=.
+set APP_BASE_NAME=%~n0
+set APP_HOME=%DIRNAME%
+
+@rem Resolve any "." and ".." in APP_HOME to make it shorter.
+for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
+
+@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+set DEFAULT_JVM_OPTS="-Xmx80m" "-Xms80m"
+
+@rem Find java.exe
+if defined JAVA_HOME goto findJavaFromJavaHome
+
+set JAVA_EXE=java.exe
+%JAVA_EXE% -version >NUL 2>&1
+if "%ERRORLEVEL%" == "0" goto init
+
+echo.
+echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+echo.
+echo Please set the JAVA_HOME variable in your environment to match the
+echo location of your Java installation.
+
+goto fail
+
+:findJavaFromJavaHome
+set JAVA_HOME=%JAVA_HOME:"=%
+set JAVA_EXE=%JAVA_HOME%/bin/java.exe
+
+if exist "%JAVA_EXE%" goto init
+
+echo.
+echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
+echo.
+echo Please set the JAVA_HOME variable in your environment to match the
+echo location of your Java installation.
+
+goto fail
+
+:init
+@rem Get command-line arguments, handling Windows variants
+
+if not "%OS%" == "Windows_NT" goto win9xME_args
+
+:win9xME_args
+@rem Slurp the command line arguments.
+set CMD_LINE_ARGS=
+set _SKIP=2
+
+:win9xME_args_slurp
+if "x%~1" == "x" goto execute
+
+set CMD_LINE_ARGS=%*
+
+:execute
+@rem Setup the command line
+
+set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
+
+@rem Execute Gradle
+"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS%
+
+:end
+@rem End local scope for the variables with windows NT shell
+if "%ERRORLEVEL%"=="0" goto mainEnd
+
+:fail
+rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
+rem the _cmd.exe /c_ return code!
+if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
+exit /b 1
+
+:mainEnd
+if "%OS%"=="Windows_NT" endlocal
+
+:omega
diff --git a/android_legacy/libs/ipn.aar b/android_legacy/libs/ipn.aar
new file mode 100644
index 0000000..f5bb72d
Binary files /dev/null and b/android_legacy/libs/ipn.aar differ
diff --git a/android_legacy/src/main/AndroidManifest.xml b/android_legacy/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..8523e9d
--- /dev/null
+++ b/android_legacy/src/main/AndroidManifest.xml
@@ -0,0 +1,85 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/android_legacy/src/main/ic_launcher-playstore.png b/android_legacy/src/main/ic_launcher-playstore.png
new file mode 100644
index 0000000..bae6d31
Binary files /dev/null and b/android_legacy/src/main/ic_launcher-playstore.png differ
diff --git a/android_legacy/src/main/java/com/tailscale/ipn/App.java b/android_legacy/src/main/java/com/tailscale/ipn/App.java
new file mode 100644
index 0000000..d44efaf
--- /dev/null
+++ b/android_legacy/src/main/java/com/tailscale/ipn/App.java
@@ -0,0 +1,417 @@
+// Copyright (c) 2020 Tailscale Inc & AUTHORS All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package com.tailscale.ipn;
+
+import android.app.Application;
+import android.app.Activity;
+import android.app.DownloadManager;
+import android.app.Fragment;
+import android.app.FragmentTransaction;
+import android.app.NotificationChannel;
+import android.app.PendingIntent;
+import android.app.UiModeManager;
+import android.content.BroadcastReceiver;
+import android.content.ContentResolver;
+import android.content.ContentValues;
+import android.content.Context;
+import android.content.Intent;
+import android.content.IntentFilter;
+import android.content.SharedPreferences;
+import android.content.pm.PackageManager;
+import android.content.pm.PackageInfo;
+import android.content.pm.Signature;
+import android.content.res.Configuration;
+import android.provider.MediaStore;
+import android.provider.Settings;
+import android.net.ConnectivityManager;
+import android.net.LinkProperties;
+import android.net.Network;
+import android.net.NetworkInfo;
+import android.net.NetworkRequest;
+import android.net.Uri;
+import android.net.VpnService;
+import android.view.View;
+import android.os.Build;
+import android.os.Environment;
+import android.os.Handler;
+import android.os.Looper;
+
+import android.Manifest;
+import android.webkit.MimeTypeMap;
+
+import java.io.IOException;
+import java.io.File;
+import java.io.FileOutputStream;
+
+import java.lang.StringBuilder;
+
+import java.net.InetAddress;
+import java.net.InterfaceAddress;
+import java.net.NetworkInterface;
+
+import java.security.GeneralSecurityException;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Locale;
+
+import androidx.core.app.NotificationCompat;
+import androidx.core.app.NotificationManagerCompat;
+
+import androidx.core.content.ContextCompat;
+
+import androidx.security.crypto.EncryptedSharedPreferences;
+import androidx.security.crypto.MasterKey;
+
+import androidx.browser.customtabs.CustomTabsIntent;
+
+import org.gioui.Gio;
+
+public class App extends Application {
+ private static final String PEER_TAG = "peer";
+
+ static final String STATUS_CHANNEL_ID = "tailscale-status";
+ static final int STATUS_NOTIFICATION_ID = 1;
+
+ static final String NOTIFY_CHANNEL_ID = "tailscale-notify";
+ static final int NOTIFY_NOTIFICATION_ID = 2;
+
+ private static final String FILE_CHANNEL_ID = "tailscale-files";
+ private static final int FILE_NOTIFICATION_ID = 3;
+
+ private static final Handler mainHandler = new Handler(Looper.getMainLooper());
+
+ private ConnectivityManager connectivityManager;
+ public DnsConfig dns = new DnsConfig();
+ public DnsConfig getDnsConfigObj() { return this.dns; }
+
+ @Override public void onCreate() {
+ super.onCreate();
+ // Load and initialize the Go library.
+ Gio.init(this);
+
+ this.connectivityManager = (ConnectivityManager) this.getSystemService(Context.CONNECTIVITY_SERVICE);
+ setAndRegisterNetworkCallbacks();
+
+ createNotificationChannel(NOTIFY_CHANNEL_ID, "Notifications", NotificationManagerCompat.IMPORTANCE_DEFAULT);
+ createNotificationChannel(STATUS_CHANNEL_ID, "VPN Status", NotificationManagerCompat.IMPORTANCE_LOW);
+ createNotificationChannel(FILE_CHANNEL_ID, "File transfers", NotificationManagerCompat.IMPORTANCE_DEFAULT);
+ }
+
+ // requestNetwork attempts to find the best network that matches the passed NetworkRequest. It is possible that
+ // this might return an unusuable network, eg a captive portal.
+ private void setAndRegisterNetworkCallbacks() {
+ connectivityManager.requestNetwork(dns.getDNSConfigNetworkRequest(), new ConnectivityManager.NetworkCallback(){
+ @Override
+ public void onAvailable(Network network){
+ super.onAvailable(network);
+ StringBuilder sb = new StringBuilder("");
+ LinkProperties linkProperties = connectivityManager.getLinkProperties(network);
+ List dnsList = linkProperties.getDnsServers();
+ for (InetAddress ip : dnsList) {
+ sb.append(ip.getHostAddress()).append(" ");
+ }
+ String searchDomains = linkProperties.getDomains();
+ if (searchDomains != null) {
+ sb.append("\n");
+ sb.append(searchDomains);
+ }
+
+ dns.updateDNSFromNetwork(sb.toString());
+ onDnsConfigChanged();
+ }
+
+ @Override
+ public void onLost(Network network) {
+ super.onLost(network);
+ onDnsConfigChanged();
+ }
+ });
+ }
+
+ public void startVPN() {
+ Intent intent = new Intent(this, IPNService.class);
+ intent.setAction(IPNService.ACTION_REQUEST_VPN);
+ startService(intent);
+ }
+
+ public void stopVPN() {
+ Intent intent = new Intent(this, IPNService.class);
+ intent.setAction(IPNService.ACTION_STOP_VPN);
+ startService(intent);
+ }
+
+ // encryptToPref a byte array of data using the Jetpack Security
+ // library and writes it to a global encrypted preference store.
+ public void encryptToPref(String prefKey, String plaintext) throws IOException, GeneralSecurityException {
+ getEncryptedPrefs().edit().putString(prefKey, plaintext).commit();
+ }
+
+ // decryptFromPref decrypts a encrypted preference using the Jetpack Security
+ // library and returns the plaintext.
+ public String decryptFromPref(String prefKey) throws IOException, GeneralSecurityException {
+ return getEncryptedPrefs().getString(prefKey, null);
+ }
+
+ private SharedPreferences getEncryptedPrefs() throws IOException, GeneralSecurityException {
+ MasterKey key = new MasterKey.Builder(this)
+ .setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
+ .build();
+
+ return EncryptedSharedPreferences.create(
+ this,
+ "secret_shared_prefs",
+ key,
+ EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
+ EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
+ );
+ }
+
+ public boolean autoConnect = false;
+ public boolean vpnReady = false;
+
+ void setTileReady(boolean ready) {
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) {
+ return;
+ }
+ QuickToggleService.setReady(this, ready);
+ android.util.Log.d("App", "Set Tile Ready: " + ready + " " + autoConnect);
+
+ vpnReady = ready;
+ if (ready && autoConnect) {
+ startVPN();
+ }
+ }
+
+ void setTileStatus(boolean status) {
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) {
+ return;
+ }
+ QuickToggleService.setStatus(this, status);
+ }
+
+ String getHostname() {
+ String userConfiguredDeviceName = getUserConfiguredDeviceName();
+ if (!isEmpty(userConfiguredDeviceName)) return userConfiguredDeviceName;
+
+ return getModelName();
+ }
+
+ String getModelName() {
+ String manu = Build.MANUFACTURER;
+ String model = Build.MODEL;
+ // Strip manufacturer from model.
+ int idx = model.toLowerCase().indexOf(manu.toLowerCase());
+ if (idx != -1) {
+ model = model.substring(idx + manu.length());
+ model = model.trim();
+ }
+ return manu + " " + model;
+ }
+
+ String getOSVersion() {
+ return Build.VERSION.RELEASE;
+ }
+
+ // get user defined nickname from Settings
+ // returns null if not available
+ private String getUserConfiguredDeviceName() {
+ String nameFromSystemDevice = Settings.Secure.getString(getContentResolver(), "device_name");
+ if (!isEmpty(nameFromSystemDevice)) return nameFromSystemDevice;
+ return null;
+ }
+
+ private static boolean isEmpty(String str) {
+ return str == null || str.length() == 0;
+ }
+
+ // attachPeer adds a Peer fragment for tracking the Activity
+ // lifecycle.
+ void attachPeer(Activity act) {
+ act.runOnUiThread(new Runnable() {
+ @Override public void run() {
+ FragmentTransaction ft = act.getFragmentManager().beginTransaction();
+ ft.add(new Peer(), PEER_TAG);
+ ft.commit();
+ act.getFragmentManager().executePendingTransactions();
+ }
+ });
+ }
+
+ boolean isChromeOS() {
+ return getPackageManager().hasSystemFeature("android.hardware.type.pc");
+ }
+
+ void prepareVPN(Activity act, int reqCode) {
+ act.runOnUiThread(new Runnable() {
+ @Override public void run() {
+ Intent intent = VpnService.prepare(act);
+ if (intent == null) {
+ onVPNPrepared();
+ } else {
+ startActivityForResult(act, intent, reqCode);
+ }
+ }
+ });
+ }
+
+ static void startActivityForResult(Activity act, Intent intent, int request) {
+ Fragment f = act.getFragmentManager().findFragmentByTag(PEER_TAG);
+ f.startActivityForResult(intent, request);
+ }
+
+ void showURL(Activity act, String url) {
+ act.runOnUiThread(new Runnable() {
+ @Override public void run() {
+ CustomTabsIntent.Builder builder = new CustomTabsIntent.Builder();
+ int headerColor = 0xff496495;
+ builder.setToolbarColor(headerColor);
+ CustomTabsIntent intent = builder.build();
+ intent.launchUrl(act, Uri.parse(url));
+ }
+ });
+ }
+
+ // getPackageSignatureFingerprint returns the first package signing certificate, if any.
+ byte[] getPackageCertificate() throws Exception {
+ PackageInfo info;
+ info = getPackageManager().getPackageInfo(getPackageName(), PackageManager.GET_SIGNATURES);
+ for (Signature signature : info.signatures) {
+ return signature.toByteArray();
+ }
+ return null;
+ }
+
+ void requestWriteStoragePermission(Activity act) {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q || Build.VERSION.SDK_INT < Build.VERSION_CODES.M) {
+ // We can write files without permission.
+ return;
+ }
+ if (ContextCompat.checkSelfPermission(act, Manifest.permission.WRITE_EXTERNAL_STORAGE) == PackageManager.PERMISSION_GRANTED) {
+ return;
+ }
+ act.requestPermissions(new String[]{Manifest.permission.WRITE_EXTERNAL_STORAGE}, IPNActivity.WRITE_STORAGE_RESULT);
+ }
+
+ String insertMedia(String name, String mimeType) throws IOException {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ ContentResolver resolver = getContentResolver();
+ ContentValues contentValues = new ContentValues();
+ contentValues.put(MediaStore.MediaColumns.DISPLAY_NAME, name);
+ if (!"".equals(mimeType)) {
+ contentValues.put(MediaStore.MediaColumns.MIME_TYPE, mimeType);
+ }
+ Uri root = MediaStore.Files.getContentUri("external");
+ return resolver.insert(root, contentValues).toString();
+ } else {
+ File dir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS);
+ dir.mkdirs();
+ File f = new File(dir, name);
+ return Uri.fromFile(f).toString();
+ }
+ }
+
+ int openUri(String uri, String mode) throws IOException {
+ ContentResolver resolver = getContentResolver();
+ return resolver.openFileDescriptor(Uri.parse(uri), mode).detachFd();
+ }
+
+ void deleteUri(String uri) {
+ ContentResolver resolver = getContentResolver();
+ resolver.delete(Uri.parse(uri), null, null);
+ }
+
+ public void notifyFile(String uri, String msg) {
+ Intent viewIntent;
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ viewIntent = new Intent(Intent.ACTION_VIEW, Uri.parse(uri));
+ } else {
+ // uri is a file:// which is not allowed to be shared outside the app.
+ viewIntent = new Intent(DownloadManager.ACTION_VIEW_DOWNLOADS);
+ }
+ PendingIntent pending = PendingIntent.getActivity(this, 0, viewIntent, PendingIntent.FLAG_UPDATE_CURRENT);
+ NotificationCompat.Builder builder = new NotificationCompat.Builder(this, FILE_CHANNEL_ID)
+ .setSmallIcon(R.drawable.ic_notification)
+ .setContentTitle("File received")
+ .setContentText(msg)
+ .setContentIntent(pending)
+ .setAutoCancel(true)
+ .setOnlyAlertOnce(true)
+ .setPriority(NotificationCompat.PRIORITY_DEFAULT);
+
+ NotificationManagerCompat nm = NotificationManagerCompat.from(this);
+ nm.notify(FILE_NOTIFICATION_ID, builder.build());
+ }
+
+ public void createNotificationChannel(String id, String name, int importance) {
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
+ return;
+ }
+ NotificationChannel channel = new NotificationChannel(id, name, importance);
+ NotificationManagerCompat nm = NotificationManagerCompat.from(this);
+ nm.createNotificationChannel(channel);
+ }
+
+ static native void onVPNPrepared();
+ private static native void onDnsConfigChanged();
+ static native void onShareIntent(int nfiles, int[] types, String[] mimes, String[] items, String[] names, long[] sizes);
+ static native void onWriteStorageGranted();
+
+ // Returns details of the interfaces in the system, encoded as a single string for ease
+ // of JNI transfer over to the Go environment.
+ //
+ // Example:
+ // rmnet_data0 10 2000 true false false false false | fe80::4059:dc16:7ed3:9c6e%rmnet_data0/64
+ // dummy0 3 1500 true false false false false | fe80::1450:5cff:fe13:f891%dummy0/64
+ // wlan0 30 1500 true true false false true | fe80::2f60:2c82:4163:8389%wlan0/64 10.1.10.131/24
+ // r_rmnet_data0 21 1500 true false false false false | fe80::9318:6093:d1ad:ba7f%r_rmnet_data0/64
+ // rmnet_data2 12 1500 true false false false false | fe80::3c8c:44dc:46a9:9907%rmnet_data2/64
+ // r_rmnet_data1 22 1500 true false false false false | fe80::b6cd:5cb0:8ae6:fe92%r_rmnet_data1/64
+ // rmnet_data1 11 1500 true false false false false | fe80::51f2:ee00:edce:d68b%rmnet_data1/64
+ // lo 1 65536 true false true false false | ::1/128 127.0.0.1/8
+ // v4-rmnet_data2 68 1472 true true false true true | 192.0.0.4/32
+ //
+ // Where the fields are:
+ // name ifindex mtu isUp hasBroadcast isLoopback isPointToPoint hasMulticast | ip1/N ip2/N ip3/N;
+ String getInterfacesAsString() {
+ List interfaces;
+ try {
+ interfaces = Collections.list(NetworkInterface.getNetworkInterfaces());
+ } catch (Exception e) {
+ return "";
+ }
+
+ StringBuilder sb = new StringBuilder("");
+ for (NetworkInterface nif : interfaces) {
+ try {
+ // Android doesn't have a supportsBroadcast() but the Go net.Interface wants
+ // one, so we say the interface has broadcast if it has multicast.
+ sb.append(String.format(java.util.Locale.ROOT, "%s %d %d %b %b %b %b %b |", nif.getName(),
+ nif.getIndex(), nif.getMTU(), nif.isUp(), nif.supportsMulticast(),
+ nif.isLoopback(), nif.isPointToPoint(), nif.supportsMulticast()));
+
+ for (InterfaceAddress ia : nif.getInterfaceAddresses()) {
+ // InterfaceAddress == hostname + "/" + IP
+ String[] parts = ia.toString().split("/", 0);
+ if (parts.length > 1) {
+ sb.append(String.format(java.util.Locale.ROOT, "%s/%d ", parts[1], ia.getNetworkPrefixLength()));
+ }
+ }
+ } catch (Exception e) {
+ // TODO(dgentry) should log the exception not silently suppress it.
+ continue;
+ }
+ sb.append("\n");
+ }
+
+ return sb.toString();
+ }
+
+ boolean isTV() {
+ UiModeManager mm = (UiModeManager)getSystemService(UI_MODE_SERVICE);
+ return mm.getCurrentModeType() == Configuration.UI_MODE_TYPE_TELEVISION;
+ }
+}
diff --git a/android_legacy/src/main/java/com/tailscale/ipn/DnsConfig.java b/android_legacy/src/main/java/com/tailscale/ipn/DnsConfig.java
new file mode 100644
index 0000000..dfe6e8c
--- /dev/null
+++ b/android_legacy/src/main/java/com/tailscale/ipn/DnsConfig.java
@@ -0,0 +1,64 @@
+// Copyright (c) 2021 Tailscale Inc & AUTHORS All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package com.tailscale.ipn;
+
+import android.net.NetworkCapabilities;
+import android.net.NetworkRequest;
+
+import java.lang.reflect.Method;
+
+import java.net.InetAddress;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Locale;
+import java.util.concurrent.locks.Lock;
+import java.util.concurrent.locks.ReentrantLock;
+
+// Tailscale DNS Config retrieval
+//
+// Tailscale's DNS support can either override the local DNS servers with a set of servers
+// configured in the admin panel, or supplement the local DNS servers with additional
+// servers for specific domains like example.com.beta.tailscale.net. In the non-override mode,
+// we need to retrieve the current set of DNS servers from the platform. These will typically
+// be the DNS servers received from DHCP.
+//
+// Importantly, after the Tailscale VPN comes up it will set a DNS server of 100.100.100.100
+// but we still want to retrieve the underlying DNS servers received from DHCP. If we roam
+// from Wi-Fi to LTE, we want the DNS servers received from LTE.
+
+public class DnsConfig {
+ private String dnsConfigs;
+
+ // getDnsConfigAsString returns the current DNS configuration as a multiline string:
+ // line[0] DNS server addresses separated by spaces
+ // line[1] search domains separated by spaces
+ //
+ // For example:
+ // 8.8.8.8 8.8.4.4
+ // example.com
+ //
+ // an empty string means the current DNS configuration could not be retrieved.
+ String getDnsConfigAsString() {
+ return getDnsConfigs().trim();
+ }
+
+ private String getDnsConfigs(){
+ synchronized(this) {
+ return this.dnsConfigs;
+ }
+ }
+
+ void updateDNSFromNetwork(String dnsConfigs){
+ synchronized(this) {
+ this.dnsConfigs = dnsConfigs;
+ }
+ }
+
+ NetworkRequest getDNSConfigNetworkRequest(){
+ // Request networks that are able to reach the Internet.
+ return new NetworkRequest.Builder().addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET).build();
+ }
+}
diff --git a/android_legacy/src/main/java/com/tailscale/ipn/IPNActivity.java b/android_legacy/src/main/java/com/tailscale/ipn/IPNActivity.java
new file mode 100644
index 0000000..340697d
--- /dev/null
+++ b/android_legacy/src/main/java/com/tailscale/ipn/IPNActivity.java
@@ -0,0 +1,133 @@
+// Copyright (c) 2021 Tailscale Inc & AUTHORS All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package com.tailscale.ipn;
+
+import android.app.Activity;
+import android.content.res.AssetFileDescriptor;
+import android.content.res.Configuration;
+import android.content.Intent;
+import android.database.Cursor;
+import android.os.Bundle;
+import android.provider.OpenableColumns;
+import android.net.Uri;
+import android.content.pm.PackageManager;
+
+import java.util.List;
+import java.util.ArrayList;
+
+import org.gioui.GioView;
+
+public final class IPNActivity extends Activity {
+ final static int WRITE_STORAGE_RESULT = 1000;
+
+ private GioView view;
+
+ @Override public void onCreate(Bundle state) {
+ super.onCreate(state);
+ view = new GioView(this);
+ setContentView(view);
+ handleIntent();
+ }
+
+ @Override public void onNewIntent(Intent i) {
+ setIntent(i);
+ handleIntent();
+ }
+
+ private void handleIntent() {
+ Intent it = getIntent();
+ String act = it.getAction();
+ String[] texts;
+ Uri[] uris;
+ if (Intent.ACTION_SEND.equals(act)) {
+ uris = new Uri[]{it.getParcelableExtra(Intent.EXTRA_STREAM)};
+ texts = new String[]{it.getStringExtra(Intent.EXTRA_TEXT)};
+ } else if (Intent.ACTION_SEND_MULTIPLE.equals(act)) {
+ List extraUris = it.getParcelableArrayListExtra(Intent.EXTRA_STREAM);
+ uris = extraUris.toArray(new Uri[0]);
+ texts = new String[uris.length];
+ } else {
+ return;
+ }
+ String mime = it.getType();
+ int nitems = uris.length;
+ String[] items = new String[nitems];
+ String[] mimes = new String[nitems];
+ int[] types = new int[nitems];
+ String[] names = new String[nitems];
+ long[] sizes = new long[nitems];
+ int nfiles = 0;
+ for (int i = 0; i < uris.length; i++) {
+ String text = texts[i];
+ Uri uri = uris[i];
+ if (text != null) {
+ types[nfiles] = 1; // FileTypeText
+ names[nfiles] = "file.txt";
+ mimes[nfiles] = mime;
+ items[nfiles] = text;
+ // Determined by len(text) in Go to eliminate UTF-8 encoding differences.
+ sizes[nfiles] = 0;
+ nfiles++;
+ } else if (uri != null) {
+ Cursor c = getContentResolver().query(uri, null, null, null, null);
+ if (c == null) {
+ // Ignore files we have no permission to access.
+ continue;
+ }
+ int nameCol = c.getColumnIndex(OpenableColumns.DISPLAY_NAME);
+ int sizeCol = c.getColumnIndex(OpenableColumns.SIZE);
+ c.moveToFirst();
+ String name = c.getString(nameCol);
+ long size = c.getLong(sizeCol);
+ types[nfiles] = 2; // FileTypeURI
+ mimes[nfiles] = mime;
+ items[nfiles] = uri.toString();
+ names[nfiles] = name;
+ sizes[nfiles] = size;
+ nfiles++;
+ }
+ }
+ App.onShareIntent(nfiles, types, mimes, items, names, sizes);
+ }
+
+ @Override public void onRequestPermissionsResult(int reqCode, String[] perms, int[] grants) {
+ switch (reqCode) {
+ case WRITE_STORAGE_RESULT:
+ if (grants.length > 0 && grants[0] == PackageManager.PERMISSION_GRANTED) {
+ App.onWriteStorageGranted();
+ }
+ }
+ }
+
+ @Override public void onDestroy() {
+ view.destroy();
+ super.onDestroy();
+ }
+
+ @Override public void onStart() {
+ super.onStart();
+ view.start();
+ }
+
+ @Override public void onStop() {
+ view.stop();
+ super.onStop();
+ }
+
+ @Override public void onConfigurationChanged(Configuration c) {
+ super.onConfigurationChanged(c);
+ view.configurationChanged();
+ }
+
+ @Override public void onLowMemory() {
+ super.onLowMemory();
+ view.onLowMemory();
+ }
+
+ @Override public void onBackPressed() {
+ if (!view.backPressed())
+ super.onBackPressed();
+ }
+}
diff --git a/android_legacy/src/main/java/com/tailscale/ipn/IPNReceiver.java b/android_legacy/src/main/java/com/tailscale/ipn/IPNReceiver.java
new file mode 100644
index 0000000..ea6cb75
--- /dev/null
+++ b/android_legacy/src/main/java/com/tailscale/ipn/IPNReceiver.java
@@ -0,0 +1,29 @@
+// Copyright (c) 2023 Tailscale Inc & AUTHORS All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package com.tailscale.ipn;
+
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+import androidx.work.WorkManager;
+import androidx.work.OneTimeWorkRequest;
+
+public class IPNReceiver extends BroadcastReceiver {
+
+ public static final String INTENT_CONNECT_VPN = "com.tailscale.ipn.CONNECT_VPN";
+ public static final String INTENT_DISCONNECT_VPN = "com.tailscale.ipn.DISCONNECT_VPN";
+
+ @Override
+ public void onReceive(Context context, Intent intent) {
+ WorkManager workManager = WorkManager.getInstance(context);
+
+ // On the relevant action, start the relevant worker, which can stay active for longer than this receiver can.
+ if (intent.getAction() == INTENT_CONNECT_VPN) {
+ workManager.enqueue(new OneTimeWorkRequest.Builder(StartVPNWorker.class).build());
+ } else if (intent.getAction() == INTENT_DISCONNECT_VPN) {
+ workManager.enqueue(new OneTimeWorkRequest.Builder(StopVPNWorker.class).build());
+ }
+ }
+}
diff --git a/android_legacy/src/main/java/com/tailscale/ipn/IPNService.java b/android_legacy/src/main/java/com/tailscale/ipn/IPNService.java
new file mode 100644
index 0000000..fee5b9e
--- /dev/null
+++ b/android_legacy/src/main/java/com/tailscale/ipn/IPNService.java
@@ -0,0 +1,138 @@
+// Copyright (c) 2020 Tailscale Inc & AUTHORS All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package com.tailscale.ipn;
+
+import android.os.Build;
+import android.app.PendingIntent;
+import android.content.Intent;
+import android.content.pm.PackageManager;
+import android.net.VpnService;
+import android.system.OsConstants;
+import androidx.work.WorkManager;
+import androidx.work.OneTimeWorkRequest;
+
+import org.gioui.GioActivity;
+
+import androidx.core.app.NotificationCompat;
+import androidx.core.app.NotificationManagerCompat;
+
+public class IPNService extends VpnService {
+ public static final String ACTION_REQUEST_VPN = "com.tailscale.ipn.REQUEST_VPN";
+ public static final String ACTION_STOP_VPN = "com.tailscale.ipn.STOP_VPN";
+
+ @Override public int onStartCommand(Intent intent, int flags, int startId) {
+ if (intent != null && ACTION_STOP_VPN.equals(intent.getAction())) {
+ ((App)getApplicationContext()).autoConnect = false;
+ close();
+ return START_NOT_STICKY;
+ }
+ if (intent != null && "android.net.VpnService".equals(intent.getAction())) {
+ // Start VPN and connect to it due to Always-on VPN
+ Intent i = new Intent(IPNReceiver.INTENT_CONNECT_VPN);
+ i.setPackage(getPackageName());
+ i.setClass(getApplicationContext(), com.tailscale.ipn.IPNReceiver.class);
+ sendBroadcast(i);
+ requestVPN();
+ connect();
+ return START_STICKY;
+ }
+ requestVPN();
+ App app = ((App)getApplicationContext());
+ if (app.vpnReady && app.autoConnect) {
+ connect();
+ }
+ return START_STICKY;
+ }
+
+ private void close() {
+ stopForeground(true);
+ disconnect();
+ }
+
+ @Override public void onDestroy() {
+ close();
+ super.onDestroy();
+ }
+
+ @Override public void onRevoke() {
+ close();
+ super.onRevoke();
+ }
+
+ private PendingIntent configIntent() {
+ return PendingIntent.getActivity(this, 0, new Intent(this, IPNActivity.class),
+ PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE);
+ }
+
+ private void disallowApp(VpnService.Builder b, String name) {
+ try {
+ b.addDisallowedApplication(name);
+ } catch (PackageManager.NameNotFoundException e) {
+ return;
+ }
+ }
+
+ protected VpnService.Builder newBuilder() {
+ VpnService.Builder b = new VpnService.Builder()
+ .setConfigureIntent(configIntent())
+ .allowFamily(OsConstants.AF_INET)
+ .allowFamily(OsConstants.AF_INET6);
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q)
+ b.setMetered(false); // Inherit the metered status from the underlying networks.
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M)
+ b.setUnderlyingNetworks(null); // Use all available networks.
+
+ // RCS/Jibe https://github.com/tailscale/tailscale/issues/2322
+ this.disallowApp(b, "com.google.android.apps.messaging");
+
+ // Stadia https://github.com/tailscale/tailscale/issues/3460
+ this.disallowApp(b, "com.google.stadia.android");
+
+ // Android Auto https://github.com/tailscale/tailscale/issues/3828
+ this.disallowApp(b, "com.google.android.projection.gearhead");
+
+ // GoPro https://github.com/tailscale/tailscale/issues/2554
+ this.disallowApp(b, "com.gopro.smarty");
+
+ // Sonos https://github.com/tailscale/tailscale/issues/2548
+ this.disallowApp(b, "com.sonos.acr");
+ this.disallowApp(b, "com.sonos.acr2");
+
+ // Google Chromecast https://github.com/tailscale/tailscale/issues/3636
+ this.disallowApp(b, "com.google.android.apps.chromecast.app");
+
+ return b;
+ }
+
+ public void notify(String title, String message) {
+ NotificationCompat.Builder builder = new NotificationCompat.Builder(this, App.NOTIFY_CHANNEL_ID)
+ .setSmallIcon(R.drawable.ic_notification)
+ .setContentTitle(title)
+ .setContentText(message)
+ .setContentIntent(configIntent())
+ .setAutoCancel(true)
+ .setOnlyAlertOnce(true)
+ .setPriority(NotificationCompat.PRIORITY_DEFAULT);
+
+ NotificationManagerCompat nm = NotificationManagerCompat.from(this);
+ nm.notify(App.NOTIFY_NOTIFICATION_ID, builder.build());
+ }
+
+ public void updateStatusNotification(String title, String message) {
+ NotificationCompat.Builder builder = new NotificationCompat.Builder(this, App.STATUS_CHANNEL_ID)
+ .setSmallIcon(R.drawable.ic_notification)
+ .setContentTitle(title)
+ .setContentText(message)
+ .setContentIntent(configIntent())
+ .setPriority(NotificationCompat.PRIORITY_LOW);
+
+ startForeground(App.STATUS_NOTIFICATION_ID, builder.build());
+ }
+
+ private native void requestVPN();
+
+ private native void disconnect();
+ private native void connect();
+}
diff --git a/android_legacy/src/main/java/com/tailscale/ipn/Peer.java b/android_legacy/src/main/java/com/tailscale/ipn/Peer.java
new file mode 100644
index 0000000..22cf9c9
--- /dev/null
+++ b/android_legacy/src/main/java/com/tailscale/ipn/Peer.java
@@ -0,0 +1,17 @@
+// Copyright (c) 2020 Tailscale Inc & AUTHORS All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package com.tailscale.ipn;
+
+import android.app.Activity;
+import android.app.Fragment;
+import android.content.Intent;
+
+public class Peer extends Fragment {
+ @Override public void onActivityResult(int requestCode, int resultCode, Intent data) {
+ onActivityResult0(getActivity(), requestCode, resultCode);
+ }
+
+ private static native void onActivityResult0(Activity act, int reqCode, int resCode);
+}
diff --git a/android_legacy/src/main/java/com/tailscale/ipn/QuickToggleService.java b/android_legacy/src/main/java/com/tailscale/ipn/QuickToggleService.java
new file mode 100644
index 0000000..ec341be
--- /dev/null
+++ b/android_legacy/src/main/java/com/tailscale/ipn/QuickToggleService.java
@@ -0,0 +1,88 @@
+// Copyright (c) 2020 Tailscale Inc & AUTHORS All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package com.tailscale.ipn;
+
+import android.content.Context;
+import android.content.Intent;
+import android.service.quicksettings.Tile;
+import android.service.quicksettings.TileService;
+
+public class QuickToggleService extends TileService {
+ // lock protects the static fields below it.
+ private static Object lock = new Object();
+ // Active tracks whether the VPN is active.
+ private static boolean active;
+ // Ready tracks whether the tailscale backend is
+ // ready to switch on/off.
+ private static boolean ready;
+ // currentTile tracks getQsTile while service is listening.
+ private static Tile currentTile;
+
+ @Override public void onStartListening() {
+ synchronized (lock) {
+ currentTile = getQsTile();
+ }
+ updateTile();
+ }
+
+ @Override public void onStopListening() {
+ synchronized (lock) {
+ currentTile = null;
+ }
+ }
+
+ @Override public void onClick() {
+ boolean r;
+ synchronized (lock) {
+ r = ready;
+ }
+ if (r) {
+ onTileClick();
+ } else {
+ // Start main activity.
+ Intent i = getPackageManager().getLaunchIntentForPackage(getPackageName());
+ startActivityAndCollapse(i);
+ }
+ }
+
+ private static void updateTile() {
+ Tile t;
+ boolean act;
+ synchronized (lock) {
+ t = currentTile;
+ act = active && ready;
+ }
+ if (t == null) {
+ return;
+ }
+ t.setState(act ? Tile.STATE_ACTIVE : Tile.STATE_INACTIVE);
+ t.updateTile();
+ }
+
+ static void setReady(Context ctx, boolean rdy) {
+ synchronized (lock) {
+ ready = rdy;
+ }
+ updateTile();
+ }
+
+ static void setStatus(Context ctx, boolean act) {
+ synchronized (lock) {
+ active = act;
+ }
+ updateTile();
+ }
+
+ private void onTileClick() {
+ boolean act;
+ synchronized (lock) {
+ act = active && ready;
+ }
+ Intent i = new Intent(act ? IPNReceiver.INTENT_DISCONNECT_VPN : IPNReceiver.INTENT_CONNECT_VPN);
+ i.setPackage(getPackageName());
+ i.setClass(getApplicationContext(), com.tailscale.ipn.IPNReceiver.class);
+ sendBroadcast(i);
+ }
+}
diff --git a/android_legacy/src/main/java/com/tailscale/ipn/StartVPNWorker.java b/android_legacy/src/main/java/com/tailscale/ipn/StartVPNWorker.java
new file mode 100644
index 0000000..e4a0b4d
--- /dev/null
+++ b/android_legacy/src/main/java/com/tailscale/ipn/StartVPNWorker.java
@@ -0,0 +1,66 @@
+// Copyright (c) 2023 Tailscale Inc & AUTHORS All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package com.tailscale.ipn;
+
+import android.app.Notification;
+import android.app.NotificationChannel;
+import android.app.NotificationManager;
+import android.app.PendingIntent;
+import android.content.Context;
+import android.content.Intent;
+import android.net.VpnService;
+import android.os.Build;
+import androidx.work.Worker;
+import androidx.work.WorkerParameters;
+
+public final class StartVPNWorker extends Worker {
+
+ public StartVPNWorker(
+ Context appContext,
+ WorkerParameters workerParams) {
+ super(appContext, workerParams);
+ }
+
+ @Override public Result doWork() {
+ App app = ((App)getApplicationContext());
+
+ // We will start the VPN from the background
+ app.autoConnect = true;
+ // We need to make sure we prepare the VPN Service, just in case it isn't prepared.
+
+ Intent intent = VpnService.prepare(app);
+ if (intent == null) {
+ // If null then the VPN is already prepared and/or it's just been prepared because we have permission
+ app.startVPN();
+ return Result.success();
+ } else {
+ // This VPN possibly doesn't have permission, we need to display a notification which when clicked launches the intent provided.
+ android.util.Log.e("StartVPNWorker", "Tailscale doesn't have permission from the system to start VPN. Launching the intent provided.");
+
+ // Send notification
+ NotificationManager notificationManager = (NotificationManager) app.getSystemService(Context.NOTIFICATION_SERVICE);
+ String channelId = "start_vpn_channel";
+
+ // Use createNotificationChannel method from App.java
+ app.createNotificationChannel(channelId, "Start VPN Channel", NotificationManager.IMPORTANCE_DEFAULT);
+
+ intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
+ int pendingIntentFlags = PendingIntent.FLAG_ONE_SHOT | (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S ? PendingIntent.FLAG_IMMUTABLE : 0);
+ PendingIntent pendingIntent = PendingIntent.getActivity(app, 0, intent, pendingIntentFlags);
+
+ Notification notification = new Notification.Builder(app, channelId)
+ .setContentTitle("Tailscale Connection Failed")
+ .setContentText("Tap here to renew permission.")
+ .setSmallIcon(R.drawable.ic_notification)
+ .setContentIntent(pendingIntent)
+ .setAutoCancel(true)
+ .build();
+
+ notificationManager.notify(1, notification);
+
+ return Result.failure();
+ }
+ }
+}
\ No newline at end of file
diff --git a/android_legacy/src/main/java/com/tailscale/ipn/StopVPNWorker.java b/android_legacy/src/main/java/com/tailscale/ipn/StopVPNWorker.java
new file mode 100644
index 0000000..296ce28
--- /dev/null
+++ b/android_legacy/src/main/java/com/tailscale/ipn/StopVPNWorker.java
@@ -0,0 +1,25 @@
+// Copyright (c) 2023 Tailscale Inc & AUTHORS All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package com.tailscale.ipn;
+
+import androidx.work.Worker;
+import android.content.Context;
+import androidx.work.WorkerParameters;
+
+public final class StopVPNWorker extends Worker {
+
+ public StopVPNWorker(
+ Context appContext,
+ WorkerParameters workerParams) {
+ super(appContext, workerParams);
+ }
+
+ @Override public Result doWork() {
+ disconnect();
+ return Result.success();
+ }
+
+ private native void disconnect();
+}
diff --git a/android_legacy/src/main/java/com/tailscale/ipn/ui/localapi/LocalAPIClient.kt b/android_legacy/src/main/java/com/tailscale/ipn/ui/localapi/LocalAPIClient.kt
new file mode 100644
index 0000000..9741679
--- /dev/null
+++ b/android_legacy/src/main/java/com/tailscale/ipn/ui/localapi/LocalAPIClient.kt
@@ -0,0 +1,150 @@
+// Copyright (c) 2024 Tailscale Inc & AUTHORS All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package com.tailscale.ipn.ui.localapi
+
+import android.util.Log
+import com.tailscale.ipn.ui.model.*
+import kotlinx.coroutines.*
+
+// A response from the echo endpoint.
+typealias StatusResponseHandler = (Result) -> Unit
+
+typealias BugReportIdHandler = (Result) -> Unit
+
+typealias PrefsHandler = (Result) -> Unit
+
+class LocalApiClient {
+ constructor() {
+ Log.d("LocalApiClient", "LocalApiClient created")
+ }
+
+ // Perform a request to the local API in the go backend. This is
+ // the primary JNI method for servicing a localAPI call. This
+ // is GUARANTEED to call back into onResponse with the response
+ // from the backend with a matching cookie.
+ // @see cmd/localapiclient/localapishim.go
+ //
+ // request: The path to the localAPI endpoint.
+ // method: The HTTP method to use.
+ // body: The body of the request.
+ // cookie: A unique identifier for this request. This is used map responses to
+ // the corresponding request. Cookies must be unique for each request.
+ external fun doRequest(request: String, method: String, body: String, cookie: String)
+
+ fun executeRequest(request: LocalAPIRequest) {
+ Log.d("LocalApiClient", "Executing request:${request.method}:${request.path}")
+ addRequest(request)
+ // The jni handler will treat the empty string in the body as null.
+ val body = request.body ?: ""
+ doRequest(request.path, request.method, body, request.cookie)
+ }
+
+ // This is called from the JNI layer to publish localAPIResponses. This should execute on the
+ // same thread that called doRequest.
+ fun onResponse(response: String, cookie: String) {
+ val request = requests[cookie]
+ if (request != null) {
+ Log.d("LocalApiClient", "Reponse for request:${request.path} cookie:${request.cookie}")
+ // The response handler will invoked internally by the request parser
+ request.parser(response)
+ removeRequest(cookie)
+ } else {
+ Log.e("LocalApiClient", "Received response for unknown request: ${cookie}")
+ }
+ }
+
+ // Tracks in-flight requests and their callback handlers by cookie. This should
+ // always be manipulated via the addRequest and removeRequest methods.
+ private var requests = HashMap>()
+ private var requestLock = Any()
+
+ fun addRequest(request: LocalAPIRequest<*>) {
+ synchronized(requestLock) { requests[request.cookie] = request }
+ }
+
+ fun removeRequest(cookie: String) {
+ synchronized(requestLock) { requests.remove(cookie) }
+ }
+
+ // localapi Invocations
+
+ fun getStatus(responseHandler: StatusResponseHandler) {
+ val req = LocalAPIRequest.status(responseHandler)
+ executeRequest(req)
+ }
+
+ fun getBugReportId(responseHandler: BugReportIdHandler) {
+ val req = LocalAPIRequest.bugReportId(responseHandler)
+ executeRequest(req)
+ }
+
+ fun getPrefs(responseHandler: PrefsHandler) {
+ val req = LocalAPIRequest.prefs(responseHandler)
+ executeRequest(req)
+ }
+
+ // (jonathan) TODO: A (likely) exhaustive list of localapi endpoints required for
+ // a fully functioning client. This is a work in progress and will be updated
+ // See: corp/xcode/Shared/LocalAPIClient.swift for the various verbs, parameters,
+ // and body contents for each endpoint. Endpoints are defined in LocalAPIEndpoint
+ //
+ // fetchFileTargets
+ // sendFiles
+ // getWaitingFiles
+ // recieveWaitingFile
+ // inidicateFileRecieved
+ // debug
+ // debugLog
+ // uploadClientMetrics
+ // start
+ // startLoginInteractive
+ // logout
+ // profiles
+ // currentProfile
+ // addProfile
+ // switchProfile
+ // deleteProfile
+ // tailnetLocalStatus
+ // signNode
+ // verifyDeepling
+ // ping
+ // setTailFSFileServerAddress
+
+ // Run some tests to validate the APIs work before we have anything
+ // that calls them. This runs after a short delay to avoid not-ready
+ // errors
+ // (jonathan) TODO: Do we need some kind of "onReady" callback?
+ // (jonathan) TODO: Remove these we're further along
+
+ fun runAPITests() = runBlocking {
+ delay(5000L)
+ getStatus { result ->
+ if (result.failed) {
+ Log.e("LocalApiClient", "Error getting status: ${result.error}")
+ } else {
+ val status = result.success
+ Log.d("LocalApiClient", "Got status: ${status}")
+ }
+ }
+
+ getBugReportId { result ->
+ if (result.failed) {
+ Log.e("LocalApiClient", "Error getting bug report id: ${result.error}")
+ } else {
+ val bugReportId = result.success
+ Log.d("LocalApiClient", "Got bug report id: ${bugReportId}")
+ }
+ }
+
+ getPrefs { result ->
+ if (result.failed) {
+ Log.e("LocalApiClient", "Error getting prefs: ${result.error}")
+ } else {
+ val prefs = result.success
+ Log.d("LocalApiClient", "Got prefs: ${prefs}")
+ }
+ }
+ }
+}
diff --git a/android_legacy/src/main/java/com/tailscale/ipn/ui/localapi/LocalAPIRequest.kt b/android_legacy/src/main/java/com/tailscale/ipn/ui/localapi/LocalAPIRequest.kt
new file mode 100644
index 0000000..c9380d2
--- /dev/null
+++ b/android_legacy/src/main/java/com/tailscale/ipn/ui/localapi/LocalAPIRequest.kt
@@ -0,0 +1,129 @@
+// Copyright (c) 2024 Tailscale Inc & AUTHORS All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package com.tailscale.ipn.ui.localapi
+
+import com.tailscale.ipn.ui.model.*
+import kotlinx.serialization.decodeFromString
+import kotlinx.serialization.json.Json
+
+enum class LocalAPIEndpoint(val rawValue: String) {
+ Debug("debug"),
+ Debug_Log("debug-log"),
+ BugReport("bugreport"),
+ Prefs("prefs"),
+ FileTargets("file-targets"),
+ UploadMetrics("upload-client-metrics"),
+ Start("start"),
+ LoginInteractive("login-interactive"),
+ ResetAuth("reset-auth"),
+ Logout("logout"),
+ Profiles("profiles"),
+ ProfilesCurrent("profiles/current"),
+ Status("status"),
+ TKAStatus("tka/status"),
+ TKASitng("tka/sign"),
+ TKAVerifyDeeplink("tka/verify-deeplink"),
+ Ping("ping"),
+ Files("files"),
+ FilePut("file-put"),
+ TailFSServerAddress("tailfs/fileserver-address");
+
+ val prefix = "/localapi/v0/"
+
+ fun path(): String {
+ return prefix + rawValue
+ }
+}
+
+// Potential local and upstream errors. Error handling in localapi in the go layer
+// is inconsistent but different clients already deal with that inconsistency so
+// 'fixing' it will likely break other things.
+//
+// For now, anything that isn't an { error: "message" } will be passed along
+// as UNPARSEABLE_RESPONSE. We can add additional error translation in the parseError
+// method as needed.
+//
+// (jonathan) TODO: Audit local API for all of the possible error results and clean
+// it up if possible.
+enum class APIErrorVals(val rawValue: String) {
+ UNPARSEABLE_RESPONSE("Unparseable localAPI response"),
+ NOT_READY("Not Ready");
+
+ fun toError(): Error {
+ return Error(rawValue)
+ }
+}
+
+class LocalAPIRequest(
+ val path: String,
+ val method: String,
+ val body: String? = null,
+ val responseHandler: (Result) -> Unit,
+ val parser: (String) -> Unit,
+) {
+ companion object {
+ val cookieLock = Any()
+ var cookieCounter: Int = 0
+ val decoder = Json { ignoreUnknownKeys = true }
+
+ fun getCookie(): String {
+ synchronized(cookieLock) {
+ cookieCounter += 1
+ return cookieCounter.toString()
+ }
+ }
+
+ fun status(responseHandler: StatusResponseHandler): LocalAPIRequest {
+ val path = LocalAPIEndpoint.Status.path()
+ return LocalAPIRequest(path, "GET", null, responseHandler) { resp ->
+ responseHandler(decode(resp))
+ }
+ }
+
+ fun bugReportId(responseHandler: BugReportIdHandler): LocalAPIRequest {
+ val path = LocalAPIEndpoint.BugReport.path()
+ return LocalAPIRequest(path, "POST", null, responseHandler) { resp ->
+ responseHandler(parseString(resp))
+ }
+ }
+
+ fun prefs(responseHandler: PrefsHandler): LocalAPIRequest {
+ val path = LocalAPIEndpoint.Prefs.path()
+ return LocalAPIRequest(path, "GET", null, responseHandler) { resp ->
+ responseHandler(decode(resp))
+ }
+ }
+
+ // Check if the response was a generic error
+ fun parseError(respData: String): Error {
+ try {
+ val err = Json.decodeFromString(respData)
+ return Error(err.error)
+ } catch (e: Exception) {
+ return Error(APIErrorVals.UNPARSEABLE_RESPONSE.toError())
+ }
+ }
+
+ // Handles responses that are raw strings. Returns an error result if the string
+ // is empty
+ fun parseString(respData: String): Result {
+ return if (respData.length > 0) Result(respData)
+ else Result(APIErrorVals.UNPARSEABLE_RESPONSE.toError())
+ }
+
+ // Attempt to decode the response into the expected type. If that fails, then try
+ // parsing as an error.
+ inline fun decode(respData: String): Result {
+ try {
+ val message = decoder.decodeFromString(respData)
+ return Result(message)
+ } catch (e: Exception) {
+ return Result(parseError(respData))
+ }
+ }
+ }
+
+ val cookie: String = getCookie()
+}
diff --git a/android_legacy/src/main/java/com/tailscale/ipn/ui/localapi/Result.kt b/android_legacy/src/main/java/com/tailscale/ipn/ui/localapi/Result.kt
new file mode 100644
index 0000000..7d9ec5a
--- /dev/null
+++ b/android_legacy/src/main/java/com/tailscale/ipn/ui/localapi/Result.kt
@@ -0,0 +1,33 @@
+// Copyright (c) 2024 Tailscale Inc & AUTHORS All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package com.tailscale.ipn.ui.localapi
+
+// Go-like result type with an optional value and an optional Error
+// This guarantees that only one of the two is non-null
+class Result {
+ val success: T?
+ val error: Error?
+
+ constructor(success: T?, error: Error?) {
+ if (success != null && error != null) {
+ throw IllegalArgumentException("Result cannot have both a success and an error")
+ }
+ if (success == null && error == null) {
+ throw IllegalArgumentException("Result must have either a success or an error")
+ }
+
+ this.success = success
+ this.error = error
+ }
+
+ constructor(success: T) : this(success, null) {}
+ constructor(error: Error) : this(null, error) {}
+
+ var successful: Boolean = false
+ get() = success != null
+
+ var failed: Boolean = false
+ get() = error != null
+}
\ No newline at end of file
diff --git a/android_legacy/src/main/res/drawable-hdpi/ic_notification.png b/android_legacy/src/main/res/drawable-hdpi/ic_notification.png
new file mode 100644
index 0000000..e6aec75
Binary files /dev/null and b/android_legacy/src/main/res/drawable-hdpi/ic_notification.png differ
diff --git a/android_legacy/src/main/res/drawable-mdpi/ic_notification.png b/android_legacy/src/main/res/drawable-mdpi/ic_notification.png
new file mode 100644
index 0000000..129824e
Binary files /dev/null and b/android_legacy/src/main/res/drawable-mdpi/ic_notification.png differ
diff --git a/android_legacy/src/main/res/drawable-xhdpi/ic_notification.png b/android_legacy/src/main/res/drawable-xhdpi/ic_notification.png
new file mode 100644
index 0000000..8066819
Binary files /dev/null and b/android_legacy/src/main/res/drawable-xhdpi/ic_notification.png differ
diff --git a/android_legacy/src/main/res/drawable-xhdpi/tv_banner.png b/android_legacy/src/main/res/drawable-xhdpi/tv_banner.png
new file mode 100644
index 0000000..b5543a6
Binary files /dev/null and b/android_legacy/src/main/res/drawable-xhdpi/tv_banner.png differ
diff --git a/android_legacy/src/main/res/drawable-xxhdpi/ic_notification.png b/android_legacy/src/main/res/drawable-xxhdpi/ic_notification.png
new file mode 100644
index 0000000..615f569
Binary files /dev/null and b/android_legacy/src/main/res/drawable-xxhdpi/ic_notification.png differ
diff --git a/android_legacy/src/main/res/drawable-xxxhdpi/ic_notification.png b/android_legacy/src/main/res/drawable-xxxhdpi/ic_notification.png
new file mode 100644
index 0000000..a45d73c
Binary files /dev/null and b/android_legacy/src/main/res/drawable-xxxhdpi/ic_notification.png differ
diff --git a/android_legacy/src/main/res/drawable/ic_launcher_foreground.xml b/android_legacy/src/main/res/drawable/ic_launcher_foreground.xml
new file mode 100644
index 0000000..154dbef
--- /dev/null
+++ b/android_legacy/src/main/res/drawable/ic_launcher_foreground.xml
@@ -0,0 +1,36 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/android_legacy/src/main/res/drawable/ic_tile.xml b/android_legacy/src/main/res/drawable/ic_tile.xml
new file mode 100644
index 0000000..3cd5907
--- /dev/null
+++ b/android_legacy/src/main/res/drawable/ic_tile.xml
@@ -0,0 +1,35 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/android_legacy/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/android_legacy/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
new file mode 100644
index 0000000..7353dbd
--- /dev/null
+++ b/android_legacy/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/android_legacy/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/android_legacy/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
new file mode 100644
index 0000000..7353dbd
--- /dev/null
+++ b/android_legacy/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/android_legacy/src/main/res/mipmap-hdpi/ic_launcher.png b/android_legacy/src/main/res/mipmap-hdpi/ic_launcher.png
new file mode 100644
index 0000000..b5f0466
Binary files /dev/null and b/android_legacy/src/main/res/mipmap-hdpi/ic_launcher.png differ
diff --git a/android_legacy/src/main/res/mipmap-hdpi/ic_launcher_round.png b/android_legacy/src/main/res/mipmap-hdpi/ic_launcher_round.png
new file mode 100644
index 0000000..91ad4fc
Binary files /dev/null and b/android_legacy/src/main/res/mipmap-hdpi/ic_launcher_round.png differ
diff --git a/android_legacy/src/main/res/mipmap-mdpi/ic_launcher.png b/android_legacy/src/main/res/mipmap-mdpi/ic_launcher.png
new file mode 100644
index 0000000..84001c9
Binary files /dev/null and b/android_legacy/src/main/res/mipmap-mdpi/ic_launcher.png differ
diff --git a/android_legacy/src/main/res/mipmap-mdpi/ic_launcher_round.png b/android_legacy/src/main/res/mipmap-mdpi/ic_launcher_round.png
new file mode 100644
index 0000000..ca60a18
Binary files /dev/null and b/android_legacy/src/main/res/mipmap-mdpi/ic_launcher_round.png differ
diff --git a/android_legacy/src/main/res/mipmap-xhdpi/ic_launcher.png b/android_legacy/src/main/res/mipmap-xhdpi/ic_launcher.png
new file mode 100644
index 0000000..a9a1919
Binary files /dev/null and b/android_legacy/src/main/res/mipmap-xhdpi/ic_launcher.png differ
diff --git a/android_legacy/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/android_legacy/src/main/res/mipmap-xhdpi/ic_launcher_round.png
new file mode 100644
index 0000000..e1e7216
Binary files /dev/null and b/android_legacy/src/main/res/mipmap-xhdpi/ic_launcher_round.png differ
diff --git a/android_legacy/src/main/res/mipmap-xxhdpi/ic_launcher.png b/android_legacy/src/main/res/mipmap-xxhdpi/ic_launcher.png
new file mode 100644
index 0000000..d3408ec
Binary files /dev/null and b/android_legacy/src/main/res/mipmap-xxhdpi/ic_launcher.png differ
diff --git a/android_legacy/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/android_legacy/src/main/res/mipmap-xxhdpi/ic_launcher_round.png
new file mode 100644
index 0000000..d222f31
Binary files /dev/null and b/android_legacy/src/main/res/mipmap-xxhdpi/ic_launcher_round.png differ
diff --git a/android_legacy/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/android_legacy/src/main/res/mipmap-xxxhdpi/ic_launcher.png
new file mode 100644
index 0000000..40521d4
Binary files /dev/null and b/android_legacy/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ
diff --git a/android_legacy/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/android_legacy/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png
new file mode 100644
index 0000000..e620f35
Binary files /dev/null and b/android_legacy/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png differ
diff --git a/android_legacy/src/main/res/values/ic_launcher_background.xml b/android_legacy/src/main/res/values/ic_launcher_background.xml
new file mode 100644
index 0000000..16b8946
--- /dev/null
+++ b/android_legacy/src/main/res/values/ic_launcher_background.xml
@@ -0,0 +1,4 @@
+
+
+ #1F2125
+
\ No newline at end of file
diff --git a/android_legacy/src/main/res/values/strings.xml b/android_legacy/src/main/res/values/strings.xml
new file mode 100644
index 0000000..07b3455
--- /dev/null
+++ b/android_legacy/src/main/res/values/strings.xml
@@ -0,0 +1,5 @@
+
+
+ Tailscale
+ Tailscale
+
diff --git a/android_legacy/src/play/java/com/tailscale/ipn/Google.java b/android_legacy/src/play/java/com/tailscale/ipn/Google.java
new file mode 100644
index 0000000..44f8226
--- /dev/null
+++ b/android_legacy/src/play/java/com/tailscale/ipn/Google.java
@@ -0,0 +1,43 @@
+// Copyright (c) 2020 Tailscale Inc & AUTHORS All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package com.tailscale.ipn;
+
+import android.app.Activity;
+import android.content.Intent;
+import android.content.Context;
+
+import com.google.android.gms.auth.api.signin.GoogleSignIn;
+import com.google.android.gms.auth.api.signin.GoogleSignInAccount;
+import com.google.android.gms.auth.api.signin.GoogleSignInClient;
+import com.google.android.gms.auth.api.signin.GoogleSignInOptions;
+
+// Google implements helpers for Google services.
+public final class Google {
+ static String getIdTokenForActivity(Activity act) {
+ GoogleSignInAccount acc = GoogleSignIn.getLastSignedInAccount(act);
+ return acc.getIdToken();
+ }
+
+ static void googleSignIn(Activity act, String serverOAuthID, int reqCode) {
+ act.runOnUiThread(new Runnable() {
+ @Override public void run() {
+ GoogleSignInOptions gso = new GoogleSignInOptions.Builder(GoogleSignInOptions.DEFAULT_SIGN_IN)
+ .requestIdToken(serverOAuthID)
+ .requestEmail()
+ .build();
+ GoogleSignInClient client = GoogleSignIn.getClient(act, gso);
+ Intent signInIntent = client.getSignInIntent();
+ App.startActivityForResult(act, signInIntent, reqCode);
+ }
+ });
+ }
+
+ static void googleSignOut(Context ctx) {
+ GoogleSignInOptions gso = new GoogleSignInOptions.Builder(GoogleSignInOptions.DEFAULT_SIGN_IN)
+ .build();
+ GoogleSignInClient client = GoogleSignIn.getClient(ctx, gso);
+ client.signOut();
+ }
+}