android: restructure app (#182)
Make android_legacy for the old app and remove some of the new models from it Modify Makefile to build the legacy app and the new app Updates tailscale/tailscale#10992 Signed-off-by: kari-ts <kari@tailscale.com>pull/177/head^2
@ -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'
|
||||||
|
}
|
@ -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
|
@ -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
|
@ -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" "$@"
|
@ -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
|
@ -0,0 +1,85 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
|
||||||
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||||
|
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="28"/>
|
||||||
|
<uses-permission android:name="android.permission.CHANGE_NETWORK_STATE" />
|
||||||
|
|
||||||
|
<!-- Disable input emulation on ChromeOS -->
|
||||||
|
<uses-feature android:name="android.hardware.type.pc" android:required="false"/>
|
||||||
|
|
||||||
|
<!-- Signal support for Android TV -->
|
||||||
|
<uses-feature android:name="android.software.leanback" android:required="false" />
|
||||||
|
<uses-feature android:name="android.hardware.touchscreen" android:required="false" />
|
||||||
|
|
||||||
|
<application android:label="Tailscale" android:icon="@mipmap/ic_launcher" android:roundIcon="@mipmap/ic_launcher_round"
|
||||||
|
android:banner="@drawable/tv_banner"
|
||||||
|
android:name=".App" android:allowBackup="false">
|
||||||
|
<activity android:name="IPNActivity"
|
||||||
|
android:label="@string/app_name"
|
||||||
|
android:theme="@style/Theme.GioApp"
|
||||||
|
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|keyboardHidden"
|
||||||
|
android:windowSoftInputMode="adjustResize"
|
||||||
|
android:launchMode="singleTask"
|
||||||
|
android:exported="true">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
|
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
|
||||||
|
</intent-filter>
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.service.quicksettings.action.QS_TILE_PREFERENCES" />
|
||||||
|
</intent-filter>
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.SEND" />
|
||||||
|
<category android:name="android.intent.category.DEFAULT"/>
|
||||||
|
<data android:mimeType="application/*" />
|
||||||
|
<data android:mimeType="audio/*" />
|
||||||
|
<data android:mimeType="image/*" />
|
||||||
|
<data android:mimeType="message/*" />
|
||||||
|
<data android:mimeType="multipart/*" />
|
||||||
|
<data android:mimeType="text/*" />
|
||||||
|
<data android:mimeType="video/*" />
|
||||||
|
</intent-filter>
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.SEND_MULTIPLE" />
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
<data android:mimeType="application/*" />
|
||||||
|
<data android:mimeType="audio/*" />
|
||||||
|
<data android:mimeType="image/*" />
|
||||||
|
<data android:mimeType="message/*" />
|
||||||
|
<data android:mimeType="multipart/*" />
|
||||||
|
<data android:mimeType="text/*" />
|
||||||
|
<data android:mimeType="video/*" />
|
||||||
|
</intent-filter>
|
||||||
|
</activity>
|
||||||
|
<receiver android:name="IPNReceiver"
|
||||||
|
android:exported="true"
|
||||||
|
>
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="com.tailscale.ipn.CONNECT_VPN" />
|
||||||
|
<action android:name="com.tailscale.ipn.DISCONNECT_VPN" />
|
||||||
|
</intent-filter>
|
||||||
|
</receiver>
|
||||||
|
<service android:name=".IPNService"
|
||||||
|
android:permission="android.permission.BIND_VPN_SERVICE"
|
||||||
|
android:exported="false">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.net.VpnService"/>
|
||||||
|
</intent-filter>
|
||||||
|
</service>
|
||||||
|
<service
|
||||||
|
android:name=".QuickToggleService"
|
||||||
|
android:icon="@drawable/ic_tile"
|
||||||
|
android:label="@string/tile_name"
|
||||||
|
android:permission="android.permission.BIND_QUICK_SETTINGS_TILE"
|
||||||
|
android:exported="true">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.service.quicksettings.action.QS_TILE"/>
|
||||||
|
</intent-filter>
|
||||||
|
</service>
|
||||||
|
</application>
|
||||||
|
</manifest>
|
||||||
|
|
After Width: | Height: | Size: 17 KiB |
@ -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<InetAddress> 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<NetworkInterface> interfaces;
|
||||||
|
try {
|
||||||
|
interfaces = Collections.list(NetworkInterface.getNetworkInterfaces());
|
||||||
|
} catch (Exception e) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
StringBuilder sb = new StringBuilder("");
|
||||||
|
for (NetworkInterface nif : interfaces) {
|
||||||
|
try {
|
||||||
|
// Android doesn't have a supportsBroadcast() but the Go net.Interface wants
|
||||||
|
// one, so we say the interface has broadcast if it has multicast.
|
||||||
|
sb.append(String.format(java.util.Locale.ROOT, "%s %d %d %b %b %b %b %b |", nif.getName(),
|
||||||
|
nif.getIndex(), nif.getMTU(), nif.isUp(), nif.supportsMulticast(),
|
||||||
|
nif.isLoopback(), nif.isPointToPoint(), nif.supportsMulticast()));
|
||||||
|
|
||||||
|
for (InterfaceAddress ia : nif.getInterfaceAddresses()) {
|
||||||
|
// InterfaceAddress == hostname + "/" + IP
|
||||||
|
String[] parts = ia.toString().split("/", 0);
|
||||||
|
if (parts.length > 1) {
|
||||||
|
sb.append(String.format(java.util.Locale.ROOT, "%s/%d ", parts[1], ia.getNetworkPrefixLength()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
// TODO(dgentry) should log the exception not silently suppress it.
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
sb.append("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
boolean isTV() {
|
||||||
|
UiModeManager mm = (UiModeManager)getSystemService(UI_MODE_SERVICE);
|
||||||
|
return mm.getCurrentModeType() == Configuration.UI_MODE_TYPE_TELEVISION;
|
||||||
|
}
|
||||||
|
}
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,133 @@
|
|||||||
|
// Copyright (c) 2021 Tailscale Inc & AUTHORS All rights reserved.
|
||||||
|
// Use of this source code is governed by a BSD-style
|
||||||
|
// license that can be found in the LICENSE file.
|
||||||
|
|
||||||
|
package com.tailscale.ipn;
|
||||||
|
|
||||||
|
import android.app.Activity;
|
||||||
|
import android.content.res.AssetFileDescriptor;
|
||||||
|
import android.content.res.Configuration;
|
||||||
|
import android.content.Intent;
|
||||||
|
import android.database.Cursor;
|
||||||
|
import android.os.Bundle;
|
||||||
|
import android.provider.OpenableColumns;
|
||||||
|
import android.net.Uri;
|
||||||
|
import android.content.pm.PackageManager;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
|
||||||
|
import org.gioui.GioView;
|
||||||
|
|
||||||
|
public final class IPNActivity extends Activity {
|
||||||
|
final static int WRITE_STORAGE_RESULT = 1000;
|
||||||
|
|
||||||
|
private GioView view;
|
||||||
|
|
||||||
|
@Override public void onCreate(Bundle state) {
|
||||||
|
super.onCreate(state);
|
||||||
|
view = new GioView(this);
|
||||||
|
setContentView(view);
|
||||||
|
handleIntent();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override public void onNewIntent(Intent i) {
|
||||||
|
setIntent(i);
|
||||||
|
handleIntent();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void handleIntent() {
|
||||||
|
Intent it = getIntent();
|
||||||
|
String act = it.getAction();
|
||||||
|
String[] texts;
|
||||||
|
Uri[] uris;
|
||||||
|
if (Intent.ACTION_SEND.equals(act)) {
|
||||||
|
uris = new Uri[]{it.getParcelableExtra(Intent.EXTRA_STREAM)};
|
||||||
|
texts = new String[]{it.getStringExtra(Intent.EXTRA_TEXT)};
|
||||||
|
} else if (Intent.ACTION_SEND_MULTIPLE.equals(act)) {
|
||||||
|
List<Uri> extraUris = it.getParcelableArrayListExtra(Intent.EXTRA_STREAM);
|
||||||
|
uris = extraUris.toArray(new Uri[0]);
|
||||||
|
texts = new String[uris.length];
|
||||||
|
} else {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
String mime = it.getType();
|
||||||
|
int nitems = uris.length;
|
||||||
|
String[] items = new String[nitems];
|
||||||
|
String[] mimes = new String[nitems];
|
||||||
|
int[] types = new int[nitems];
|
||||||
|
String[] names = new String[nitems];
|
||||||
|
long[] sizes = new long[nitems];
|
||||||
|
int nfiles = 0;
|
||||||
|
for (int i = 0; i < uris.length; i++) {
|
||||||
|
String text = texts[i];
|
||||||
|
Uri uri = uris[i];
|
||||||
|
if (text != null) {
|
||||||
|
types[nfiles] = 1; // FileTypeText
|
||||||
|
names[nfiles] = "file.txt";
|
||||||
|
mimes[nfiles] = mime;
|
||||||
|
items[nfiles] = text;
|
||||||
|
// Determined by len(text) in Go to eliminate UTF-8 encoding differences.
|
||||||
|
sizes[nfiles] = 0;
|
||||||
|
nfiles++;
|
||||||
|
} else if (uri != null) {
|
||||||
|
Cursor c = getContentResolver().query(uri, null, null, null, null);
|
||||||
|
if (c == null) {
|
||||||
|
// Ignore files we have no permission to access.
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
int nameCol = c.getColumnIndex(OpenableColumns.DISPLAY_NAME);
|
||||||
|
int sizeCol = c.getColumnIndex(OpenableColumns.SIZE);
|
||||||
|
c.moveToFirst();
|
||||||
|
String name = c.getString(nameCol);
|
||||||
|
long size = c.getLong(sizeCol);
|
||||||
|
types[nfiles] = 2; // FileTypeURI
|
||||||
|
mimes[nfiles] = mime;
|
||||||
|
items[nfiles] = uri.toString();
|
||||||
|
names[nfiles] = name;
|
||||||
|
sizes[nfiles] = size;
|
||||||
|
nfiles++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
App.onShareIntent(nfiles, types, mimes, items, names, sizes);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override public void onRequestPermissionsResult(int reqCode, String[] perms, int[] grants) {
|
||||||
|
switch (reqCode) {
|
||||||
|
case WRITE_STORAGE_RESULT:
|
||||||
|
if (grants.length > 0 && grants[0] == PackageManager.PERMISSION_GRANTED) {
|
||||||
|
App.onWriteStorageGranted();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override public void onDestroy() {
|
||||||
|
view.destroy();
|
||||||
|
super.onDestroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override public void onStart() {
|
||||||
|
super.onStart();
|
||||||
|
view.start();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override public void onStop() {
|
||||||
|
view.stop();
|
||||||
|
super.onStop();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override public void onConfigurationChanged(Configuration c) {
|
||||||
|
super.onConfigurationChanged(c);
|
||||||
|
view.configurationChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override public void onLowMemory() {
|
||||||
|
super.onLowMemory();
|
||||||
|
view.onLowMemory();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override public void onBackPressed() {
|
||||||
|
if (!view.backPressed())
|
||||||
|
super.onBackPressed();
|
||||||
|
}
|
||||||
|
}
|
@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -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();
|
||||||
|
}
|
@ -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);
|
||||||
|
}
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -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();
|
||||||
|
}
|
@ -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<IpnState.Status>) -> Unit
|
||||||
|
|
||||||
|
typealias BugReportIdHandler = (Result<BugReportID>) -> Unit
|
||||||
|
|
||||||
|
typealias PrefsHandler = (Result<Ipn.Prefs>) -> 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 <T> executeRequest(request: LocalAPIRequest<T>) {
|
||||||
|
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<String, LocalAPIRequest<*>>()
|
||||||
|
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<IpnState.Status>(req)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getBugReportId(responseHandler: BugReportIdHandler) {
|
||||||
|
val req = LocalAPIRequest.bugReportId(responseHandler)
|
||||||
|
executeRequest<BugReportID>(req)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getPrefs(responseHandler: PrefsHandler) {
|
||||||
|
val req = LocalAPIRequest.prefs(responseHandler)
|
||||||
|
executeRequest<Ipn.Prefs>(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}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -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<T>(
|
||||||
|
val path: String,
|
||||||
|
val method: String,
|
||||||
|
val body: String? = null,
|
||||||
|
val responseHandler: (Result<T>) -> 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<IpnState.Status> {
|
||||||
|
val path = LocalAPIEndpoint.Status.path()
|
||||||
|
return LocalAPIRequest<IpnState.Status>(path, "GET", null, responseHandler) { resp ->
|
||||||
|
responseHandler(decode<IpnState.Status>(resp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun bugReportId(responseHandler: BugReportIdHandler): LocalAPIRequest<BugReportID> {
|
||||||
|
val path = LocalAPIEndpoint.BugReport.path()
|
||||||
|
return LocalAPIRequest<BugReportID>(path, "POST", null, responseHandler) { resp ->
|
||||||
|
responseHandler(parseString(resp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun prefs(responseHandler: PrefsHandler): LocalAPIRequest<Ipn.Prefs> {
|
||||||
|
val path = LocalAPIEndpoint.Prefs.path()
|
||||||
|
return LocalAPIRequest<Ipn.Prefs>(path, "GET", null, responseHandler) { resp ->
|
||||||
|
responseHandler(decode<Ipn.Prefs>(resp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if the response was a generic error
|
||||||
|
fun parseError(respData: String): Error {
|
||||||
|
try {
|
||||||
|
val err = Json.decodeFromString<Errors.GenericError>(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<String> {
|
||||||
|
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 <reified T> decode(respData: String): Result<T> {
|
||||||
|
try {
|
||||||
|
val message = decoder.decodeFromString<T>(respData)
|
||||||
|
return Result(message)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
return Result(parseError(respData))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val cookie: String = getCookie()
|
||||||
|
}
|
@ -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<T> {
|
||||||
|
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
|
||||||
|
}
|
After Width: | Height: | Size: 641 B |
After Width: | Height: | Size: 406 B |
After Width: | Height: | Size: 879 B |
After Width: | Height: | Size: 1.7 KiB |
After Width: | Height: | Size: 1.4 KiB |
After Width: | Height: | Size: 1.8 KiB |
@ -0,0 +1,36 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="108dp"
|
||||||
|
android:height="108dp"
|
||||||
|
android:viewportWidth="100"
|
||||||
|
android:viewportHeight="100">
|
||||||
|
<group android:translateX="20"
|
||||||
|
android:translateY="20">
|
||||||
|
<path
|
||||||
|
android:pathData="M15,30.0002m-5,0a5,5 0,1 1,10 0a5,5 0,1 1,-10 0"
|
||||||
|
android:fillColor="#FFFDFA"/>
|
||||||
|
<path
|
||||||
|
android:pathData="M30,30.0002m-5,0a5,5 0,1 1,10 0a5,5 0,1 1,-10 0"
|
||||||
|
android:fillColor="#FFFDFA"/>
|
||||||
|
<path
|
||||||
|
android:pathData="M15,45.0002m-5,0a5,5 0,1 1,10 0a5,5 0,1 1,-10 0"
|
||||||
|
android:fillColor="#54514D"/>
|
||||||
|
<path
|
||||||
|
android:pathData="M45,45.0002m-5,0a5,5 0,1 1,10 0a5,5 0,1 1,-10 0"
|
||||||
|
android:fillColor="#54514D"/>
|
||||||
|
<path
|
||||||
|
android:pathData="M30,45.0002m-5,0a5,5 0,1 1,10 0a5,5 0,1 1,-10 0"
|
||||||
|
android:fillColor="#FFFDFA"/>
|
||||||
|
<path
|
||||||
|
android:pathData="M45,30.0002m-5,0a5,5 0,1 1,10 0a5,5 0,1 1,-10 0"
|
||||||
|
android:fillColor="#FFFDFA"/>
|
||||||
|
<path
|
||||||
|
android:pathData="M15,15.0002m-5,0a5,5 0,1 1,10 0a5,5 0,1 1,-10 0"
|
||||||
|
android:fillColor="#54514D"/>
|
||||||
|
<path
|
||||||
|
android:pathData="M30,14.9999m-5,0a5,5 0,1 1,10 0a5,5 0,1 1,-10 0"
|
||||||
|
android:fillColor="#54514D"/>
|
||||||
|
<path
|
||||||
|
android:pathData="M45,14.9999m-5,0a5,5 0,1 1,10 0a5,5 0,1 1,-10 0"
|
||||||
|
android:fillColor="#54514D"/>
|
||||||
|
</group>
|
||||||
|
</vector>
|
@ -0,0 +1,35 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="60"
|
||||||
|
android:viewportHeight="60">
|
||||||
|
<group>
|
||||||
|
<path
|
||||||
|
android:pathData="M15,30.0002m-5,0a5,5 0,1 1,10 0a5,5 0,1 1,-10 0"
|
||||||
|
android:fillColor="#FFFDFA"/>
|
||||||
|
<path
|
||||||
|
android:pathData="M30,30.0002m-5,0a5,5 0,1 1,10 0a5,5 0,1 1,-10 0"
|
||||||
|
android:fillColor="#FFFDFA"/>
|
||||||
|
<path
|
||||||
|
android:pathData="M15,45.0002m-5,0a5,5 0,1 1,10 0a5,5 0,1 1,-10 0"
|
||||||
|
android:fillColor="#54514D"/>
|
||||||
|
<path
|
||||||
|
android:pathData="M45,45.0002m-5,0a5,5 0,1 1,10 0a5,5 0,1 1,-10 0"
|
||||||
|
android:fillColor="#54514D"/>
|
||||||
|
<path
|
||||||
|
android:pathData="M30,45.0002m-5,0a5,5 0,1 1,10 0a5,5 0,1 1,-10 0"
|
||||||
|
android:fillColor="#FFFDFA"/>
|
||||||
|
<path
|
||||||
|
android:pathData="M45,30.0002m-5,0a5,5 0,1 1,10 0a5,5 0,1 1,-10 0"
|
||||||
|
android:fillColor="#FFFDFA"/>
|
||||||
|
<path
|
||||||
|
android:pathData="M15,15.0002m-5,0a5,5 0,1 1,10 0a5,5 0,1 1,-10 0"
|
||||||
|
android:fillColor="#54514D"/>
|
||||||
|
<path
|
||||||
|
android:pathData="M30,14.9999m-5,0a5,5 0,1 1,10 0a5,5 0,1 1,-10 0"
|
||||||
|
android:fillColor="#54514D"/>
|
||||||
|
<path
|
||||||
|
android:pathData="M45,14.9999m-5,0a5,5 0,1 1,10 0a5,5 0,1 1,-10 0"
|
||||||
|
android:fillColor="#54514D"/>
|
||||||
|
</group>
|
||||||
|
</vector>
|
@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@color/ic_launcher_background"/>
|
||||||
|
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
|
||||||
|
</adaptive-icon>
|
@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@color/ic_launcher_background"/>
|
||||||
|
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
|
||||||
|
</adaptive-icon>
|
After Width: | Height: | Size: 1.3 KiB |
After Width: | Height: | Size: 3.0 KiB |
After Width: | Height: | Size: 1021 B |
After Width: | Height: | Size: 1.8 KiB |
After Width: | Height: | Size: 1.9 KiB |
After Width: | Height: | Size: 4.2 KiB |
After Width: | Height: | Size: 3.1 KiB |
After Width: | Height: | Size: 6.9 KiB |
After Width: | Height: | Size: 4.8 KiB |
After Width: | Height: | Size: 10 KiB |
@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<color name="ic_launcher_background">#1F2125</color>
|
||||||
|
</resources>
|
@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<string name="app_name">Tailscale</string>
|
||||||
|
<string name="tile_name">Tailscale</string>
|
||||||
|
</resources>
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|