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(); + } +}