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