diff --git a/.gitignore b/.gitignore index b572c39a..345433c6 100644 --- a/.gitignore +++ b/.gitignore @@ -1,25 +1,31 @@ -target +# Gradle & Android Studio local.properties -local.sbt -/proguard-sbt.txt +.gradle/ +.externalNativeBuild/ +build/ +captures/ -#Intellij IDEA .idea/ .d gen/ +*.iml -mobile/src/main/libs -mobile/src/main/obj -mobile/src/main/jni/overture +core/src/main/libs +core/src/main/obj +core/src/main/jni/overture -mobile/src/overture/.deps -mobile/src/overture/bin -mobile/src/overture/pkg +core/src/overture/.deps +core/src/overture/bin +core/src/overture/pkg -mobile/src/overture/src/github.com/Sirupsen -mobile/src/overture/src/github.com/mattn -mobile/src/overture/src/github.com/miekg -mobile/src/overture/src/github.com/shawn1m -mobile/src/overture/src/github.com/tools -mobile/src/overture/src/golang.org -mobile/src/overture/src/gopkg.in +core/src/overture/src/github.com/Sirupsen +core/src/overture/src/github.com/mattn +core/src/overture/src/github.com/miekg +core/src/overture/src/github.com/shawn1m +core/src/overture/src/github.com/tools +core/src/overture/src/golang.org +core/src/overture/src/gopkg.in + +# work in progress +tv/ +things/ diff --git a/.gitmodules b/.gitmodules index 09136bb1..f119cabc 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,38 +1,38 @@ -[submodule "mobile/src/main/jni/shadowsocks-libev"] - path = mobile/src/main/jni/shadowsocks-libev +[submodule "core/src/main/jni/shadowsocks-libev"] + path = core/src/main/jni/shadowsocks-libev url = https://github.com/shadowsocks/shadowsocks-libev -[submodule "mobile/src/main/jni/badvpn"] - path = mobile/src/main/jni/badvpn +[submodule "core/src/main/jni/badvpn"] + path = core/src/main/jni/badvpn url = https://github.com/shadowsocks/badvpn.git branch = shadowsocks-android -[submodule "mobile/src/main/jni/libancillary"] - path = mobile/src/main/jni/libancillary +[submodule "core/src/main/jni/libancillary"] + path = core/src/main/jni/libancillary url = https://github.com/shadowsocks/libancillary.git branch = shadowsocks-android -[submodule "mobile/src/main/jni/libevent"] - path = mobile/src/main/jni/libevent +[submodule "core/src/main/jni/libevent"] + path = core/src/main/jni/libevent url = https://github.com/shadowsocks/libevent.git branch = shadowsocks-android -[submodule "mobile/src/main/jni/redsocks"] - path = mobile/src/main/jni/redsocks +[submodule "core/src/main/jni/redsocks"] + path = core/src/main/jni/redsocks url = https://github.com/shadowsocks/redsocks.git branch = shadowsocks-android -[submodule "mobile/src/main/jni/mbedtls"] - path = mobile/src/main/jni/mbedtls +[submodule "core/src/main/jni/mbedtls"] + path = core/src/main/jni/mbedtls url = https://github.com/ARMmbed/mbedtls -[submodule "mobile/src/main/jni/pcre"] - path = mobile/src/main/jni/pcre +[submodule "core/src/main/jni/pcre"] + path = core/src/main/jni/pcre url = https://android.googlesource.com/platform/external/pcre -[submodule "mobile/src/main/jni/libsodium"] - path = mobile/src/main/jni/libsodium +[submodule "core/src/main/jni/libsodium"] + path = core/src/main/jni/libsodium url = https://github.com/jedisct1/libsodium.git branch = stable -[submodule "mobile/src/main/jni/libev"] - path = mobile/src/main/jni/libev +[submodule "core/src/main/jni/libev"] + path = core/src/main/jni/libev url = https://github.com/shadowsocks/libev.git -[submodule "mobile/src/overture/go"] - path = mobile/src/overture/go +[submodule "core/src/overture/go"] + path = core/src/overture/go url = https://github.com/shadowsocks/go.git -[submodule "mobile/src/overture/src/github.com/shadowsocks/overture"] - path = mobile/src/overture/src/github.com/shadowsocks/overture +[submodule "core/src/overture/src/github.com/shadowsocks/overture"] + path = core/src/overture/src/github.com/shadowsocks/overture url = https://github.com/shadowsocks/overture.git diff --git a/.headache.cfg b/.headache.cfg deleted file mode 100644 index a8fab8c7..00000000 --- a/.headache.cfg +++ /dev/null @@ -1,2 +0,0 @@ -# Scala source -".*\\.scala" -> frame open:"/*" line:"*" close:"*/" diff --git a/.jvmopts b/.jvmopts deleted file mode 100644 index 598e353f..00000000 --- a/.jvmopts +++ /dev/null @@ -1,5 +0,0 @@ --Xms1g --Xmx3g --Xss2m --XX:+CMSClassUnloadingEnabled --XX:+UseConcMarkSweepGC diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index b7e12830..00000000 --- a/.travis.yml +++ /dev/null @@ -1,53 +0,0 @@ -language: android - -jdk: - - oraclejdk8 - -env: - global: - - NDK_VERSION=r16 - - NDK_CCACHE=ccache - - GOROOT_BOOTSTRAP=$GOROOT - - ANDROID_NDK_HOME=$HOME/.android/android-ndk-${NDK_VERSION} - - SBTPATH=$HOME/.sbt - - PATH=${ANDROID_NDK_HOME}:${ANDROID_HOME}/tools:${ANDROID_HOME}/platform-tools:${PATH} - -scala: - - 2.11.11 - -before_cache: - - find $HOME/.sbt -name "*.lock" | xargs rm - - find $HOME/.ivy2 -name "*.lock" | xargs rm - -cache: - directories: - - $HOME/.ivy2 - - $HOME/.sbt - -android: - components: - - tools - - build-tools-27.0.0 - - extra-android-m2repository - - extra-google-m2repository - -install: - - > - if [ ! -d "$ANDROID_NDK_HOME" ]; then - mkdir -p $ANDROID_NDK_HOME; - pushd $HOME/.android; - export ARCH=`uname -m`; - wget -q http://dl.google.com/android/repository/android-ndk-${NDK_VERSION}-linux-${ARCH}.zip; - unzip -q android-ndk-${NDK_VERSION}-linux-${ARCH}.zip; - popd; - fi - - > - if [ ! -f "$SBTPATH/sbt" ]; then - pushd $SBTPATH; - wget -q https://raw.githubusercontent.com/paulp/sbt-extras/master/sbt; - chmod a+x sbt; - popd; - fi - -script: - - $SBTPATH/sbt go-build android:package diff --git a/build.gradle b/build.gradle new file mode 100644 index 00000000..ff713c26 --- /dev/null +++ b/build.gradle @@ -0,0 +1,36 @@ +// Top-level build file where you can add configuration options common to all sub-projects/modules. + +buildscript { + ext { + kotlinVersion = '1.2.0' + minSdkVersion = 19 + sdkVersion = 27 + supportLibraryVersion = '27.0.2' + takisoftFixVersion = '27.0.2.0' + playServicesVersion = '11.6.2' + junitVersion = '4.12' + androidTestVersion = '1.0.1' + androidEspressoVersion = '3.0.1' + } + + repositories { + google() + jcenter() + } + dependencies { + classpath 'com.android.tools.build:gradle:3.0.1' + classpath 'com.google.gms:google-services:3.1.1' + classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlinVersion" + } +} + +allprojects { + repositories { + google() + jcenter() + } +} + +task clean(type: Delete) { + delete rootProject.buildDir +} diff --git a/build.sbt b/build.sbt deleted file mode 100644 index acb71230..00000000 --- a/build.sbt +++ /dev/null @@ -1,56 +0,0 @@ -lazy val commonSettings = Seq( - scalaVersion := "2.11.12", - dexMaxHeap := "4g", - - organization := "com.github.shadowsocks", - - platformTarget := "android-27", - - compileOrder := CompileOrder.JavaThenScala, - javacOptions ++= "-source" :: "1.7" :: "-target" :: "1.7" :: Nil, - scalacOptions ++= "-target:jvm-1.7" :: "-Xexperimental" :: Nil, - ndkArgs := "-j" :: java.lang.Runtime.getRuntime.availableProcessors.toString :: Nil, - ndkAbiFilter := Seq("armeabi-v7a", "arm64-v8a", "x86"), - - proguardVersion := "5.3.3", - proguardCache := Seq(), - - shrinkResources := true, - typedResources := false, - - resConfigs := Seq("fa", "ja", "ko", "ru", "zh-rCN", "zh-rTW"), - - resolvers += Resolver.jcenterRepo, - resolvers += Resolver.bintrayRepo("gericop", "maven"), - resolvers += "google" at "https://maven.google.com" -) - -val supportLibsVersion = "27.0.2" -val takisoftFixVersion = "27.0.2.0" -lazy val root = Project(id = "shadowsocks-android", base = file(".")) - .settings(commonSettings) - .aggregate(plugin, mobile) - -install in Android := (install in (mobile, Android)).value -run in Android := (run in (mobile, Android)).evaluated - -lazy val plugin = project - .settings(commonSettings) - .settings( - libraryDependencies ++= - "com.android.support" % "preference-v14" % supportLibsVersion :: - "com.takisoft.fix" % "preference-v7" % takisoftFixVersion :: - "com.takisoft.fix" % "preference-v7-simplemenu" % takisoftFixVersion :: - Nil - ) - -lazy val mobile = project - .settings(commonSettings) - .settings( - libraryDependencies ++= - "com.android.support" % "customtabs" % supportLibsVersion :: - "com.android.support" % "design" % supportLibsVersion :: - "com.android.support" % "gridlayout-v7" % supportLibsVersion :: - Nil - ) - .dependsOn(plugin) diff --git a/core/.gitignore b/core/.gitignore new file mode 100644 index 00000000..796b96d1 --- /dev/null +++ b/core/.gitignore @@ -0,0 +1 @@ +/build diff --git a/core/build.gradle b/core/build.gradle new file mode 100644 index 00000000..d0df0468 --- /dev/null +++ b/core/build.gradle @@ -0,0 +1,40 @@ +apply plugin: 'com.android.library' + +android { + compileSdkVersion rootProject.sdkVersion + + defaultConfig { + minSdkVersion rootProject.minSdkVersion + targetSdkVersion rootProject.sdkVersion + versionCode 1 + versionName "1.0.0" + + testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner" + + externalNativeBuild { + ndkBuild { + abiFilters 'armeabi-v7a', 'arm64-v8a', 'x86' + arguments "-j${Runtime.runtime.availableProcessors()}" + } + } + } + + buildTypes { + release { + minifyEnabled false + } + } + + externalNativeBuild { + ndkBuild { + path 'src/main/jni/Android.mk' + } + } +} + +dependencies { + api project(':plugin') + testImplementation "junit:junit:$junitVersion" + androidTestImplementation "com.android.support.test:runner:$androidTestVersion" + androidTestImplementation "com.android.support.test.espresso:espresso-core:$androidEspressoVersion" +} diff --git a/mobile/gfwlist/gen.pl b/core/gfwlist/gen.pl similarity index 100% rename from mobile/gfwlist/gen.pl rename to core/gfwlist/gen.pl diff --git a/mobile/gfwlist/gen.py b/core/gfwlist/gen.py similarity index 100% rename from mobile/gfwlist/gen.py rename to core/gfwlist/gen.py diff --git a/core/src/androidTest/java/com/github/shadowsocks/ExampleInstrumentedTest.java b/core/src/androidTest/java/com/github/shadowsocks/ExampleInstrumentedTest.java new file mode 100644 index 00000000..defcdd50 --- /dev/null +++ b/core/src/androidTest/java/com/github/shadowsocks/ExampleInstrumentedTest.java @@ -0,0 +1,26 @@ +package com.github.shadowsocks; + +import android.content.Context; +import android.support.test.InstrumentationRegistry; +import android.support.test.runner.AndroidJUnit4; + +import org.junit.Test; +import org.junit.runner.RunWith; + +import static org.junit.Assert.*; + +/** + * Instrumented test, which will execute on an Android device. + * + * @see Testing documentation + */ +@RunWith(AndroidJUnit4.class) +public class ExampleInstrumentedTest { + @Test + public void useAppContext() throws Exception { + // Context of the app under test. + Context appContext = InstrumentationRegistry.getTargetContext(); + + assertEquals("com.github.shadowsockscorelibraries.test", appContext.getPackageName()); + } +} diff --git a/core/src/main/AndroidManifest.xml b/core/src/main/AndroidManifest.xml new file mode 100644 index 00000000..2c7522bf --- /dev/null +++ b/core/src/main/AndroidManifest.xml @@ -0,0 +1,2 @@ + diff --git a/mobile/src/main/java/com/github/shadowsocks/JniHelper.java b/core/src/main/java/com/github/shadowsocks/JniHelper.java similarity index 100% rename from mobile/src/main/java/com/github/shadowsocks/JniHelper.java rename to core/src/main/java/com/github/shadowsocks/JniHelper.java diff --git a/mobile/src/main/jni/Android.mk b/core/src/main/jni/Android.mk similarity index 100% rename from mobile/src/main/jni/Android.mk rename to core/src/main/jni/Android.mk diff --git a/mobile/src/main/jni/badvpn b/core/src/main/jni/badvpn similarity index 100% rename from mobile/src/main/jni/badvpn rename to core/src/main/jni/badvpn diff --git a/mobile/src/main/jni/build-shared-executable.mk b/core/src/main/jni/build-shared-executable.mk similarity index 100% rename from mobile/src/main/jni/build-shared-executable.mk rename to core/src/main/jni/build-shared-executable.mk diff --git a/mobile/src/main/jni/include/libev/config.h b/core/src/main/jni/include/libev/config.h similarity index 100% rename from mobile/src/main/jni/include/libev/config.h rename to core/src/main/jni/include/libev/config.h diff --git a/mobile/src/main/jni/include/pdnsd/config.h b/core/src/main/jni/include/pdnsd/config.h similarity index 100% rename from mobile/src/main/jni/include/pdnsd/config.h rename to core/src/main/jni/include/pdnsd/config.h diff --git a/mobile/src/main/jni/include/shadowsocks-libev/config.h b/core/src/main/jni/include/shadowsocks-libev/config.h similarity index 100% rename from mobile/src/main/jni/include/shadowsocks-libev/config.h rename to core/src/main/jni/include/shadowsocks-libev/config.h diff --git a/mobile/src/main/jni/include/sodium/version.h b/core/src/main/jni/include/sodium/version.h similarity index 100% rename from mobile/src/main/jni/include/sodium/version.h rename to core/src/main/jni/include/sodium/version.h diff --git a/mobile/src/main/jni/jni-helper.cpp b/core/src/main/jni/jni-helper.cpp similarity index 100% rename from mobile/src/main/jni/jni-helper.cpp rename to core/src/main/jni/jni-helper.cpp diff --git a/mobile/src/main/jni/libancillary b/core/src/main/jni/libancillary similarity index 100% rename from mobile/src/main/jni/libancillary rename to core/src/main/jni/libancillary diff --git a/mobile/src/main/jni/libev b/core/src/main/jni/libev similarity index 100% rename from mobile/src/main/jni/libev rename to core/src/main/jni/libev diff --git a/mobile/src/main/jni/libevent b/core/src/main/jni/libevent similarity index 100% rename from mobile/src/main/jni/libevent rename to core/src/main/jni/libevent diff --git a/mobile/src/main/jni/libsodium b/core/src/main/jni/libsodium similarity index 100% rename from mobile/src/main/jni/libsodium rename to core/src/main/jni/libsodium diff --git a/mobile/src/main/jni/mbedtls b/core/src/main/jni/mbedtls similarity index 100% rename from mobile/src/main/jni/mbedtls rename to core/src/main/jni/mbedtls diff --git a/mobile/src/main/jni/pcre b/core/src/main/jni/pcre similarity index 100% rename from mobile/src/main/jni/pcre rename to core/src/main/jni/pcre diff --git a/mobile/src/main/jni/redsocks b/core/src/main/jni/redsocks similarity index 100% rename from mobile/src/main/jni/redsocks rename to core/src/main/jni/redsocks diff --git a/mobile/src/main/jni/shadowsocks-libev b/core/src/main/jni/shadowsocks-libev similarity index 100% rename from mobile/src/main/jni/shadowsocks-libev rename to core/src/main/jni/shadowsocks-libev diff --git a/mobile/src/overture/go b/core/src/overture/go similarity index 100% rename from mobile/src/overture/go rename to core/src/overture/go diff --git a/mobile/src/overture/make.bash b/core/src/overture/make.bash similarity index 100% rename from mobile/src/overture/make.bash rename to core/src/overture/make.bash diff --git a/mobile/src/overture/src/github.com/shadowsocks/overture b/core/src/overture/src/github.com/shadowsocks/overture similarity index 100% rename from mobile/src/overture/src/github.com/shadowsocks/overture rename to core/src/overture/src/github.com/shadowsocks/overture diff --git a/core/src/test/java/com/github/shadowsocks/ExampleUnitTest.java b/core/src/test/java/com/github/shadowsocks/ExampleUnitTest.java new file mode 100644 index 00000000..997edea9 --- /dev/null +++ b/core/src/test/java/com/github/shadowsocks/ExampleUnitTest.java @@ -0,0 +1,17 @@ +package com.github.shadowsocks; + +import org.junit.Test; + +import static org.junit.Assert.*; + +/** + * Example local unit test, which will execute on the development machine (host). + * + * @see Testing documentation + */ +public class ExampleUnitTest { + @Test + public void addition_isCorrect() throws Exception { + assertEquals(4, 2 + 2); + } +} \ No newline at end of file diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 00000000..aac7c9b4 --- /dev/null +++ b/gradle.properties @@ -0,0 +1,17 @@ +# Project-wide Gradle settings. + +# IDE (e.g. Android Studio) users: +# Gradle settings configured through the IDE *will override* +# any settings specified in this file. + +# For more details on how to configure your build environment visit +# http://www.gradle.org/docs/current/userguide/build_environment.html + +# Specifies the JVM arguments used for the daemon process. +# The setting is particularly useful for tweaking memory settings. +org.gradle.jvmargs=-Xmx1536m + +# When configured, Gradle will run in incubating parallel mode. +# This option should only be used with decoupled projects. More details, visit +# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects +# org.gradle.parallel=true diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 00000000..13372aef Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 00000000..88632036 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,6 @@ +#Fri Dec 01 17:03:17 PST 2017 +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-4.1-all.zip diff --git a/gradlew b/gradlew new file mode 100755 index 00000000..9d82f789 --- /dev/null +++ b/gradlew @@ -0,0 +1,160 @@ +#!/usr/bin/env bash + +############################################################################## +## +## Gradle start up script for UN*X +## +############################################################################## + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS="" + +APP_NAME="Gradle" +APP_BASE_NAME=`basename "$0"` + +# 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 +case "`uname`" in + CYGWIN* ) + cygwin=true + ;; + Darwin* ) + darwin=true + ;; + MINGW* ) + msys=true + ;; +esac + +# 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 + +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" ] ; 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, switch paths to Windows format before running java +if $cygwin ; 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=$((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 + +# Split up the JVM_OPTS And GRADLE_OPTS values into an array, following the shell quoting and substitution rules +function splitJvmOpts() { + JVM_OPTS=("$@") +} +eval splitJvmOpts $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS +JVM_OPTS[${#JVM_OPTS[*]}]="-Dorg.gradle.appname=$APP_BASE_NAME" + +exec "$JAVACMD" "${JVM_OPTS[@]}" -classpath "$CLASSPATH" org.gradle.wrapper.GradleWrapperMain "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 00000000..aec99730 --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,90 @@ +@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 + +@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= + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@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 Windowz variants + +if not "%OS%" == "Windows_NT" goto win9xME_args +if "%@eval[2+2]" == "4" goto 4NT_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=%* +goto execute + +:4NT_args +@rem Get arguments from the 4NT Shell from JP Software +set CMD_LINE_ARGS=%$ + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/mobile/.gitignore b/mobile/.gitignore new file mode 100644 index 00000000..796b96d1 --- /dev/null +++ b/mobile/.gitignore @@ -0,0 +1 @@ +/build diff --git a/mobile/build.gradle b/mobile/build.gradle new file mode 100644 index 00000000..48e42a1e --- /dev/null +++ b/mobile/build.gradle @@ -0,0 +1,85 @@ +import java.util.regex.Matcher +import java.util.regex.Pattern + +apply plugin: 'com.android.application' +apply plugin: 'kotlin-android' + +def getCurrentFlavor() { + Gradle gradle = getGradle() + String tskReqStr = gradle.getStartParameter().getTaskRequests().toString() + + Pattern pattern; + + if( tskReqStr.contains( "assemble" ) ) + pattern = Pattern.compile("assemble(\\w+)(Release|Debug)") + else + pattern = Pattern.compile("generate(\\w+)(Release|Debug)") + + Matcher matcher = pattern.matcher( tskReqStr ) + + if( matcher.find() ) + return matcher.group(1).toLowerCase() + else + { + println "NO MATCH FOUND" + return ""; + } +} + +android { + compileSdkVersion rootProject.sdkVersion + defaultConfig { + applicationId "com.github.shadowsocks" + minSdkVersion rootProject.minSdkVersion + targetSdkVersion rootProject.sdkVersion + versionCode 196 + versionName "4.3.0" + testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner" + vectorDrawables.useSupportLibrary true + } + buildTypes { + release { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' + } + } + sourceSets.main.jniLibs.srcDirs += + new File(project(':core').buildDir, "intermediates/bundles/${getCurrentFlavor()}/jni") + sourceSets.main.jniLibs.srcDirs += new File(project(':core').projectDir, "src/main/jni/overture") +} + +dependencies { + implementation project(':core') + implementation "com.android.support:customtabs:$supportLibraryVersion" + implementation "com.android.support:design:$supportLibraryVersion" + implementation "com.android.support:gridlayout-v7:$supportLibraryVersion" + implementation 'com.futuremind.recyclerfastscroll:fastscroll:0.2.5' + implementation 'com.evernote:android-job:1.2.1' + implementation 'com.github.jorgecastilloprz:fabprogresscircle:1.01' + implementation "com.google.android.gms:play-services-ads:$playServicesVersion" + implementation "com.google.android.gms:play-services-analytics:$playServicesVersion" + implementation "com.google.android.gms:play-services-gcm:$playServicesVersion" + implementation "com.google.firebase:firebase-config:$playServicesVersion" + implementation 'com.j256.ormlite:ormlite-android:5.0' + implementation 'com.mikepenz:crossfader:1.5.1' + implementation 'com.mikepenz:fastadapter:3.0.4' + implementation 'com.mikepenz:iconics-core:3.0.0' + implementation 'com.mikepenz:materialdrawer:6.0.2' + implementation 'com.mikepenz:materialize:1.1.2' + implementation 'com.squareup.okhttp3:okhttp:3.9.1' + implementation "com.takisoft.fix:preference-v7-simplemenu:$takisoftFixVersion" + implementation 'com.twofortyfouram:android-plugin-api-for-locale:1.0.2' + implementation 'dnsjava:dnsjava:2.1.8' + implementation 'eu.chainfire:libsuperuser:1.0.0.201704021214' + implementation 'me.dm7.barcodescanner:zxing:1.9.8' + implementation 'net.glxn.qrgen:android:2.0' + implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlinVersion" + testImplementation "junit:junit:$junitVersion" + androidTestImplementation "com.android.support.test:runner:$androidTestVersion" + androidTestImplementation "com.android.support.test.espresso:espresso-core:$androidEspressoVersion" +} +repositories { + mavenCentral() +} + +apply plugin: 'com.google.gms.google-services' diff --git a/mobile/build.sbt b/mobile/build.sbt deleted file mode 100644 index 8bcaab12..00000000 --- a/mobile/build.sbt +++ /dev/null @@ -1,71 +0,0 @@ -enablePlugins(AndroidApp) -enablePlugins(AndroidGms) - -android.useSupportVectors - -name := "shadowsocks" -version := "4.3.0" -versionCode := Some(196) - -proguardOptions ++= - "-dontwarn com.google.android.gms.internal.**" :: - "-dontwarn com.j256.ormlite.**" :: - "-dontwarn okhttp3.**" :: - "-dontwarn okio.**" :: - "-dontwarn org.xbill.**" :: - "-keep class com.github.shadowsocks.JniHelper { *; }" :: - "-dontwarn com.evernote.android.job.gcm.**" :: - "-dontwarn com.evernote.android.job.util.GcmAvailableHelper" :: - "-keep public class com.evernote.android.job.v21.PlatformJobService" :: - "-keep public class com.evernote.android.job.v14.PlatformAlarmService" :: - "-keep public class com.evernote.android.job.v14.PlatformAlarmReceiver" :: - "-keep public class com.evernote.android.job.JobBootReceiver" :: - "-keep public class com.evernote.android.job.JobRescheduleService" :: - Nil - -val playServicesVersion = "11.6.2" -resolvers += Resolver.jcenterRepo -libraryDependencies ++= - "com.futuremind.recyclerfastscroll" % "fastscroll" % "0.2.5" :: - "com.evernote" % "android-job" % "1.2.1" :: - "com.github.jorgecastilloprz" % "fabprogresscircle" % "1.01" :: - "com.google.android.gms" % "play-services-ads" % playServicesVersion :: - "com.google.android.gms" % "play-services-analytics" % playServicesVersion :: - "com.google.android.gms" % "play-services-gcm" % playServicesVersion :: - "com.google.firebase" % "firebase-config" % playServicesVersion :: - "com.j256.ormlite" % "ormlite-android" % "5.0" :: - "com.mikepenz" % "crossfader" % "1.5.1" :: - "com.mikepenz" % "fastadapter" % "3.0.4" :: - "com.mikepenz" % "iconics-core" % "3.0.0" :: - "com.mikepenz" % "materialdrawer" % "6.0.2" :: - "com.mikepenz" % "materialize" % "1.1.2" :: - "com.squareup.okhttp3" % "okhttp" % "3.9.1" :: - "com.twofortyfouram" % "android-plugin-api-for-locale" % "1.0.2" :: - "dnsjava" % "dnsjava" % "2.1.8" :: - "eu.chainfire" % "libsuperuser" % "1.0.0.201704021214" :: - "me.dm7.barcodescanner" % "zxing" % "1.9.8" :: - "net.glxn.qrgen" % "android" % "2.0" :: - Nil - -packagingOptions := PackagingOptions(excludes = - "META-INF/maven/com.squareup.okio/okio/pom.properties" :: - "META-INF/maven/com.squareup.okio/okio/pom.xml" :: - "META-INF/maven/com.squareup.okhttp3/okhttp/pom.properties" :: - "META-INF/maven/com.squareup.okhttp3/okhttp/pom.xml" :: - Nil) - -lazy val goClean: TaskKey[Unit] = TaskKey[Unit]("go-clean", "Clean go build dependencies") -goClean := { - IO.delete(baseDirectory(base => base / "src/overture/.deps").value) - IO.delete(baseDirectory(base => base / "src/overture/bin").value) - IO.delete(baseDirectory(base => base / "src/overture/go/bin").value) - IO.delete(baseDirectory(base => base / "src/main/jni/overture").value) -} - -lazy val goBuild: TaskKey[Unit] = TaskKey[Unit]("go-build", "Build go and overture") -goBuild := { - Process(Seq("mobile/src/overture/make.bash", minSdkVersion.value)) ! streams.value.log match { - case 0 => // Success! - case n => sys.error(s"Native build script exit code: $n") - } -} diff --git a/mobile/lint.xml b/mobile/lint.xml deleted file mode 100644 index cb6d4839..00000000 --- a/mobile/lint.xml +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/mobile/local.properties.example b/mobile/local.properties.example deleted file mode 100644 index d23c53ef..00000000 --- a/mobile/local.properties.example +++ /dev/null @@ -1,3 +0,0 @@ -key.alias: your_key_alias -key.store: /path/to/your/key/store -key.store.password: your_key_password diff --git a/mobile/proguard-rules.pro b/mobile/proguard-rules.pro new file mode 100644 index 00000000..f1b42451 --- /dev/null +++ b/mobile/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile diff --git a/mobile/src/androidTest/java/com/github/shadowsocks/ExampleInstrumentedTest.java b/mobile/src/androidTest/java/com/github/shadowsocks/ExampleInstrumentedTest.java new file mode 100644 index 00000000..191c2e05 --- /dev/null +++ b/mobile/src/androidTest/java/com/github/shadowsocks/ExampleInstrumentedTest.java @@ -0,0 +1,26 @@ +package com.github.shadowsocks; + +import android.content.Context; +import android.support.test.InstrumentationRegistry; +import android.support.test.runner.AndroidJUnit4; + +import org.junit.Test; +import org.junit.runner.RunWith; + +import static org.junit.Assert.*; + +/** + * Instrumented test, which will execute on an Android device. + * + * @see Testing documentation + */ +@RunWith(AndroidJUnit4.class) +public class ExampleInstrumentedTest { + @Test + public void useAppContext() throws Exception { + // Context of the app under test. + Context appContext = InstrumentationRegistry.getTargetContext(); + + assertEquals("com.github.shadowsocks", appContext.getPackageName()); + } +} diff --git a/mobile/src/main/AndroidManifest.xml b/mobile/src/main/AndroidManifest.xml index 313010a6..11956866 100644 --- a/mobile/src/main/AndroidManifest.xml +++ b/mobile/src/main/AndroidManifest.xml @@ -1,5 +1,6 @@ + xmlns:tools="http://schemas.android.com/tools" + package="com.github.shadowsocks"> @@ -15,17 +16,14 @@ - - + android:name=".App" + android:icon="@mipmap/ic_launcher" + android:backupAgent=".ConfigBackupHelper" + android:label="@string/app_name" + android:supportsRtl="true" + android:networkSecurityConfig="@xml/network_security_config" + android:banner="@drawable/ic_start_connected"> @@ -33,11 +31,14 @@ android:value="@integer/google_play_services_version"/> + + android:name=".MainActivity" + android:label="@string/app_name" + android:launchMode="singleTask"> @@ -62,32 +63,32 @@ + android:name=".ProfileConfigActivity" + android:excludeFromRecents="true" + android:label="@string/profile_config" + android:launchMode="singleTask"/> + android:name=".VpnRequestActivity" + android:theme="@android:style/Theme.Translucent.NoTitleBar" + android:excludeFromRecents="true" + android:taskAffinity="" + android:launchMode="singleTask"/> + android:name=".AppManager" + android:label="@string/proxied_apps" + android:parentActivityName=".ProfileConfigActivity" + android:excludeFromRecents="true" + android:launchMode="singleTask"/> + android:name=".ScannerActivity" + android:label="@string/add_profile_methods_scan_qr_code" + android:parentActivityName=".MainActivity" + android:excludeFromRecents="true"/> - @@ -95,24 +96,24 @@ + android:name=".QuickToggleShortcut" + android:label="@string/quick_toggle" + android:theme="@android:style/Theme.Translucent.NoTitleBar" + android:excludeFromRecents="true" + android:taskAffinity="" + android:process=":bg" + android:launchMode="singleTask"> + android:name=".bg.VpnService" + android:process=":bg" + android:label="@string/app_name" + android:permission="android.permission.BIND_VPN_SERVICE" + android:exported="false"> @@ -130,7 +131,7 @@ android:exported="false"> - @@ -145,7 +146,7 @@ - + @@ -165,5 +166,4 @@ - diff --git a/mobile/src/main/assets/fonts/Iceland.ttf b/mobile/src/main/assets/fonts/Iceland.ttf deleted file mode 100644 index df1938c2..00000000 Binary files a/mobile/src/main/assets/fonts/Iceland.ttf and /dev/null differ diff --git a/mobile/src/main/java/com/github/shadowsocks/AboutFragment.kt b/mobile/src/main/java/com/github/shadowsocks/AboutFragment.kt new file mode 100644 index 00000000..9dacc02b --- /dev/null +++ b/mobile/src/main/java/com/github/shadowsocks/AboutFragment.kt @@ -0,0 +1,56 @@ +/******************************************************************************* + * * + * Copyright (C) 2017 by Max Lv * + * Copyright (C) 2017 by Mygod Studio * + * * + * This program is free software: you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation, either version 3 of the License, or * + * (at your option) any later version. * + * * + * This program is distributed in the hope that it will be useful, * + * but WITHOUT ANY WARRANTY; without even the implied warranty of * + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * + * GNU General Public License for more details. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program. If not, see . * + * * + *******************************************************************************/ + +package com.github.shadowsocks + +import android.annotation.TargetApi +import android.os.Build +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.webkit.WebResourceRequest +import android.webkit.WebView +import android.webkit.WebViewClient + +class AboutFragment : ToolbarFragment() { + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? = + inflater.inflate(R.layout.layout_about, container, false) + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + toolbar.title = getString(R.string.about_title, BuildConfig.VERSION_NAME) + val web = view.findViewById(R.id.web_view) + web.loadUrl("file:///android_asset/pages/about.html") + web.webViewClient = object : WebViewClient() { + @Suppress("OverridingDeprecatedMember") + override fun shouldOverrideUrlLoading(view: WebView, url: String): Boolean { + (activity as MainActivity).launchUrl(url) + return true + } + + @TargetApi(Build.VERSION_CODES.LOLLIPOP) + override fun shouldOverrideUrlLoading(view: WebView?, request: WebResourceRequest): Boolean { + (activity as MainActivity).launchUrl(request.url) + return true + } + } + } +} diff --git a/mobile/src/main/java/com/github/shadowsocks/App.kt b/mobile/src/main/java/com/github/shadowsocks/App.kt new file mode 100644 index 00000000..8156f6ee --- /dev/null +++ b/mobile/src/main/java/com/github/shadowsocks/App.kt @@ -0,0 +1,221 @@ +/******************************************************************************* + * * + * Copyright (C) 2017 by Max Lv * + * Copyright (C) 2017 by Mygod Studio * + * * + * This program is free software: you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation, either version 3 of the License, or * + * (at your option) any later version. * + * * + * This program is distributed in the hope that it will be useful, * + * but WITHOUT ANY WARRANTY; without even the implied warranty of * + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * + * GNU General Public License for more details. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program. If not, see . * + * * + *******************************************************************************/ + +package com.github.shadowsocks + +import android.app.Application +import android.app.NotificationChannel +import android.app.NotificationManager +import android.content.BroadcastReceiver +import android.content.Intent +import android.content.IntentFilter +import android.content.pm.PackageInfo +import android.content.pm.PackageManager +import android.content.res.Configuration +import android.os.Build +import android.os.Handler +import android.os.LocaleList +import android.os.Looper +import android.support.annotation.RequiresApi +import android.support.v7.app.AppCompatDelegate +import android.util.Log +import com.evernote.android.job.JobManager +import com.github.shadowsocks.acl.AclSyncJob +import com.github.shadowsocks.bg.BaseService +import com.github.shadowsocks.database.Profile +import com.github.shadowsocks.database.ProfileManager +import com.github.shadowsocks.preference.BottomSheetPreferenceDialogFragment +import com.github.shadowsocks.preference.DataStore +import com.github.shadowsocks.preference.IconListPreference +import com.github.shadowsocks.utils.Action +import com.github.shadowsocks.utils.Key +import com.github.shadowsocks.utils.TcpFastOpen +import com.github.shadowsocks.utils.broadcastReceiver +import com.google.android.gms.analytics.GoogleAnalytics +import com.google.android.gms.analytics.HitBuilders +import com.google.android.gms.analytics.StandardExceptionParser +import com.google.android.gms.analytics.Tracker +import com.google.firebase.FirebaseApp +import com.google.firebase.remoteconfig.FirebaseRemoteConfig +import com.j256.ormlite.logger.LocalLog +import com.takisoft.fix.support.v7.preference.PreferenceFragmentCompat +import java.io.File +import java.io.IOException +import java.util.* + +class App : Application() { + companion object { + lateinit var app: App + private const val TAG = "ShadowsocksApplication" + + // The ones in Locale doesn't have script included + private val SIMPLIFIED_CHINESE by lazy { + if (Build.VERSION.SDK_INT >= 21) Locale.forLanguageTag("zh-Hans-CN") else Locale.SIMPLIFIED_CHINESE + } + private val TRADITIONAL_CHINESE by lazy { + if (Build.VERSION.SDK_INT >= 21) Locale.forLanguageTag("zh-Hant-TW") else Locale.TRADITIONAL_CHINESE + } + } + + val handler by lazy { Handler(Looper.getMainLooper()) } + val remoteConfig: FirebaseRemoteConfig by lazy { FirebaseRemoteConfig.getInstance() } + private val tracker: Tracker by lazy { GoogleAnalytics.getInstance(this).newTracker(R.xml.tracker) } + val info: PackageInfo by lazy { packageManager.getPackageInfo(packageName, PackageManager.GET_SIGNATURES) } + + fun startService() { + val intent = Intent(this, BaseService.serviceClass.java) + if (Build.VERSION.SDK_INT >= 26) startForegroundService(intent) else startService(intent) + } + fun reloadService() = sendBroadcast(Intent(Action.RELOAD)) + fun stopService() = sendBroadcast(Intent(Action.CLOSE)) + + val currentProfile: Profile? get() = ProfileManager.getProfile(DataStore.profileId) + + fun switchProfile(id: Int): Profile { + val result = ProfileManager.getProfile(id) ?: ProfileManager.createProfile() + DataStore.profileId = result.id + return result + } + + // send event + fun track(category: String, action: String) = tracker.send(HitBuilders.EventBuilder() + .setCategory(category) + .setAction(action) + .setLabel(BuildConfig.VERSION_NAME) + .build()) + fun track(t: Throwable) = track(Thread.currentThread(), t) + fun track(thread: Thread, t: Throwable) { + tracker.send(HitBuilders.ExceptionBuilder() + .setDescription(StandardExceptionParser(this, null).getDescription(thread.name, t)) + .setFatal(false) + .build()) + t.printStackTrace() + } + + private fun checkChineseLocale(config: Configuration) { + fun check(locale: Locale): Locale? { + if (locale.language != "zh") return null + when (locale.country) { "CN", "TW" -> return null } + if (Build.VERSION.SDK_INT >= 21) when (locale.script) { + "Hans" -> return SIMPLIFIED_CHINESE + "Hant" -> return TRADITIONAL_CHINESE + else -> Log.w(TAG, "Unknown zh locale script: ${locale.script}. Falling back to trying countries...") + } + when (locale.country) { + "SG" -> return SIMPLIFIED_CHINESE + "HK", "MO" -> return TRADITIONAL_CHINESE + } + Log.w(TAG, "Unknown zh locale: %s. Falling back to zh-Hans-CN..." + .format(Locale.ENGLISH, if (Build.VERSION.SDK_INT >= 21) locale.toLanguageTag() else locale)) + return SIMPLIFIED_CHINESE + } + if (Build.VERSION.SDK_INT >= 24) @RequiresApi(24) { + val localeList = config.locales + var changed = false + val newList = Array(localeList.size(), { i -> + val locale = localeList[i] + val newLocale = check(locale) + if (newLocale == null) locale else { + changed = true + newLocale + } + }) + if (changed) { + val newConfig = Configuration(config) + newConfig.locales = LocaleList(*(newList.distinct().toTypedArray())) + val res = resources + res.updateConfiguration(newConfig, res.displayMetrics) + } + } else { + @Suppress("DEPRECATION") + val newLocale = check(config.locale) + if (newLocale != null) { + val newConfig = Configuration(config) + @Suppress("DEPRECATION") + newConfig.locale = newLocale + val res = resources + res.updateConfiguration(newConfig, res.displayMetrics) + } + } + } + + override fun onCreate() { + super.onCreate() + app = this + if (!BuildConfig.DEBUG) System.setProperty(LocalLog.LOCAL_LOG_LEVEL_PROPERTY, "ERROR") + AppCompatDelegate.setCompatVectorFromResourcesEnabled(true) + checkChineseLocale(resources.configuration) + + FirebaseApp.initializeApp(this) + remoteConfig.setDefaults(R.xml.default_configs) + remoteConfig.fetch().addOnCompleteListener { + if (it.isSuccessful) remoteConfig.activateFetched() else Log.e(TAG, "Failed to fetch config") + } + + JobManager.create(this).addJobCreator(AclSyncJob) + PreferenceFragmentCompat.registerPreferenceFragment(IconListPreference::class.java, + BottomSheetPreferenceDialogFragment::class.java) + + TcpFastOpen.enabled(DataStore.getBoolean(Key.tfo, TcpFastOpen.sendEnabled)) + + if (DataStore.getLong(Key.assetUpdateTime, -1) != info.lastUpdateTime) { + val assetManager = assets + for (dir in arrayOf("acl", "overture")) + try { + for (file in assetManager.list(dir)) assetManager.open(dir + '/' + file).use { input -> + File(filesDir, file).outputStream().use { output -> input.copyTo(output) } + } + } catch (e: IOException) { + Log.e(TAG, e.message) + app.track(e) + } + DataStore.putLong(Key.assetUpdateTime, info.lastUpdateTime) + } + + if (Build.VERSION.SDK_INT >= 26) @RequiresApi(26) { + val nm = getSystemService(NotificationManager::class.java) + nm.createNotificationChannels(listOf( + NotificationChannel("service-vpn", getText(R.string.service_vpn), + NotificationManager.IMPORTANCE_LOW), + NotificationChannel("service-proxy", getText(R.string.service_proxy), + NotificationManager.IMPORTANCE_LOW), + NotificationChannel("service-transproxy", getText(R.string.service_transproxy), + NotificationManager.IMPORTANCE_LOW))) + nm.deleteNotificationChannel("service-nat") // NAT mode is gone for good + } + } + + override fun onConfigurationChanged(newConfig: Configuration) { + super.onConfigurationChanged(newConfig) + checkChineseLocale(newConfig) + } + + fun listenForPackageChanges(callback: () -> Unit): BroadcastReceiver { + val filter = IntentFilter(Intent.ACTION_PACKAGE_ADDED) + filter.addAction(Intent.ACTION_PACKAGE_REMOVED) + filter.addDataScheme("package") + val result = broadcastReceiver { _, intent -> + if (intent.action != Intent.ACTION_PACKAGE_REMOVED || + !intent.getBooleanExtra(Intent.EXTRA_REPLACING, false)) callback() + } + app.registerReceiver(result, filter) + return result + } +} diff --git a/mobile/src/main/java/com/github/shadowsocks/AppManager.kt b/mobile/src/main/java/com/github/shadowsocks/AppManager.kt new file mode 100644 index 00000000..62b4a358 --- /dev/null +++ b/mobile/src/main/java/com/github/shadowsocks/AppManager.kt @@ -0,0 +1,269 @@ +/******************************************************************************* + * * + * Copyright (C) 2017 by Max Lv * + * Copyright (C) 2017 by Mygod Studio * + * * + * This program is free software: you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation, either version 3 of the License, or * + * (at your option) any later version. * + * * + * This program is distributed in the hope that it will be useful, * + * but WITHOUT ANY WARRANTY; without even the implied warranty of * + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * + * GNU General Public License for more details. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program. If not, see . * + * * + *******************************************************************************/ + +package com.github.shadowsocks + +import android.Manifest +import android.animation.Animator +import android.animation.AnimatorListenerAdapter +import android.annotation.SuppressLint +import android.content.BroadcastReceiver +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import android.content.pm.PackageManager +import android.graphics.drawable.Drawable +import android.os.Bundle +import android.os.Handler +import android.support.v4.app.TaskStackBuilder +import android.support.v7.app.AppCompatActivity +import android.support.v7.widget.DefaultItemAnimator +import android.support.v7.widget.LinearLayoutManager +import android.support.v7.widget.RecyclerView +import android.support.v7.widget.Toolbar +import android.view.* +import android.widget.ImageView +import android.widget.Switch +import android.widget.Toast +import com.futuremind.recyclerviewfastscroll.FastScroller +import com.futuremind.recyclerviewfastscroll.SectionTitleProvider +import com.github.shadowsocks.App.Companion.app +import com.github.shadowsocks.database.ProfileManager +import com.github.shadowsocks.preference.DataStore +import com.github.shadowsocks.utils.Key +import com.github.shadowsocks.utils.thread +import java.util.concurrent.atomic.AtomicBoolean + +class AppManager : AppCompatActivity(), Toolbar.OnMenuItemClickListener { + companion object { + data class ProxiedApp(val name: String, val packageName: String, val icon: Drawable) + + @SuppressLint("StaticFieldLeak") + private var instance: AppManager? = null + + private var receiver: BroadcastReceiver? = null + private var cachedApps: Array? = null + private fun getApps(pm: PackageManager): Array { + if (receiver == null) receiver = app.listenForPackageChanges { + synchronized(AppManager) { cachedApps = null } + AppManager.instance?.reloadApps() + } + return synchronized(AppManager) { + val cachedApps = cachedApps ?: pm.getInstalledPackages(PackageManager.GET_PERMISSIONS) + .filter { it.requestedPermissions?.contains(Manifest.permission.INTERNET) ?: false } + .map { ProxiedApp(pm.getApplicationLabel(it.applicationInfo).toString(), it.packageName, + it.applicationInfo.loadIcon(pm)) } + .toTypedArray() + this.cachedApps = cachedApps + cachedApps + } + } + } + + private inner class AppViewHolder(view: View) : RecyclerView.ViewHolder(view), View.OnClickListener { + private val icon = view.findViewById(R.id.itemicon) + private val check = view.findViewById(R.id.itemcheck) + private lateinit var item: ProxiedApp + private val proxied get() = proxiedApps.contains(item.packageName) + + init { + view.setOnClickListener(this) + } + + fun bind(app: ProxiedApp) { + this.item = app + icon.setImageDrawable(app.icon) + check.text = app.name + check.isChecked = proxied + } + + override fun onClick(v: View?) { + if (proxied) { + proxiedApps.remove(item.packageName) + check.isChecked = false + } else { + proxiedApps.add(item.packageName) + check.isChecked = true + } + if (!appsLoading.get()) { + DataStore.individual = proxiedApps.joinToString("\n") + DataStore.dirty = true + } + } + } + + private inner class AppsAdapter : RecyclerView.Adapter(), SectionTitleProvider { + private val apps = getApps(packageManager).sortedWith(Comparator { a, b -> + when (Pair(proxiedApps.contains(a.packageName), proxiedApps.contains(b.packageName))) { + Pair(true, false) -> -1 + Pair(false, true) -> 1 + else -> a.name.compareTo(b.name, true) + } + }) + + override fun onBindViewHolder(holder: AppViewHolder, position: Int) = holder.bind(apps[position]) + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): AppViewHolder = + AppViewHolder(LayoutInflater.from(parent.context).inflate(R.layout.layout_apps_item, parent, false)) + override fun getItemCount(): Int = apps.size + override fun getSectionTitle(position: Int): String = apps[position].name.substring(0, 1) + } + + private lateinit var proxiedApps: HashSet + private lateinit var toolbar: Toolbar + private lateinit var bypassSwitch: Switch + private lateinit var appListView: RecyclerView + private lateinit var fastScroller: FastScroller + private lateinit var loadingView: View + private val appsLoading = AtomicBoolean() + private val handler = Handler() + private val clipboard by lazy { getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager } + + private fun initProxiedApps(str: String = DataStore.individual) { + proxiedApps = str.split('\n').toHashSet() + } + private fun reloadApps() { + if (!appsLoading.compareAndSet(true, false)) loadAppsAsync() + } + private fun loadAppsAsync() { + if (!appsLoading.compareAndSet(false, true)) return + thread { + var adapter: AppsAdapter? + do { + appsLoading.set(true) + adapter = AppsAdapter() + } while (!appsLoading.compareAndSet(true, false)) + handler.post { + appListView.adapter = adapter + fastScroller.setRecyclerView(appListView) + val shortAnimTime = resources.getInteger(android.R.integer.config_shortAnimTime) + appListView.alpha = 0F + appListView.visibility = View.VISIBLE + appListView.animate().alpha(1F).duration = shortAnimTime.toLong() + fastScroller.alpha = 0F + fastScroller.visibility = View.VISIBLE + fastScroller.animate().alpha(1F).duration = shortAnimTime.toLong() + loadingView.animate().alpha(0F).setListener(object : AnimatorListenerAdapter() { + override fun onAnimationEnd(animation: Animator) { + loadingView.visibility = View.GONE + } + }).duration = shortAnimTime.toLong() + } + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.layout_apps) + toolbar = findViewById(R.id.toolbar) + toolbar.setTitle(R.string.proxied_apps) + toolbar.setNavigationIcon(R.drawable.abc_ic_ab_back_material) + toolbar.setNavigationOnClickListener { + val intent = parentActivityIntent + if (shouldUpRecreateTask(intent) || isTaskRoot) + TaskStackBuilder.create(this).addNextIntentWithParentStack(intent).startActivities() else finish() + } + toolbar.inflateMenu(R.menu.app_manager_menu) + toolbar.setOnMenuItemClickListener(this) + + if (!DataStore.proxyApps) { + DataStore.proxyApps = true + DataStore.dirty = true + } + findViewById(R.id.onSwitch).setOnCheckedChangeListener { _, checked -> + DataStore.proxyApps = checked + DataStore.dirty = true + finish() + } + + bypassSwitch = findViewById(R.id.bypassSwitch) + bypassSwitch.isChecked = DataStore.bypass + bypassSwitch.setOnCheckedChangeListener { _, checked -> + DataStore.bypass = checked + DataStore.dirty = true + } + + initProxiedApps() + loadingView = findViewById(R.id.loading) + appListView = findViewById(R.id.list) + appListView.layoutManager = LinearLayoutManager(this, LinearLayoutManager.VERTICAL, false) + appListView.itemAnimator = DefaultItemAnimator() + fastScroller = findViewById(R.id.fastscroller) + + instance = this + loadAppsAsync() + } + + override fun onMenuItemClick(item: MenuItem): Boolean { + when (item.itemId) { + R.id.action_apply_all -> { + val profiles = ProfileManager.getAllProfiles() + if (profiles != null) { + val proxiedAppString = DataStore.individual + profiles.forEach { + it.individual = proxiedAppString + ProfileManager.updateProfile(it) + } + Toast.makeText(this, R.string.action_apply_all, Toast.LENGTH_SHORT).show() + } else Toast.makeText(this, R.string.action_export_err, Toast.LENGTH_SHORT).show() + return true + } + R.id.action_export -> { + clipboard.primaryClip = ClipData.newPlainText(Key.individual, + "${DataStore.bypass}\n${DataStore.individual}") + Toast.makeText(this, R.string.action_export_msg, Toast.LENGTH_SHORT).show() + return true + } + R.id.action_import -> { + val proxiedAppString = clipboard.primaryClip?.getItemAt(0)?.text?.toString() + if (!proxiedAppString.isNullOrEmpty()) { + val i = proxiedAppString!!.indexOf('\n') + try { + val (enabled, apps) = if (i < 0) Pair(proxiedAppString, "") else + Pair(proxiedAppString.substring(0, i), proxiedAppString.substring(i + 1)) + bypassSwitch.isChecked = enabled.toBoolean() + DataStore.individual = apps + DataStore.dirty = true + Toast.makeText(this, R.string.action_import_msg, Toast.LENGTH_SHORT).show() + appListView.visibility = View.GONE + fastScroller.visibility = View.GONE + loadingView.visibility = View.VISIBLE + initProxiedApps(apps) + reloadApps() + return true + } catch (_: IllegalArgumentException) { } + } + Toast.makeText(this, R.string.action_import_err, Toast.LENGTH_SHORT).show() + } + } + return false + } + + override fun onKeyUp(keyCode: Int, event: KeyEvent?): Boolean { + return if (keyCode == KeyEvent.KEYCODE_MENU) + if (toolbar.isOverflowMenuShowing) toolbar.hideOverflowMenu() else toolbar.showOverflowMenu() + else super.onKeyUp(keyCode, event) + } + + override fun onDestroy() { + instance = null + handler.removeCallbacksAndMessages(null) + super.onDestroy() + } +} diff --git a/mobile/src/main/java/com/github/shadowsocks/BootReceiver.kt b/mobile/src/main/java/com/github/shadowsocks/BootReceiver.kt new file mode 100644 index 00000000..8e1c8f87 --- /dev/null +++ b/mobile/src/main/java/com/github/shadowsocks/BootReceiver.kt @@ -0,0 +1,44 @@ +/******************************************************************************* + * * + * Copyright (C) 2017 by Max Lv * + * Copyright (C) 2017 by Mygod Studio * + * * + * This program is free software: you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation, either version 3 of the License, or * + * (at your option) any later version. * + * * + * This program is distributed in the hope that it will be useful, * + * but WITHOUT ANY WARRANTY; without even the implied warranty of * + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * + * GNU General Public License for more details. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program. If not, see . * + * * + *******************************************************************************/ + +package com.github.shadowsocks + +import android.content.BroadcastReceiver +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import com.github.shadowsocks.App.Companion.app + +class BootReceiver : BroadcastReceiver() { + companion object { + private val componentName by lazy { ComponentName(app, BootReceiver::class.java) } + var enabled: Boolean + get() = app.packageManager.getComponentEnabledSetting(componentName) == + PackageManager.COMPONENT_ENABLED_STATE_ENABLED + set(value) = app.packageManager.setComponentEnabledSetting(componentName, + if (value) PackageManager.COMPONENT_ENABLED_STATE_ENABLED + else PackageManager.COMPONENT_ENABLED_STATE_DISABLED, PackageManager.DONT_KILL_APP) + } + + override fun onReceive(context: Context, intent: Intent) { + if (intent.action == Intent.ACTION_BOOT_COMPLETED) app.startService() + } +} diff --git a/mobile/src/main/java/com/github/shadowsocks/ConfigBackupHelper.kt b/mobile/src/main/java/com/github/shadowsocks/ConfigBackupHelper.kt new file mode 100644 index 00000000..98f62969 --- /dev/null +++ b/mobile/src/main/java/com/github/shadowsocks/ConfigBackupHelper.kt @@ -0,0 +1,31 @@ +/******************************************************************************* + * * + * Copyright (C) 2017 by Max Lv * + * Copyright (C) 2017 by Mygod Studio * + * * + * This program is free software: you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation, either version 3 of the License, or * + * (at your option) any later version. * + * * + * This program is distributed in the hope that it will be useful, * + * but WITHOUT ANY WARRANTY; without even the implied warranty of * + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * + * GNU General Public License for more details. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program. If not, see . * + * * + *******************************************************************************/ + +package com.github.shadowsocks + +import android.app.backup.BackupAgentHelper +import android.app.backup.FileBackupHelper +import com.github.shadowsocks.acl.Acl +import com.github.shadowsocks.utils.Key + +class ConfigBackupHelper : BackupAgentHelper() { + override fun onCreate() = addHelper("com.github.shadowsocks.database.profile", + FileBackupHelper(this, "../databases/" + Key.PROFILE, Acl.CUSTOM_RULES + ".acl")) +} diff --git a/mobile/src/main/java/com/github/shadowsocks/GlobalSettingsFragment.kt b/mobile/src/main/java/com/github/shadowsocks/GlobalSettingsFragment.kt new file mode 100644 index 00000000..b5c0221f --- /dev/null +++ b/mobile/src/main/java/com/github/shadowsocks/GlobalSettingsFragment.kt @@ -0,0 +1,46 @@ +/******************************************************************************* + * * + * Copyright (C) 2017 by Max Lv * + * Copyright (C) 2017 by Mygod Studio * + * * + * This program is free software: you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation, either version 3 of the License, or * + * (at your option) any later version. * + * * + * This program is distributed in the hope that it will be useful, * + * but WITHOUT ANY WARRANTY; without even the implied warranty of * + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * + * GNU General Public License for more details. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program. If not, see . * + * * + *******************************************************************************/ + +package com.github.shadowsocks + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import com.github.shadowsocks.utils.childFragManager + +class GlobalSettingsFragment : ToolbarFragment() { + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? = + inflater.inflate(R.layout.layout_global_settings, container, false) + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + toolbar.setTitle(R.string.settings) + + val fm = childFragmentManager + fm.beginTransaction().replace(R.id.content, GlobalSettingsPreferenceFragment()).commit() + fm.executePendingTransactions() + } + + override fun onDetach() { + super.onDetach() + childFragManager = null + } +} diff --git a/mobile/src/main/java/com/github/shadowsocks/GlobalSettingsPreferenceFragment.kt b/mobile/src/main/java/com/github/shadowsocks/GlobalSettingsPreferenceFragment.kt new file mode 100644 index 00000000..78cf93cf --- /dev/null +++ b/mobile/src/main/java/com/github/shadowsocks/GlobalSettingsPreferenceFragment.kt @@ -0,0 +1,97 @@ +/******************************************************************************* + * * + * Copyright (C) 2017 by Max Lv * + * Copyright (C) 2017 by Mygod Studio * + * * + * This program is free software: you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation, either version 3 of the License, or * + * (at your option) any later version. * + * * + * This program is distributed in the hope that it will be useful, * + * but WITHOUT ANY WARRANTY; without even the implied warranty of * + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * + * GNU General Public License for more details. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program. If not, see . * + * * + *******************************************************************************/ + +package com.github.shadowsocks + +import android.os.Bundle +import android.support.design.widget.Snackbar +import android.support.v14.preference.SwitchPreference +import android.support.v7.preference.Preference +import com.github.shadowsocks.bg.BaseService +import com.github.shadowsocks.preference.DataStore +import com.github.shadowsocks.utils.Key +import com.github.shadowsocks.utils.TcpFastOpen +import com.takisoft.fix.support.v7.preference.PreferenceFragmentCompatDividers + +class GlobalSettingsPreferenceFragment : PreferenceFragmentCompatDividers() { + override fun onCreatePreferencesFix(savedInstanceState: Bundle?, rootKey: String?) { + preferenceManager.preferenceDataStore = DataStore + DataStore.initGlobal() + addPreferencesFromResource(R.xml.pref_global) + val switch = findPreference(Key.isAutoConnect) as SwitchPreference + switch.setOnPreferenceChangeListener { _, value -> + BootReceiver.enabled = value as Boolean + true + } + switch.isChecked = BootReceiver.enabled + + val tfo = findPreference(Key.tfo) as SwitchPreference + tfo.isChecked = TcpFastOpen.sendEnabled + tfo.setOnPreferenceChangeListener { _, value -> + val result = TcpFastOpen.enabled(value as Boolean) + if (result != null && result != "Success.") + Snackbar.make(activity!!.findViewById(R.id.snackbar), result, Snackbar.LENGTH_LONG).show() + value == TcpFastOpen.sendEnabled + } + if (!TcpFastOpen.supported) { + tfo.isEnabled = false + tfo.summary = getString(R.string.tcp_fastopen_summary_unsupported, System.getProperty("os.version")) + } + + val serviceMode = findPreference(Key.serviceMode) + val portProxy = findPreference(Key.portProxy) + val portLocalDns = findPreference(Key.portLocalDns) + val portTransproxy = findPreference(Key.portTransproxy) + val onServiceModeChange = Preference.OnPreferenceChangeListener { _, newValue -> + val (enabledLocalDns, enabledTransproxy) = when (newValue as String?) { + Key.modeProxy -> Pair(false, false) + Key.modeVpn -> Pair(true, false) + Key.modeTransproxy -> Pair(true, true) + else -> throw IllegalArgumentException() + } + portLocalDns.isEnabled = enabledLocalDns + portTransproxy.isEnabled = enabledTransproxy + true + } + val listener: (Int) -> Unit = { + when (it) { + BaseService.IDLE, BaseService.STOPPED -> { + serviceMode.isEnabled = true + portProxy.isEnabled = true + onServiceModeChange.onPreferenceChange(null, DataStore.serviceMode) + } + else -> { + serviceMode.isEnabled = false + portProxy.isEnabled = false + portLocalDns.isEnabled = false + portTransproxy.isEnabled = false + } + } + } + listener((activity as MainActivity).state) + MainActivity.stateListener = listener + serviceMode.onPreferenceChangeListener = onServiceModeChange + } + + override fun onDestroy() { + MainActivity.stateListener = null + super.onDestroy() + } +} diff --git a/mobile/src/main/java/com/github/shadowsocks/MainActivity.kt b/mobile/src/main/java/com/github/shadowsocks/MainActivity.kt new file mode 100644 index 00000000..1ee5f552 --- /dev/null +++ b/mobile/src/main/java/com/github/shadowsocks/MainActivity.kt @@ -0,0 +1,445 @@ +/******************************************************************************* + * * + * Copyright (C) 2017 by Max Lv * + * Copyright (C) 2017 by Mygod Studio * + * * + * This program is free software: you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation, either version 3 of the License, or * + * (at your option) any later version. * + * * + * This program is distributed in the hope that it will be useful, * + * but WITHOUT ANY WARRANTY; without even the implied warranty of * + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * + * GNU General Public License for more details. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program. If not, see . * + * * + *******************************************************************************/ + +package com.github.shadowsocks + +import android.app.Activity +import android.app.backup.BackupManager +import android.content.ActivityNotFoundException +import android.content.Intent +import android.net.Uri +import android.net.VpnService +import android.nfc.NdefMessage +import android.nfc.NfcAdapter +import android.os.Bundle +import android.os.SystemClock +import android.support.customtabs.CustomTabsIntent +import android.support.design.widget.FloatingActionButton +import android.support.design.widget.Snackbar +import android.support.v4.content.ContextCompat +import android.support.v7.app.AlertDialog +import android.support.v7.app.AppCompatActivity +import android.support.v7.content.res.AppCompatResources +import android.support.v7.preference.PreferenceDataStore +import android.text.TextUtils +import android.util.Log +import android.view.View +import android.widget.TextView +import android.widget.Toast +import com.github.jorgecastilloprz.FABProgressCircle +import com.github.shadowsocks.App.Companion.app +import com.github.shadowsocks.acl.Acl +import com.github.shadowsocks.acl.CustomRulesFragment +import com.github.shadowsocks.aidl.IShadowsocksService +import com.github.shadowsocks.aidl.IShadowsocksServiceCallback +import com.github.shadowsocks.bg.BaseService +import com.github.shadowsocks.bg.Executable +import com.github.shadowsocks.bg.TrafficMonitor +import com.github.shadowsocks.database.Profile +import com.github.shadowsocks.database.ProfileManager +import com.github.shadowsocks.preference.DataStore +import com.github.shadowsocks.preference.OnPreferenceDataStoreChangeListener +import com.github.shadowsocks.utils.Key +import com.github.shadowsocks.utils.position +import com.github.shadowsocks.utils.responseLength +import com.github.shadowsocks.utils.thread +import com.mikepenz.crossfader.Crossfader +import com.mikepenz.crossfader.view.CrossFadeSlidingPaneLayout +import com.mikepenz.materialdrawer.Drawer +import com.mikepenz.materialdrawer.DrawerBuilder +import com.mikepenz.materialdrawer.interfaces.ICrossfader +import com.mikepenz.materialdrawer.model.PrimaryDrawerItem +import com.mikepenz.materialdrawer.model.interfaces.IDrawerItem +import java.net.HttpURLConnection +import java.net.InetSocketAddress +import java.net.Proxy +import java.net.URL +import java.util.* + +class MainActivity : AppCompatActivity(), ShadowsocksConnection.Interface, Drawer.OnDrawerItemClickListener, + OnPreferenceDataStoreChangeListener { + companion object { + private const val TAG = "ShadowsocksMainActivity" + private const val REQUEST_CONNECT = 1 + + private const val DRAWER_PROFILES = 0L + private const val DRAWER_GLOBAL_SETTINGS = 1L + private const val DRAWER_ABOUT = 3L + private const val DRAWER_FAQ = 4L + private const val DRAWER_CUSTOM_RULES = 5L + + var stateListener: ((Int) -> Unit)? = null + } + + // UI + private lateinit var fab: FloatingActionButton + private lateinit var fabProgressCircle: FABProgressCircle + internal var crossfader: Crossfader? = null + internal lateinit var drawer: Drawer + private var previousPosition: Int = 0 // it's actually lateinit + + private var testCount = 0 + private lateinit var statusText: TextView + private lateinit var txText: TextView + private lateinit var rxText: TextView + private lateinit var txRateText: TextView + private lateinit var rxRateText: TextView + + private val customTabsIntent by lazy { + CustomTabsIntent.Builder() + .setToolbarColor(ContextCompat.getColor(this, R.color.material_primary_500)) + .build() + } + fun launchUrl(uri: Uri) = try { + customTabsIntent.launchUrl(this, uri) + } catch (_: ActivityNotFoundException) { } // ignore + fun launchUrl(uri: String) = launchUrl(Uri.parse(uri)) + + private val greyTint by lazy { ContextCompat.getColorStateList(this, R.color.material_primary_500) } + private val greenTint by lazy { ContextCompat.getColorStateList(this, R.color.material_green_700) } + + private fun hideCircle() = try { + fabProgressCircle.hide() + } catch (_: NullPointerException) { } // ignore + + // service + var state = BaseService.IDLE + override val serviceCallback: IShadowsocksServiceCallback.Stub by lazy { + object : IShadowsocksServiceCallback.Stub() { + override fun stateChanged(state: Int, profileName: String?, msg: String?) { + app.handler.post { changeState(state, msg) } + } + override fun trafficUpdated(profileId: Int, txRate: Long, rxRate: Long, txTotal: Long, rxTotal: Long) { + app.handler.post { updateTraffic(profileId, txRate, rxRate, txTotal, rxTotal) } + } + override fun trafficPersisted(profileId: Int) { + app.handler.post { ProfilesFragment.instance?.onTrafficPersisted(profileId) } + } + } + } + + fun changeState(state: Int, msg: String? = null) { + when (state) { + BaseService.CONNECTING -> { + fab.setImageResource(R.drawable.ic_start_busy) + fabProgressCircle.show() + statusText.setText(R.string.connecting) + } + BaseService.CONNECTED -> { + if (this.state == BaseService.CONNECTING) fabProgressCircle.beginFinalAnimation() + else fabProgressCircle.postDelayed(this::hideCircle, 1000) + fab.setImageResource(R.drawable.ic_start_connected) + statusText.setText(R.string.vpn_connected) + } + BaseService.STOPPING -> { + fab.setImageResource(R.drawable.ic_start_busy) + if (this.state == BaseService.CONNECTED) fabProgressCircle.show() // ignore for stopped + statusText.setText(R.string.stopping) + } + else -> { + fab.setImageResource(R.drawable.ic_start_idle) + fabProgressCircle.postDelayed(this::hideCircle, 1000) + if (msg != null) { + Snackbar.make(findViewById(R.id.snackbar), + getString(R.string.vpn_error).format(Locale.ENGLISH, msg), Snackbar.LENGTH_LONG).show() + Log.e(TAG, "Error to start VPN service: $msg") + } + statusText.setText(R.string.not_connected) + } + } + this.state = state + if (state == BaseService.CONNECTED) fab.backgroundTintList = greenTint else { + fab.backgroundTintList = greyTint + updateTraffic(-1, 0, 0, 0, 0) + testCount += 1 // suppress previous test messages + } + ProfilesFragment.instance?.profilesAdapter?.notifyDataSetChanged() // refresh button enabled state + stateListener?.invoke(state) + fab.isEnabled = false + if (state == BaseService.CONNECTED || state == BaseService.STOPPED) app.handler.postDelayed( + { fab.isEnabled = state == BaseService.CONNECTED || state == BaseService.STOPPED }, 1000) + } + fun updateTraffic(profileId: Int, txRate: Long, rxRate: Long, txTotal: Long, rxTotal: Long) { + txText.text = TrafficMonitor.formatTraffic(txTotal) + rxText.text = TrafficMonitor.formatTraffic(rxTotal) + txRateText.text = getString(R.string.speed, TrafficMonitor.formatTraffic(txRate)) + rxRateText.text = getString(R.string.speed, TrafficMonitor.formatTraffic(rxRate)) + val child = supportFragmentManager.findFragmentById(R.id.fragment_holder) as ToolbarFragment? + if (state != BaseService.STOPPING) + child?.onTrafficUpdated(profileId, txRate, rxRate, txTotal, rxTotal) + } + + /** + * Based on: https://android.googlesource.com/platform/frameworks/base/+/97bfd27/services/core/java/com/android/server/connectivity/NetworkMonitor.java#879 + */ + private fun testConnection(id: Int) { + val url = URL("https", when (app.currentProfile!!.route) { + Acl.CHINALIST -> "www.qualcomm.cn" + else -> "www.google.com" + }, "/generate_204") + val conn = (if (BaseService.usingVpnMode) url.openConnection() else + url.openConnection(Proxy(Proxy.Type.SOCKS, + InetSocketAddress("127.0.0.1", DataStore.portProxy)))) + as HttpURLConnection + conn.instanceFollowRedirects = false + conn.connectTimeout = 10000 + conn.readTimeout = 10000 + conn.useCaches = false + val (success, result) = try { + val start = SystemClock.elapsedRealtime() + val code = conn.responseCode + val elapsed = SystemClock.elapsedRealtime() - start + if (code == 204 || code == 200 && conn.responseLength == 0L) + Pair(true, getString(R.string.connection_test_available, elapsed)) + else throw Exception(getString(R.string.connection_test_error_status_code, code)) + } catch (e: Exception) { + Pair(false, getString(R.string.connection_test_error, e.message)) + } finally { + conn.disconnect() + } + if (testCount == id) app.handler.post { + if (success) statusText.text = result else { + statusText.setText(R.string.connection_test_fail) + Snackbar.make(findViewById(R.id.snackbar), result, Snackbar.LENGTH_LONG).show() + } + } + } + + override fun onServiceConnected(service: IShadowsocksService) = changeState(service.state) + override fun onServiceDisconnected() = changeState(BaseService.IDLE) + override fun binderDied() { + app.handler.post { + connection.disconnect() + Executable.killAll() + connection.connect() + } + } + + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + if (resultCode == Activity.RESULT_OK) app.startService() else { + Snackbar.make(findViewById(R.id.snackbar), R.string.vpn_permission_denied, Snackbar.LENGTH_LONG).show() + Log.e(TAG, "Failed to start VpnService: $data") + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.layout_main) + val drawerBuilder = DrawerBuilder() + .withActivity(this) + .withTranslucentStatusBar(true) + .withHeader(R.layout.layout_header) + .addDrawerItems( + PrimaryDrawerItem() + .withIdentifier(DRAWER_PROFILES) + .withName(R.string.profiles) + .withIcon(AppCompatResources.getDrawable(this, R.drawable.ic_action_description)) + .withIconTintingEnabled(true), + PrimaryDrawerItem() + .withIdentifier(DRAWER_CUSTOM_RULES) + .withName(R.string.custom_rules) + .withIcon(AppCompatResources.getDrawable(this, R.drawable.ic_action_assignment)) + .withIconTintingEnabled(true), + PrimaryDrawerItem() + .withIdentifier(DRAWER_GLOBAL_SETTINGS) + .withName(R.string.settings) + .withIcon(AppCompatResources.getDrawable(this, R.drawable.ic_action_settings)) + .withIconTintingEnabled(true) + ) + .addStickyDrawerItems( + PrimaryDrawerItem() + .withIdentifier(DRAWER_FAQ) + .withName(R.string.faq) + .withIcon(AppCompatResources.getDrawable(this, R.drawable.ic_action_help_outline)) + .withIconTintingEnabled(true) + .withSelectable(false), + PrimaryDrawerItem() + .withIdentifier(DRAWER_ABOUT) + .withName(R.string.about) + .withIcon(AppCompatResources.getDrawable(this, R.drawable.ic_action_copyright)) + .withIconTintingEnabled(true) + ) + .withOnDrawerItemClickListener(this) + .withActionBarDrawerToggle(true) + .withSavedInstance(savedInstanceState) + val miniDrawerWidth = resources.getDimension(R.dimen.material_mini_drawer_item) + if (resources.displayMetrics.widthPixels >= + resources.getDimension(R.dimen.profile_item_max_width) + miniDrawerWidth) { + drawer = drawerBuilder.withGenerateMiniDrawer(true).buildView() + val crossfader = Crossfader() + this.crossfader = crossfader + crossfader + .withContent(findViewById(android.R.id.content)) + .withFirst(drawer.slider, resources.getDimensionPixelSize(R.dimen.material_drawer_width)) + .withSecond(drawer.miniDrawer.build(this), miniDrawerWidth.toInt()) + .withSavedInstance(savedInstanceState) + .build() + if (resources.configuration.layoutDirection == View.LAYOUT_DIRECTION_RTL) + crossfader.crossFadeSlidingPaneLayout.setShadowDrawableRight( + AppCompatResources.getDrawable(this, R.drawable.material_drawer_shadow_right)) + else crossfader.crossFadeSlidingPaneLayout.setShadowDrawableLeft( + AppCompatResources.getDrawable(this, R.drawable.material_drawer_shadow_left)) + drawer.miniDrawer.withCrossFader(object : ICrossfader { // a wrapper is needed + override fun isCrossfaded(): Boolean = crossfader.isCrossFaded + override fun crossfade() = crossfader.crossFade() + }) + } else drawer = drawerBuilder.build() + + if (savedInstanceState == null) displayFragment(ProfilesFragment()) + previousPosition = drawer.currentSelectedPosition + statusText = findViewById(R.id.status) + txText = findViewById(R.id.tx) + txRateText = findViewById(R.id.txRate) + rxText = findViewById(R.id.rx) + rxRateText = findViewById(R.id.rxRate) + findViewById(R.id.stat).setOnClickListener { + if (state == BaseService.CONNECTED) { + ++testCount + statusText.setText(R.string.connection_test_testing) + val id = testCount // it would change by other code + thread { testConnection(id) } + } + } + + fab = findViewById(R.id.fab) + fabProgressCircle = findViewById(R.id.fabProgressCircle) + fab.setOnClickListener { + if (state == BaseService.CONNECTED) app.stopService() else thread { + if (BaseService.usingVpnMode) { + val intent = VpnService.prepare(this) + if (intent != null) startActivityForResult(intent, REQUEST_CONNECT) + else app.handler.post { onActivityResult(REQUEST_CONNECT, Activity.RESULT_OK, null) } + } else app.startService() + } + } + fab.setOnLongClickListener { + Toast.makeText(this, if (state == BaseService.CONNECTED) R.string.stop else R.string.connect, + Toast.LENGTH_SHORT) + .position(fab, window, 0, resources.getDimensionPixelOffset(R.dimen.margin_small)) + .show() + true + } + + changeState(BaseService.IDLE) // reset everything to init state + app.handler.post { connection.connect() } + DataStore.registerChangeListener(this) + + val intent = this.intent + if (intent != null) handleShareIntent(intent) + } + + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + handleShareIntent(intent) + } + private fun handleShareIntent(intent: Intent) { + val sharedStr = when (intent.action) { + Intent.ACTION_VIEW -> intent.data.toString() + NfcAdapter.ACTION_NDEF_DISCOVERED -> { + val rawMsgs = intent.getParcelableArrayExtra(NfcAdapter.EXTRA_NDEF_MESSAGES) + if (rawMsgs != null && rawMsgs.isNotEmpty()) String((rawMsgs[0] as NdefMessage).records[0].payload) + else null + } + else -> null + } + if (TextUtils.isEmpty(sharedStr)) return + val profiles = Profile.findAll(sharedStr).toList() + if (profiles.isEmpty()) { + Snackbar.make(findViewById(R.id.snackbar), R.string.profile_invalid_input, Snackbar.LENGTH_LONG).show() + return + } + AlertDialog.Builder(this) + .setTitle(R.string.add_profile_dialog) + .setPositiveButton(R.string.yes, { _, _ -> profiles.forEach { ProfileManager.createProfile(it) } }) + .setNegativeButton(R.string.no, null) + .setMessage(profiles.joinToString("\n")) + .create() + .show() + } + + override fun onPreferenceDataStoreChanged(store: PreferenceDataStore, key: String?) { + if (key == Key.serviceMode) app.handler.post { + connection.disconnect() + connection.connect() + } + } + + private fun displayFragment(fragment: ToolbarFragment) { + supportFragmentManager.beginTransaction().replace(R.id.fragment_holder, fragment).commitAllowingStateLoss() + drawer.closeDrawer() + } + + override fun onItemClick(view: View?, position: Int, drawerItem: IDrawerItem<*, *>): Boolean { + if (position == previousPosition) drawer.closeDrawer() else { + previousPosition = position + when (drawerItem.identifier) { + DRAWER_PROFILES -> displayFragment(ProfilesFragment()) + DRAWER_GLOBAL_SETTINGS -> displayFragment(GlobalSettingsFragment()) + DRAWER_ABOUT -> { + app.track(TAG, "about") + displayFragment(AboutFragment()) + } + DRAWER_FAQ -> launchUrl(getString(R.string.faq_url)) + DRAWER_CUSTOM_RULES -> displayFragment(CustomRulesFragment()) + else -> return false + } + } + return true + } + + override fun onResume() { + super.onResume() + app.remoteConfig.fetch() + if (state !in arrayOf(BaseService.STOPPING, BaseService.CONNECTING)) hideCircle() + } + + override fun onStart() { + super.onStart() + connection.listeningForBandwidth = true + } + + override fun onBackPressed() { + if (drawer.isDrawerOpen) drawer.closeDrawer() else { + val currentFragment = supportFragmentManager.findFragmentById(R.id.fragment_holder) as ToolbarFragment + if (!currentFragment.onBackPressed()) + if (currentFragment is ProfilesFragment) super.onBackPressed() + else drawer.setSelection(DRAWER_PROFILES) + } + } + + override fun onStop() { + connection.listeningForBandwidth = false + super.onStop() + } + + override fun onSaveInstanceState(outState: Bundle?) { + super.onSaveInstanceState(outState) + drawer.saveInstanceState(outState) + crossfader?.saveInstanceState(outState) + } + + override fun onDestroy() { + super.onDestroy() + DataStore.unregisterChangeListener(this) + connection.disconnect() + BackupManager(this).dataChanged() + app.handler.removeCallbacksAndMessages(null) + } +} diff --git a/mobile/src/main/java/com/github/shadowsocks/ProfileConfigActivity.kt b/mobile/src/main/java/com/github/shadowsocks/ProfileConfigActivity.kt new file mode 100644 index 00000000..dbf3098f --- /dev/null +++ b/mobile/src/main/java/com/github/shadowsocks/ProfileConfigActivity.kt @@ -0,0 +1,67 @@ +/******************************************************************************* + * * + * Copyright (C) 2017 by Max Lv * + * Copyright (C) 2017 by Mygod Studio * + * * + * This program is free software: you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation, either version 3 of the License, or * + * (at your option) any later version. * + * * + * This program is distributed in the hope that it will be useful, * + * but WITHOUT ANY WARRANTY; without even the implied warranty of * + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * + * GNU General Public License for more details. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program. If not, see . * + * * + *******************************************************************************/ + +package com.github.shadowsocks + +import android.app.Activity +import android.content.Intent +import android.os.Bundle +import android.support.v7.app.AlertDialog +import android.support.v7.app.AppCompatActivity +import android.support.v7.widget.Toolbar +import com.github.shadowsocks.plugin.PluginContract +import com.github.shadowsocks.preference.DataStore + +class ProfileConfigActivity : AppCompatActivity() { + companion object { + const val REQUEST_CODE_PLUGIN_HELP = 1 + } + + private val child by lazy { supportFragmentManager.findFragmentById(R.id.content) as ProfileConfigFragment } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.layout_profile_config) + val toolbar = findViewById(R.id.toolbar) + toolbar.setTitle(R.string.profile_config) + toolbar.setNavigationIcon(R.drawable.ic_navigation_close) + toolbar.setNavigationOnClickListener { onBackPressed() } + toolbar.inflateMenu(R.menu.profile_config_menu) + toolbar.setOnMenuItemClickListener(child) + } + + override fun onBackPressed() { + if (DataStore.dirty) AlertDialog.Builder(this) + .setTitle(R.string.unsaved_changes_prompt) + .setPositiveButton(R.string.yes, { _, _ -> child.saveAndExit() }) + .setNegativeButton(R.string.no, { _, _ -> finish() }) + .setNeutralButton(android.R.string.cancel, null) + .create() + .show() else super.onBackPressed() + } + + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + if (requestCode != REQUEST_CODE_PLUGIN_HELP) super.onActivityResult(requestCode, resultCode, data) + else if (resultCode == Activity.RESULT_OK) AlertDialog.Builder(this) + .setTitle("?") + .setMessage(data?.getCharSequenceExtra(PluginContract.EXTRA_HELP_MESSAGE)) + .show() + } +} diff --git a/mobile/src/main/java/com/github/shadowsocks/ProfileConfigFragment.kt b/mobile/src/main/java/com/github/shadowsocks/ProfileConfigFragment.kt new file mode 100644 index 00000000..d70af73b --- /dev/null +++ b/mobile/src/main/java/com/github/shadowsocks/ProfileConfigFragment.kt @@ -0,0 +1,207 @@ +/******************************************************************************* + * * + * Copyright (C) 2017 by Max Lv * + * Copyright (C) 2017 by Mygod Studio * + * * + * This program is free software: you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation, either version 3 of the License, or * + * (at your option) any later version. * + * * + * This program is distributed in the hope that it will be useful, * + * but WITHOUT ANY WARRANTY; without even the implied warranty of * + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * + * GNU General Public License for more details. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program. If not, see . * + * * + *******************************************************************************/ + +package com.github.shadowsocks + +import android.app.Activity +import android.content.Intent +import android.os.Build +import android.os.Bundle +import android.os.UserManager +import android.support.design.widget.Snackbar +import android.support.v14.preference.SwitchPreference +import android.support.v7.app.AlertDialog +import android.support.v7.preference.Preference +import android.support.v7.preference.PreferenceDataStore +import android.support.v7.widget.Toolbar +import android.view.MenuItem +import com.github.shadowsocks.App.Companion.app +import com.github.shadowsocks.bg.BaseService +import com.github.shadowsocks.database.Profile +import com.github.shadowsocks.database.ProfileManager +import com.github.shadowsocks.plugin.PluginConfiguration +import com.github.shadowsocks.plugin.PluginContract +import com.github.shadowsocks.plugin.PluginManager +import com.github.shadowsocks.plugin.PluginOptions +import com.github.shadowsocks.preference.DataStore +import com.github.shadowsocks.preference.IconListPreference +import com.github.shadowsocks.preference.OnPreferenceDataStoreChangeListener +import com.github.shadowsocks.preference.PluginConfigurationDialogFragment +import com.github.shadowsocks.utils.Action +import com.github.shadowsocks.utils.Key +import com.takisoft.fix.support.v7.preference.EditTextPreference +import com.takisoft.fix.support.v7.preference.PreferenceFragmentCompatDividers + +class ProfileConfigFragment : PreferenceFragmentCompatDividers(), Toolbar.OnMenuItemClickListener, + Preference.OnPreferenceChangeListener, OnPreferenceDataStoreChangeListener { + companion object { + private const val REQUEST_CODE_PLUGIN_CONFIGURE = 1 + } + + private lateinit var profile: Profile + private lateinit var isProxyApps: SwitchPreference + private lateinit var plugin: IconListPreference + private lateinit var pluginConfigure: EditTextPreference + private lateinit var pluginConfiguration: PluginConfiguration + + override fun onCreatePreferencesFix(savedInstanceState: Bundle?, rootKey: String?) { + preferenceManager.preferenceDataStore = DataStore + val activity = activity!! + val profile = ProfileManager.getProfile(activity.intent.getIntExtra(Action.EXTRA_PROFILE_ID, -1)) + if (profile == null) { + activity.finish() + return + } + this.profile = profile + profile.serialize() + addPreferencesFromResource(R.xml.pref_profile) + if (Build.VERSION.SDK_INT >= 25 && activity.getSystemService(UserManager::class.java).isDemoUser) { + findPreference(Key.host).summary = "shadowsocks.example.org" + findPreference(Key.remotePort).summary = "1337" + findPreference(Key.password).summary = "\u2022".repeat(32) + } + isProxyApps = findPreference(Key.proxyApps) as SwitchPreference + if (Build.VERSION.SDK_INT < 21) isProxyApps.parent!!.removePreference(isProxyApps) else { + isProxyApps.isEnabled = BaseService.usingVpnMode + isProxyApps.setOnPreferenceClickListener { + startActivity(Intent(activity, AppManager::class.java)) + isProxyApps.isChecked = true + false + } + } + plugin = findPreference(Key.plugin) as IconListPreference + pluginConfigure = findPreference(Key.pluginConfigure) as EditTextPreference + plugin.unknownValueSummary = getString(R.string.plugin_unknown) + plugin.setOnPreferenceChangeListener { _, newValue -> + pluginConfiguration = PluginConfiguration(pluginConfiguration.pluginsOptions, newValue as String) + DataStore.plugin = pluginConfiguration.toString() + DataStore.dirty = true + pluginConfigure.isEnabled = newValue.isNotEmpty() + pluginConfigure.text = pluginConfiguration.selectedOptions.toString() + if (PluginManager.fetchPlugins()[newValue]?.trusted == false) + Snackbar.make(view!!, R.string.plugin_untrusted, Snackbar.LENGTH_LONG).show() + true + } + pluginConfigure.onPreferenceChangeListener = this + initPlugins() + app.listenForPackageChanges { initPlugins() } + DataStore.registerChangeListener(this) + } + + private fun initPlugins() { + val plugins = PluginManager.fetchPlugins() + plugin.entries = plugins.map { it.value.label }.toTypedArray() + plugin.entryValues = plugins.map { it.value.id }.toTypedArray() + plugin.entryIcons = plugins.map { it.value.icon }.toTypedArray() + plugin.entryPackageNames = plugins.map { it.value.packageName }.toTypedArray() + pluginConfiguration = PluginConfiguration(DataStore.plugin) + plugin.value = pluginConfiguration.selected + plugin.init() + plugin.checkSummary() + pluginConfigure.isEnabled = pluginConfiguration.selected.isNotEmpty() + pluginConfigure.text = pluginConfiguration.selectedOptions.toString() + } + + private fun showPluginEditor() { + val bundle = Bundle() + bundle.putString("key", Key.pluginConfigure) + bundle.putString(PluginConfigurationDialogFragment.PLUGIN_ID_FRAGMENT_TAG, pluginConfiguration.selected) + displayPreferenceDialog(PluginConfigurationDialogFragment(), Key.pluginConfigure, bundle) + } + + fun saveAndExit() { + profile.deserialize() + ProfileManager.updateProfile(profile) + ProfilesFragment.instance?.profilesAdapter?.deepRefreshId(profile.id) + activity!!.finish() + } + + override fun onResume() { + super.onResume() + isProxyApps.isChecked = DataStore.proxyApps // fetch proxyApps updated by AppManager + } + + override fun onPreferenceChange(preference: Preference?, newValue: Any?): Boolean = try { + val selected = pluginConfiguration.selected + pluginConfiguration = PluginConfiguration(pluginConfiguration.pluginsOptions + + (pluginConfiguration.selected to PluginOptions(selected, newValue as String?)), selected) + DataStore.plugin = pluginConfiguration.toString() + DataStore.dirty = true + true + } catch (exc: IllegalArgumentException) { + Snackbar.make(view!!, exc.localizedMessage, Snackbar.LENGTH_LONG).show() + false + } + + override fun onPreferenceDataStoreChanged(store: PreferenceDataStore, key: String?) { + if (key != Key.proxyApps && findPreference(key) != null) DataStore.dirty = true + } + + override fun onDisplayPreferenceDialog(preference: Preference) { + if (preference.key == Key.pluginConfigure) { + val intent = PluginManager.buildIntent(pluginConfiguration.selected, PluginContract.ACTION_CONFIGURE) + if (intent.resolveActivity(activity!!.packageManager) != null) + startActivityForResult(intent.putExtra(PluginContract.EXTRA_OPTIONS, + pluginConfiguration.selectedOptions.toString()), REQUEST_CODE_PLUGIN_CONFIGURE) else { + showPluginEditor() + } + } else super.onDisplayPreferenceDialog(preference) + } + + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + if (requestCode == REQUEST_CODE_PLUGIN_CONFIGURE) when (resultCode) { + Activity.RESULT_OK -> { + val options = data?.getStringExtra(PluginContract.EXTRA_OPTIONS) + pluginConfigure.text = options + onPreferenceChange(null, options) + } + PluginContract.RESULT_FALLBACK -> showPluginEditor() + + } else super.onActivityResult(requestCode, resultCode, data) + } + + override fun onMenuItemClick(item: MenuItem): Boolean { + return when (item.itemId) { + R.id.action_delete -> { + val activity = activity!! + AlertDialog.Builder(activity) + .setTitle(R.string.delete_confirm_prompt) + .setPositiveButton(R.string.yes, { _, _ -> + ProfileManager.delProfile(profile.id) + activity.finish() + }) + .setNegativeButton(R.string.no, null) + .create() + .show() + true + } + R.id.action_apply -> { + saveAndExit() + true + } + else -> false + } + } + + override fun onDestroy() { + DataStore.unregisterChangeListener(this) + super.onDestroy() + } +} diff --git a/mobile/src/main/java/com/github/shadowsocks/ProfilesFragment.kt b/mobile/src/main/java/com/github/shadowsocks/ProfilesFragment.kt new file mode 100644 index 00000000..974a7ed2 --- /dev/null +++ b/mobile/src/main/java/com/github/shadowsocks/ProfilesFragment.kt @@ -0,0 +1,421 @@ +/******************************************************************************* + * * + * Copyright (C) 2017 by Max Lv * + * Copyright (C) 2017 by Mygod Studio * + * * + * This program is free software: you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation, either version 3 of the License, or * + * (at your option) any later version. * + * * + * This program is distributed in the hope that it will be useful, * + * but WITHOUT ANY WARRANTY; without even the implied warranty of * + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * + * GNU General Public License for more details. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program. If not, see . * + * * + *******************************************************************************/ + +package com.github.shadowsocks + +import android.annotation.SuppressLint +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import android.content.Intent +import android.nfc.NdefMessage +import android.nfc.NdefRecord +import android.nfc.NfcAdapter +import android.os.Bundle +import android.support.design.widget.Snackbar +import android.support.v4.app.DialogFragment +import android.support.v7.widget.* +import android.support.v7.widget.helper.ItemTouchHelper +import android.view.* +import android.widget.ImageView +import android.widget.LinearLayout +import android.widget.TextView +import android.widget.Toast +import com.github.shadowsocks.App.Companion.app +import com.github.shadowsocks.bg.BaseService +import com.github.shadowsocks.bg.TrafficMonitor +import com.github.shadowsocks.database.Profile +import com.github.shadowsocks.database.ProfileManager +import com.github.shadowsocks.plugin.PluginConfiguration +import com.github.shadowsocks.preference.DataStore +import com.github.shadowsocks.utils.Action +import com.github.shadowsocks.utils.position +import com.github.shadowsocks.widget.UndoSnackbarManager +import com.google.android.gms.ads.AdRequest +import com.google.android.gms.ads.AdSize +import com.google.android.gms.ads.AdView +import net.glxn.qrgen.android.QRCode + +class ProfilesFragment : ToolbarFragment(), Toolbar.OnMenuItemClickListener { + companion object { + /** + * used for callback from ProfileManager and stateChanged from MainActivity + */ + var instance: ProfilesFragment? = null + + private const val KEY_URL = "com.github.shadowsocks.QRCodeDialog.KEY_URL" + } + + /** + * Is ProfilesFragment editable at all. + */ + private val isEnabled get() = when ((activity as MainActivity).state) { + BaseService.CONNECTED, BaseService.STOPPED -> true + else -> false + } + private fun isProfileEditable(id: Int) = when ((activity as MainActivity).state) { + BaseService.CONNECTED -> id != DataStore.profileId + BaseService.STOPPED -> true + else -> false + } + + @SuppressLint("ValidFragment") + private class QRCodeDialog() : DialogFragment() { + constructor(url: String) : this() { + val bundle = Bundle() + bundle.putString(KEY_URL, url) + arguments = bundle + } + + private val url get() = arguments!!.getString(KEY_URL) + private val nfcShareItem by lazy { url.toByteArray() } + private var adapter: NfcAdapter? = null + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { + val image = ImageView(context) + image.layoutParams = LinearLayout.LayoutParams(-1, -1) + val size = resources.getDimensionPixelSize(R.dimen.qr_code_size) + image.setImageBitmap((QRCode.from(url).withSize(size, size) as QRCode).bitmap()) + return image + } + + override fun onAttach(context: Context?) { + super.onAttach(context) + val adapter = NfcAdapter.getDefaultAdapter(context) + adapter?.setNdefPushMessage(NdefMessage(arrayOf( + NdefRecord(NdefRecord.TNF_ABSOLUTE_URI, nfcShareItem, byteArrayOf(), nfcShareItem))), activity) + this.adapter = adapter + } + + override fun onDetach() { + adapter?.setNdefPushMessage(null, activity) + adapter = null + super.onDetach() + } + } + + inner class ProfileViewHolder(view: View) : RecyclerView.ViewHolder(view), + View.OnClickListener, View.OnLongClickListener, PopupMenu.OnMenuItemClickListener { + internal lateinit var item: Profile + + private val text1 = itemView.findViewById(android.R.id.text1) + private val text2 = itemView.findViewById(android.R.id.text2) + private val traffic = itemView.findViewById(R.id.traffic) + private val edit = itemView.findViewById(R.id.edit) + private var adView: AdView? = null + + init { + edit.setOnClickListener { startConfig(item.id) } + edit.setOnLongClickListener(this) + itemView.setOnClickListener(this) + val share = itemView.findViewById(R.id.share) + share.setOnClickListener { + val popup = PopupMenu(activity!!, share) + popup.menuInflater.inflate(R.menu.profile_share_popup, popup.menu) + popup.setOnMenuItemClickListener(this) + popup.show() + } + share.setOnLongClickListener(this) + } + + fun bind(item: Profile) { + this.item = item + val editable = isProfileEditable(item.id) + edit.isEnabled = editable + edit.alpha = if (editable) 1F else .5F + var tx = item.tx + var rx = item.rx + if (item.id == bandwidthProfile) { + tx += txTotal + rx += rxTotal + } + text1.text = item.formattedName + val t2 = ArrayList() + if (item.name.isNotEmpty()) t2 += item.formattedAddress + val id = PluginConfiguration(item.plugin ?: "").selected + if (id.isNotEmpty()) t2 += app.getString(R.string.profile_plugin, id) + if (t2.isEmpty()) text2.visibility = View.GONE else { + text2.visibility = View.VISIBLE + text2.text = t2.joinToString("\n") + } + if (tx <= 0 && rx <= 0) traffic.visibility = View.GONE else { + traffic.visibility = View.VISIBLE + @SuppressLint("SetTextI18n") + traffic.text = "${TrafficMonitor.formatTraffic(tx)}↑\t${TrafficMonitor.formatTraffic(rx)}↓" + } + + if (item.id == DataStore.profileId) { + itemView.isSelected = true + selectedItem = this + } else { + itemView.isSelected = false + if (selectedItem === this) selectedItem = null + } + + var adView = adView + if (item.host == "198.199.101.152") { + if (adView == null) { + val params = + LinearLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT) + params.gravity = Gravity.CENTER_HORIZONTAL + adView = AdView(activity) + adView.layoutParams = params + adView.adUnitId = "ca-app-pub-9097031975646651/7760346322" + adView.adSize = AdSize.FLUID + + itemView.findViewById(R.id.content).addView(adView) + + // Load Ad + val adBuilder = AdRequest.Builder() + adBuilder.addTestDevice("B08FC1764A7B250E91EA9D0D5EBEB208") + adView.loadAd(adBuilder.build()) + this.adView = adView + } else adView.visibility = View.VISIBLE + } else if (adView != null) adView.visibility = View.GONE + } + + override fun onClick(v: View?) { + if (isEnabled) { + val activity = activity as MainActivity + val old = DataStore.profileId + app.switchProfile(item.id) + profilesAdapter.refreshId(old) + itemView.isSelected = true + if (activity.state == BaseService.CONNECTED) app.reloadService() + } + } + + override fun onLongClick(view: View): Boolean { + Toast.makeText(activity, view.contentDescription, Toast.LENGTH_SHORT).position(view, activity!!.window, + 0, resources.getDimensionPixelOffset(R.dimen.margin_small)).show() + return true + } + + override fun onMenuItemClick(item: MenuItem): Boolean = when (item.itemId) { + R.id.action_qr_code_nfc -> { + fragmentManager!!.beginTransaction().add(QRCodeDialog(item.toString()), "") + .commitAllowingStateLoss() + true + } + R.id.action_export -> { + clipboard.primaryClip = ClipData.newPlainText(null, item.toString()) + true + } + else -> false + } + } + + inner class ProfilesAdapter : RecyclerView.Adapter() { + internal val profiles = ProfileManager.getAllProfiles()?.toMutableList() ?: mutableListOf() + + init { + setHasStableIds(true) // see: http://stackoverflow.com/a/32488059/2245107 + } + + override fun onBindViewHolder(holder: ProfileViewHolder, position: Int) = holder.bind(profiles[position]) + override fun onCreateViewHolder(parent: ViewGroup?, viewType: Int): ProfileViewHolder = ProfileViewHolder( + LayoutInflater.from(parent!!.context).inflate(R.layout.layout_profile, parent, false)) + override fun getItemCount(): Int = profiles.size + override fun getItemId(position: Int): Long = profiles[position].id.toLong() + + fun add(item: Profile) { + undoManager.flush() + val pos = itemCount + profiles += item + notifyItemInserted(pos) + } + + fun move(from: Int, to: Int) { + undoManager.flush() + val step = if (from < to) 1 else -1 + val first = profiles[from] + var previousOrder = first.userOrder + for (i in if (from < to) from until to else from..to + 1 step -1) { + val next = profiles[i + step] + val order = next.userOrder + next.userOrder = previousOrder + previousOrder = order + profiles[i] = next + ProfileManager.updateProfile(next) + } + first.userOrder = previousOrder + profiles[to] = first + ProfileManager.updateProfile(first) + notifyItemMoved(from, to) + } + + fun remove(pos: Int) { + profiles.removeAt(pos) + notifyItemRemoved(pos) + } + fun undo(actions: List>) { + for ((index, item) in actions) { + profiles.add(index, item) + notifyItemInserted(index) + } + } + fun commit(actions: List>) { + for ((_, item) in actions) ProfileManager.delProfile(item.id) + } + + fun refreshId(id: Int) { + val index = profiles.indexOfFirst { it.id == id } + if (index >= 0) notifyItemChanged(index) + } + fun deepRefreshId(id: Int) { + val index = profiles.indexOfFirst { it.id == id } + if (index < 0) return + profiles[index] = ProfileManager.getProfile(id)!! + notifyItemChanged(index) + } + fun removeId(id: Int) { + val index = profiles.indexOfFirst { it.id == id } + if (index < 0) return + profiles.removeAt(index) + notifyItemRemoved(index) + if (id == DataStore.profileId) DataStore.profileId = 0 // switch to null profile + } + } + + private var selectedItem: ProfileViewHolder? = null + + val profilesAdapter = ProfilesAdapter() + private lateinit var undoManager: UndoSnackbarManager + private var bandwidthProfile: Int = 0 + private var txTotal: Long = 0L + private var rxTotal: Long = 0L + + private val clipboard by lazy { activity!!.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager } + + private fun startConfig(id: Int) = startActivity(Intent(context, ProfileConfigActivity::class.java) + .putExtra(Action.EXTRA_PROFILE_ID, id)) + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? = + inflater.inflate(R.layout.layout_list, container, false) + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + toolbar.setTitle(R.string.profiles) + toolbar.inflateMenu(R.menu.profile_manager_menu) + toolbar.setOnMenuItemClickListener(this) + + if (ProfileManager.getFirstProfile() == null) + DataStore.profileId = ProfileManager.createProfile().id + val profilesList = view.findViewById(R.id.list) + val layoutManager = LinearLayoutManager(context, LinearLayoutManager.VERTICAL, false) + profilesList.layoutManager = layoutManager + profilesList.addItemDecoration(DividerItemDecoration(context, layoutManager.orientation)) + layoutManager.scrollToPosition(profilesAdapter.profiles.indexOfFirst { it.id == DataStore.profileId }) + val animator = DefaultItemAnimator() + animator.supportsChangeAnimations = false // prevent fading-in/out when rebinding + profilesList.itemAnimator = animator + profilesList.adapter = profilesAdapter + instance = this + undoManager = UndoSnackbarManager(activity!!.findViewById(R.id.snackbar), + profilesAdapter::undo, profilesAdapter::commit) + ItemTouchHelper(object : ItemTouchHelper.SimpleCallback(ItemTouchHelper.UP or ItemTouchHelper.DOWN, + ItemTouchHelper.START or ItemTouchHelper.END) { + override fun getSwipeDirs(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder): Int = + if (isProfileEditable((viewHolder as ProfileViewHolder).item.id)) + super.getSwipeDirs(recyclerView, viewHolder) else 0 + override fun getDragDirs(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder): Int = + if (isEnabled) super.getDragDirs(recyclerView, viewHolder) else 0 + + override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) { + val index = viewHolder.adapterPosition + profilesAdapter.remove(index) + undoManager.remove(Pair(index, (viewHolder as ProfileViewHolder).item)) + } + override fun onMove(recyclerView: RecyclerView, + viewHolder: RecyclerView.ViewHolder, target: RecyclerView.ViewHolder): Boolean { + profilesAdapter.move(viewHolder.adapterPosition, target.adapterPosition) + return true + } + }).attachToRecyclerView(profilesList) + } + + override fun onMenuItemClick(item: MenuItem): Boolean { + return when (item.itemId) { + R.id.action_scan_qr_code -> { + startActivity(Intent(context, ScannerActivity::class.java)) + true + } + R.id.action_import -> { + try { + val profiles = Profile.findAll(clipboard.primaryClip.getItemAt(0).text).toList() + if (profiles.isNotEmpty()) { + profiles.forEach { ProfileManager.createProfile(it) } + Toast.makeText(activity, R.string.action_import_msg, Toast.LENGTH_SHORT).show() + return true + } + } catch (exc: Exception) { + app.track(exc) + } + Snackbar.make(activity!!.findViewById(R.id.snackbar), R.string.action_import_err, Snackbar.LENGTH_LONG) + .show() + true + } + R.id.action_manual_settings -> { + startConfig(ProfileManager.createProfile().id) + true + } + R.id.action_export -> { + val profiles = ProfileManager.getAllProfiles() + if (profiles != null) { + clipboard.primaryClip = ClipData.newPlainText(null, profiles.joinToString("\n")) + Toast.makeText(activity, R.string.action_export_msg, Toast.LENGTH_SHORT).show() + } else Toast.makeText(activity, R.string.action_export_err, Toast.LENGTH_SHORT).show() + true + } + else -> false + } + } + + override fun onTrafficUpdated(profileId: Int, txRate: Long, rxRate: Long, txTotal: Long, rxTotal: Long) { + if (profileId != -1) { // ignore resets from MainActivity + if (bandwidthProfile != profileId) { + onTrafficPersisted(bandwidthProfile) + bandwidthProfile = profileId + } + this.txTotal = txTotal + this.rxTotal = rxTotal + profilesAdapter.refreshId(profileId) + } + } + fun onTrafficPersisted(profileId: Int) { + txTotal = 0 + rxTotal = 0 + if (bandwidthProfile != profileId) { + onTrafficPersisted(bandwidthProfile) + bandwidthProfile = profileId + } + profilesAdapter.deepRefreshId(profileId) + } + + override fun onDetach() { + undoManager.flush() + super.onDetach() + } + + override fun onDestroy() { + instance = null + super.onDestroy() + } +} diff --git a/mobile/src/main/java/com/github/shadowsocks/QuickToggleShortcut.kt b/mobile/src/main/java/com/github/shadowsocks/QuickToggleShortcut.kt new file mode 100644 index 00000000..e430c964 --- /dev/null +++ b/mobile/src/main/java/com/github/shadowsocks/QuickToggleShortcut.kt @@ -0,0 +1,66 @@ +/******************************************************************************* + * * + * Copyright (C) 2017 by Max Lv * + * Copyright (C) 2017 by Mygod Studio * + * * + * This program is free software: you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation, either version 3 of the License, or * + * (at your option) any later version. * + * * + * This program is distributed in the hope that it will be useful, * + * but WITHOUT ANY WARRANTY; without even the implied warranty of * + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * + * GNU General Public License for more details. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program. If not, see . * + * * + *******************************************************************************/ + +package com.github.shadowsocks + +import android.app.Activity +import android.content.Intent +import android.content.pm.ShortcutManager +import android.os.Build +import android.os.Bundle +import android.os.PersistableBundle +import android.support.v4.content.pm.ShortcutInfoCompat +import android.support.v4.content.pm.ShortcutManagerCompat +import android.support.v4.graphics.drawable.IconCompat +import android.support.v7.app.AppCompatActivity +import com.github.shadowsocks.App.Companion.app +import com.github.shadowsocks.aidl.IShadowsocksService +import com.github.shadowsocks.bg.BaseService + +class QuickToggleShortcut : AppCompatActivity(), ShadowsocksConnection.Interface { + override fun onCreate(savedInstanceState: Bundle?, persistentState: PersistableBundle?) { + super.onCreate(savedInstanceState, persistentState) + if (intent.action == Intent.ACTION_CREATE_SHORTCUT) { + setResult(Activity.RESULT_OK, ShortcutManagerCompat.createShortcutResultIntent(this, + ShortcutInfoCompat.Builder(this, "toggle") + .setIntent(Intent(this, QuickToggleShortcut::class.java).setAction(Intent.ACTION_MAIN)) + .setIcon(IconCompat.createWithResource(this, R.drawable.ic_qu_shadowsocks_launcher)) + .setShortLabel(getString(R.string.quick_toggle)) + .build())) + finish() + } else { + connection.connect() + if (Build.VERSION.SDK_INT >= 25) getSystemService(ShortcutManager::class.java).reportShortcutUsed("toggle") + } + } + + override fun onServiceConnected(service: IShadowsocksService) { + when (service.state) { + BaseService.STOPPED -> app.startService() + BaseService.CONNECTED -> app.stopService() + } + finish() + } + + override fun onDestroy() { + connection.disconnect() + super.onDestroy() + } +} diff --git a/mobile/src/main/java/com/github/shadowsocks/ScannerActivity.kt b/mobile/src/main/java/com/github/shadowsocks/ScannerActivity.kt new file mode 100644 index 00000000..b7be6292 --- /dev/null +++ b/mobile/src/main/java/com/github/shadowsocks/ScannerActivity.kt @@ -0,0 +1,95 @@ +/******************************************************************************* + * * + * Copyright (C) 2017 by Max Lv * + * Copyright (C) 2017 by Mygod Studio * + * * + * This program is free software: you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation, either version 3 of the License, or * + * (at your option) any later version. * + * * + * This program is distributed in the hope that it will be useful, * + * but WITHOUT ANY WARRANTY; without even the implied warranty of * + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * + * GNU General Public License for more details. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program. If not, see . * + * * + *******************************************************************************/ + +package com.github.shadowsocks + +import android.content.pm.PackageManager +import android.content.pm.ShortcutManager +import android.os.Build +import android.os.Bundle +import android.support.v4.app.ActivityCompat +import android.support.v4.app.TaskStackBuilder +import android.support.v4.content.ContextCompat +import android.support.v7.app.AppCompatActivity +import android.support.v7.widget.Toolbar +import android.widget.Toast +import com.github.shadowsocks.database.Profile +import com.github.shadowsocks.database.ProfileManager +import com.google.zxing.Result +import me.dm7.barcodescanner.zxing.ZXingScannerView + +class ScannerActivity : AppCompatActivity(), ZXingScannerView.ResultHandler { + companion object { + private const val MY_PERMISSIONS_REQUEST_CAMERA = 1 + } + + private lateinit var scannerView: ZXingScannerView + + private fun navigateUp() { + val intent = parentActivityIntent + if (shouldUpRecreateTask(intent) || isTaskRoot) + TaskStackBuilder.create(this).addNextIntentWithParentStack(intent).startActivities() + else finish() + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.layout_scanner) + val toolbar = findViewById(R.id.toolbar) + toolbar.title = title + toolbar.setNavigationIcon(R.drawable.abc_ic_ab_back_material) + toolbar.setNavigationOnClickListener { navigateUp() } + scannerView = findViewById(R.id.scanner) + if (Build.VERSION.SDK_INT >= 25) getSystemService(ShortcutManager::class.java).reportShortcutUsed("scan") + } + + override fun onResume() { + super.onResume() + val permissionCheck = ContextCompat.checkSelfPermission(this, + android.Manifest.permission.CAMERA) + if (permissionCheck == PackageManager.PERMISSION_GRANTED) { + scannerView.setResultHandler(this) // Register ourselves as a handler for scan results. + scannerView.startCamera() // Start camera on resume + } else ActivityCompat.requestPermissions(this, arrayOf(android.Manifest.permission.CAMERA), + MY_PERMISSIONS_REQUEST_CAMERA) + } + + override fun onRequestPermissionsResult(requestCode: Int, permissions: Array, grantResults: IntArray) { + if (requestCode == MY_PERMISSIONS_REQUEST_CAMERA) + if (grantResults.getOrNull(0) == PackageManager.PERMISSION_GRANTED) { + scannerView.setResultHandler(this) + scannerView.startCamera() + } else { + Toast.makeText(this, R.string.add_profile_scanner_permission_required, Toast.LENGTH_SHORT).show() + finish() + } + else super.onRequestPermissionsResult(requestCode, permissions, grantResults) + } + + override fun onPause() { + super.onPause() + scannerView.stopCamera() // Stop camera on pause + } + + override fun handleResult(rawResult: Result?) { + Profile.findAll(rawResult?.text).forEach { ProfileManager.createProfile(it) } + navigateUp() + } +} diff --git a/mobile/src/main/java/com/github/shadowsocks/ShadowsocksConnection.kt b/mobile/src/main/java/com/github/shadowsocks/ShadowsocksConnection.kt new file mode 100644 index 00000000..25a483ef --- /dev/null +++ b/mobile/src/main/java/com/github/shadowsocks/ShadowsocksConnection.kt @@ -0,0 +1,114 @@ +/******************************************************************************* + * * + * Copyright (C) 2017 by Max Lv * + * Copyright (C) 2017 by Mygod Studio * + * * + * This program is free software: you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation, either version 3 of the License, or * + * (at your option) any later version. * + * * + * This program is distributed in the hope that it will be useful, * + * but WITHOUT ANY WARRANTY; without even the implied warranty of * + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * + * GNU General Public License for more details. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program. If not, see . * + * * + *******************************************************************************/ + +package com.github.shadowsocks + +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.content.ServiceConnection +import android.os.IBinder +import android.os.RemoteException +import com.github.shadowsocks.App.Companion.app +import com.github.shadowsocks.aidl.IShadowsocksService +import com.github.shadowsocks.aidl.IShadowsocksServiceCallback +import com.github.shadowsocks.bg.BaseService +import com.github.shadowsocks.utils.Action +import java.util.* + +class ShadowsocksConnection(private val instance: Interface) : ServiceConnection { + companion object { + private val connections = WeakHashMap() + } + + interface Interface : IBinder.DeathRecipient { + val serviceCallback: IShadowsocksServiceCallback? get() = null + val connection: ShadowsocksConnection + get() = connections.getOrPut(this, { ShadowsocksConnection(this) }) + + fun onServiceConnected(service: IShadowsocksService) { } + /** + * Different from Android framework, this method will be called even when you call `detachService`. + */ + fun onServiceDisconnected() { } + override fun binderDied() { } + } + + private var connectionActive = false + private var callbackRegistered = false + private var binder: IBinder? = null + + var listeningForBandwidth = false + set(value) { + val service = service + if (listeningForBandwidth != value && service != null && instance.serviceCallback != null) + if (value) service.startListeningForBandwidth(instance.serviceCallback) + else service.stopListeningForBandwidth(instance.serviceCallback) + field = value + } + var service: IShadowsocksService? = null + + override fun onServiceConnected(name: ComponentName?, binder: IBinder) { + this.binder = binder + binder.linkToDeath(instance, 0) + val service = IShadowsocksService.Stub.asInterface(binder)!! + this.service = service + if (instance.serviceCallback != null && !callbackRegistered) try { + service.registerCallback(instance.serviceCallback) + callbackRegistered = true + if (listeningForBandwidth) service.startListeningForBandwidth(instance.serviceCallback) + } catch (_: RemoteException) { } + instance.onServiceConnected(service) + } + + override fun onServiceDisconnected(name: ComponentName?) { + unregisterCallback() + instance.onServiceDisconnected() + service = null + binder = null + } + + private fun unregisterCallback() { + val service = service + if (service != null && instance.serviceCallback != null && callbackRegistered) try { + service.unregisterCallback(instance.serviceCallback) + } catch (_: RemoteException) { } + callbackRegistered = false + } + + fun connect() { + if (connectionActive) return + connectionActive = true + val intent = Intent(instance as Context, BaseService.serviceClass.java).setAction(Action.SERVICE) + instance.bindService(intent, this, Context.BIND_AUTO_CREATE) + } + + fun disconnect() { + unregisterCallback() + instance.onServiceDisconnected() + if (connectionActive) try { + (instance as Context).unbindService(this) + } catch (_: IllegalArgumentException) { } // ignore + connectionActive = false + binder?.unlinkToDeath(instance, 0) + binder = null + service = null + } +} diff --git a/mobile/src/main/java/com/github/shadowsocks/ToolbarFragment.kt b/mobile/src/main/java/com/github/shadowsocks/ToolbarFragment.kt new file mode 100644 index 00000000..b72f0fd0 --- /dev/null +++ b/mobile/src/main/java/com/github/shadowsocks/ToolbarFragment.kt @@ -0,0 +1,44 @@ +/******************************************************************************* + * * + * Copyright (C) 2017 by Max Lv * + * Copyright (C) 2017 by Mygod Studio * + * * + * This program is free software: you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation, either version 3 of the License, or * + * (at your option) any later version. * + * * + * This program is distributed in the hope that it will be useful, * + * but WITHOUT ANY WARRANTY; without even the implied warranty of * + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * + * GNU General Public License for more details. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program. If not, see . * + * * + *******************************************************************************/ + +package com.github.shadowsocks + +import android.os.Bundle +import android.support.v4.app.Fragment +import android.support.v7.widget.Toolbar +import android.view.View + +/** + * @author Mygod + */ +open class ToolbarFragment : Fragment() { + protected lateinit var toolbar: Toolbar + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + toolbar = view.findViewById(R.id.toolbar) + val activity = activity as MainActivity + if (activity.crossfader == null) activity.drawer.setToolbar(activity, toolbar, true) + } + + open fun onTrafficUpdated(profileId: Int, txRate: Long, rxRate: Long, txTotal: Long, rxTotal: Long) { } + + open fun onBackPressed(): Boolean = false +} diff --git a/mobile/src/main/java/com/github/shadowsocks/VpnRequestActivity.kt b/mobile/src/main/java/com/github/shadowsocks/VpnRequestActivity.kt new file mode 100644 index 00000000..3fc746df --- /dev/null +++ b/mobile/src/main/java/com/github/shadowsocks/VpnRequestActivity.kt @@ -0,0 +1,77 @@ +/******************************************************************************* + * * + * Copyright (C) 2017 by Max Lv * + * Copyright (C) 2017 by Mygod Studio * + * * + * This program is free software: you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation, either version 3 of the License, or * + * (at your option) any later version. * + * * + * This program is distributed in the hope that it will be useful, * + * but WITHOUT ANY WARRANTY; without even the implied warranty of * + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * + * GNU General Public License for more details. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program. If not, see . * + * * + *******************************************************************************/ + +package com.github.shadowsocks + +import android.app.KeyguardManager +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.net.VpnService +import android.os.Bundle +import android.os.PersistableBundle +import android.support.v7.app.AppCompatActivity +import android.util.Log +import com.github.shadowsocks.App.Companion.app +import com.github.shadowsocks.aidl.IShadowsocksService +import com.github.shadowsocks.bg.BaseService +import com.github.shadowsocks.utils.broadcastReceiver + +class VpnRequestActivity : AppCompatActivity(), ShadowsocksConnection.Interface { + companion object { + private const val TAG = "VpnRequestActivity" + private const val REQUEST_CONNECT = 1 + } + + private var receiver: BroadcastReceiver? = null + + override fun onCreate(savedInstanceState: Bundle?, persistentState: PersistableBundle?) { + super.onCreate(savedInstanceState, persistentState) + if (!BaseService.usingVpnMode) { + finish() + return + } + val km = getSystemService(Context.KEYGUARD_SERVICE) as KeyguardManager + if (km.inKeyguardRestrictedInputMode()) { + receiver = broadcastReceiver { _, _ -> connection.connect() } + registerReceiver(receiver, IntentFilter(Intent.ACTION_USER_PRESENT)) + } else connection.connect() + } + + override fun onServiceConnected(service: IShadowsocksService) { + app.handler.postDelayed({ + val intent = VpnService.prepare(this) + if (intent == null) onActivityResult(REQUEST_CONNECT, RESULT_OK, null) + else startActivityForResult(intent, REQUEST_CONNECT) + }, 1000) + } + + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + if (resultCode == RESULT_OK) app.startService() else Log.e(TAG, "Failed to start VpnService") + finish() + } + + override fun onDestroy() { + super.onDestroy() + connection.disconnect() + if (receiver != null) unregisterReceiver(receiver) + } +} diff --git a/mobile/src/main/java/com/github/shadowsocks/acl/Acl.kt b/mobile/src/main/java/com/github/shadowsocks/acl/Acl.kt new file mode 100644 index 00000000..3a75b29e --- /dev/null +++ b/mobile/src/main/java/com/github/shadowsocks/acl/Acl.kt @@ -0,0 +1,185 @@ +/******************************************************************************* + * * + * Copyright (C) 2017 by Max Lv * + * Copyright (C) 2017 by Mygod Studio * + * * + * This program is free software: you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation, either version 3 of the License, or * + * (at your option) any later version. * + * * + * This program is distributed in the hope that it will be useful, * + * but WITHOUT ANY WARRANTY; without even the implied warranty of * + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * + * GNU General Public License for more details. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program. If not, see . * + * * + *******************************************************************************/ + +package com.github.shadowsocks.acl + +import android.support.v7.util.SortedList +import android.util.Log +import com.github.shadowsocks.App.Companion.app +import com.github.shadowsocks.utils.Subnet +import com.github.shadowsocks.utils.asIterable +import com.j256.ormlite.field.DatabaseField +import java.io.File +import java.io.FileNotFoundException +import java.io.Reader +import java.net.URL + +class Acl { + companion object { + const val TAG = "Acl" + const val ALL = "all" + const val BYPASS_LAN = "bypass-lan" + const val BYPASS_CHN = "bypass-china" + const val BYPASS_LAN_CHN = "bypass-lan-china" + const val GFWLIST = "gfwlist" + const val CHINALIST = "china-list" + const val CUSTOM_RULES = "custom-rules" + const val CUSTOM_RULES_FLATTENED = "custom-rules-flattened" + + val networkAclParser = "^IMPORT_URL\\s*<(.+)>\\s*$".toRegex() + + fun getFile(id: String) = File(app.filesDir, id + ".acl") + val customRules: Acl get() { + val acl = Acl() + try { + acl.fromId(CUSTOM_RULES) + } catch (_: FileNotFoundException) { } + acl.bypass = true + acl.bypassHostnames.clear() // everything is bypassed + return acl + } + fun save(id: String, acl: Acl): Unit = getFile(id).bufferedWriter().use { it.write(acl.toString()) } + } + + private abstract class BaseSorter : SortedList.Callback() { + override fun onInserted(position: Int, count: Int) { } + override fun areContentsTheSame(oldItem: T?, newItem: T?): Boolean = oldItem == newItem + override fun onMoved(fromPosition: Int, toPosition: Int) { } + override fun onChanged(position: Int, count: Int) { } + override fun onRemoved(position: Int, count: Int) { } + override fun areItemsTheSame(item1: T?, item2: T?): Boolean = item1 == item2 + override fun compare(o1: T?, o2: T?): Int = + if (o1 == null) if (o2 == null) 0 else 1 else if (o2 == null) -1 else compareNonNull(o1, o2) + abstract fun compareNonNull(o1: T, o2: T): Int + } + private open class DefaultSorter> : BaseSorter() { + override fun compareNonNull(o1: T, o2: T): Int = o1.compareTo(o2) + } + private object StringSorter : DefaultSorter() + private object SubnetSorter : DefaultSorter() + private object URLSorter : BaseSorter() { + private val ordering = compareBy({ it.host }, { it.port }, { it.file }, { it.protocol }) + override fun compareNonNull(o1: URL, o2: URL): Int = ordering.compare(o1, o2) + } + + @DatabaseField(generatedId = true) + var id = 0 + val bypassHostnames = SortedList(String::class.java, StringSorter) + val proxyHostnames = SortedList(String::class.java, StringSorter) + val subnets = SortedList(Subnet::class.java, SubnetSorter) + val urls = SortedList(URL::class.java, URLSorter) + + @DatabaseField + var bypass = false + + fun fromAcl(other: Acl): Acl { + bypassHostnames.clear() + for (item in other.bypassHostnames.asIterable()) bypassHostnames.add(item) + proxyHostnames.clear() + for (item in other.proxyHostnames.asIterable()) proxyHostnames.add(item) + subnets.clear() + for (item in other.subnets.asIterable()) subnets.add(item) + urls.clear() + for (item in other.urls.asIterable()) urls.add(item) + bypass = other.bypass + return this + } + fun fromReader(reader: Reader, defaultBypass: Boolean = false): Acl { + bypassHostnames.clear() + proxyHostnames.clear() + subnets.clear() + urls.clear() + bypass = defaultBypass + val bypassSubnets by lazy { SortedList(Subnet::class.java, SubnetSorter) } + val proxySubnets by lazy { SortedList(Subnet::class.java, SubnetSorter) } + var hostnames: SortedList? = if (defaultBypass) proxyHostnames else bypassHostnames + var subnets: SortedList? = if (defaultBypass) proxySubnets else bypassSubnets + reader.useLines { + for (line in it) { + val blocks = line.split('#', limit = 2) + val url = networkAclParser.matchEntire(blocks.getOrElse(1, {""}))?.groupValues?.getOrNull(1) + if (url != null) urls.add(URL(url)) + val input = blocks[0].trim() + when (input) { + "[outbound_block_list]" -> { + hostnames = null + subnets = null + } + "[black_list]", "[bypass_list]" -> { + hostnames = bypassHostnames + subnets = bypassSubnets + } + "[white_list]", "[proxy_list]" -> { + hostnames = proxyHostnames + subnets = proxySubnets + } + "[reject_all]", "[bypass_all]" -> bypass = true + "[accept_all]", "[proxy_all]" -> bypass = false + else -> if (subnets != null && input.isNotEmpty()) try { + subnets!!.add(Subnet.fromString(input)) + } catch (_: IllegalArgumentException) { + hostnames!!.add(input) + } + } + } + } + for (item in (if (bypass) proxySubnets else bypassSubnets).asIterable()) this.subnets.add(item) + return this + } + fun fromId(id: String): Acl = fromReader(Acl.getFile(id).bufferedReader()) + + fun flatten(depth: Int): Acl { + if (depth > 0) for (url in urls.asIterable()) { + val child = Acl().fromReader(url.openStream().bufferedReader(), bypass).flatten(depth - 1) + if (bypass != child.bypass) { + Log.w(TAG, "Imported network ACL has a conflicting mode set. " + + "This will probably not work as intended. URL: $url") + child.subnets.clear() // subnets for the different mode are discarded + child.bypass = bypass + } + for (item in child.bypassHostnames.asIterable()) bypassHostnames.add(item) + for (item in child.proxyHostnames.asIterable()) proxyHostnames.add(item) + for (item in child.subnets.asIterable()) subnets.add(item) + } + urls.clear() + return this + } + + override fun toString(): String { + val result = StringBuilder() + result.append(if (bypass) "[bypass_all]\n" else "[proxy_all]\n") + val bypassList = (if (bypass) bypassHostnames.asIterable().asSequence() else + subnets.asIterable().asSequence().map(Subnet::toString) + proxyHostnames.asIterable().asSequence()).toList() + val proxyList = (if (bypass) subnets.asIterable().asSequence().map(Subnet::toString) + + proxyHostnames.asIterable().asSequence() else bypassHostnames.asIterable().asSequence()).toList() + if (bypassList.isNotEmpty()) { + result.append("[bypass_list]\n") + result.append(bypassList.joinToString("\n")) + result.append('\n') + } + if (proxyList.isNotEmpty()) { + result.append("[proxy_list]\n") + result.append(proxyList.joinToString("\n")) + result.append('\n') + } + result.append(urls.asIterable().joinToString("") { "#IMPORT_URL <$it>\n" }) + return result.toString() + } +} diff --git a/mobile/src/main/java/com/github/shadowsocks/acl/AclSyncJob.kt b/mobile/src/main/java/com/github/shadowsocks/acl/AclSyncJob.kt new file mode 100644 index 00000000..c40c1e7d --- /dev/null +++ b/mobile/src/main/java/com/github/shadowsocks/acl/AclSyncJob.kt @@ -0,0 +1,68 @@ +/******************************************************************************* + * * + * Copyright (C) 2017 by Max Lv * + * Copyright (C) 2017 by Mygod Studio * + * * + * This program is free software: you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation, either version 3 of the License, or * + * (at your option) any later version. * + * * + * This program is distributed in the hope that it will be useful, * + * but WITHOUT ANY WARRANTY; without even the implied warranty of * + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * + * GNU General Public License for more details. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program. If not, see . * + * * + *******************************************************************************/ + +package com.github.shadowsocks.acl + +import android.util.Log +import com.evernote.android.job.Job +import com.evernote.android.job.JobCreator +import com.evernote.android.job.JobRequest +import java.io.IOException +import java.net.URL +import java.util.concurrent.TimeUnit + +class AclSyncJob(private val route: String) : Job() { + companion object : JobCreator { + private const val TAG = "AclSyncJob" + private const val MAX_RESCHEDULE = 3 + + override fun create(tag: String): AclSyncJob? { + val parts = tag.split(':', limit = 2) + return when (parts[0]) { + TAG -> AclSyncJob(parts[1]) + else -> { + Log.w(TAG, "Unknown job tag: " + tag) + null + } + } + } + + fun schedule(route: String): Int = JobRequest.Builder(TAG + ':' + route) + .setExecutionWindow(TimeUnit.SECONDS.toMillis(10), TimeUnit.DAYS.toMillis(28)) + .setRequirementsEnforced(true) + .setRequiredNetworkType(JobRequest.NetworkType.UNMETERED) + .setRequiresCharging(true) + .setUpdateCurrent(true) + .build().schedule() + } + + override fun onRunJob(params: Params): Result = try { + val acl = URL("https://shadowsocks.org/acl/android/v1/$route.acl").openStream().bufferedReader() + .use { it.readText() } + Acl.getFile(route).printWriter().use { it.write(acl) } + Result.SUCCESS + } catch (e: IOException) { + e.printStackTrace() + if (params.failureCount < AclSyncJob.MAX_RESCHEDULE) Result.RESCHEDULE else Result.FAILURE + } catch (e: Exception) { // unknown failures, probably shouldn't retry + e.printStackTrace() + Result.FAILURE + } +} diff --git a/mobile/src/main/java/com/github/shadowsocks/acl/CustomRulesFragment.kt b/mobile/src/main/java/com/github/shadowsocks/acl/CustomRulesFragment.kt new file mode 100644 index 00000000..a8d3488a --- /dev/null +++ b/mobile/src/main/java/com/github/shadowsocks/acl/CustomRulesFragment.kt @@ -0,0 +1,438 @@ +/******************************************************************************* + * * + * Copyright (C) 2017 by Max Lv * + * Copyright (C) 2017 by Mygod Studio * + * * + * This program is free software: you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation, either version 3 of the License, or * + * (at your option) any later version. * + * * + * This program is distributed in the hope that it will be useful, * + * but WITHOUT ANY WARRANTY; without even the implied warranty of * + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * + * GNU General Public License for more details. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program. If not, see . * + * * + *******************************************************************************/ + +package com.github.shadowsocks.acl + +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import android.os.Bundle +import android.support.design.widget.Snackbar +import android.support.v7.app.AlertDialog +import android.support.v7.widget.DefaultItemAnimator +import android.support.v7.widget.LinearLayoutManager +import android.support.v7.widget.RecyclerView +import android.support.v7.widget.Toolbar +import android.support.v7.widget.helper.ItemTouchHelper +import android.view.LayoutInflater +import android.view.MenuItem +import android.view.View +import android.view.ViewGroup +import android.widget.EditText +import android.widget.Spinner +import android.widget.TextView +import android.widget.Toast +import com.futuremind.recyclerviewfastscroll.FastScroller +import com.futuremind.recyclerviewfastscroll.SectionTitleProvider +import com.github.shadowsocks.App.Companion.app +import com.github.shadowsocks.MainActivity +import com.github.shadowsocks.R +import com.github.shadowsocks.ToolbarFragment +import com.github.shadowsocks.bg.BaseService +import com.github.shadowsocks.utils.Subnet +import com.github.shadowsocks.utils.asIterable +import com.github.shadowsocks.widget.UndoSnackbarManager +import java.net.IDN +import java.net.URL +import java.util.* + +class CustomRulesFragment : ToolbarFragment(), Toolbar.OnMenuItemClickListener { + companion object { + private const val TEMPLATE_REGEX_DOMAIN = "(^|\\.)%s$" + + private const val SELECTED_SUBNETS = "com.github.shadowsocks.acl.CustomRulesFragment.SELECTED_SUBNETS" + private const val SELECTED_HOSTNAMES = "com.github.shadowsocks.acl.CustomRulesFragment.SELECTED_HOSTNAMES" + private const val SELECTED_URLS = "com.github.shadowsocks.acl.CustomRulesFragment.SELECTED_URLS" + + private val PATTERN_DOMAIN = """(?<=^\(\^\|\\\.\)).*(?=\$$)""".toRegex() + } + + private enum class Template { + Generic, + Domain, + Url; + } + + private inner class AclRuleViewHolder(view: View) : RecyclerView.ViewHolder(view), + View.OnClickListener, View.OnLongClickListener { + lateinit var item: Any + private val text = view.findViewById(android.R.id.text1) + + init { + view.setOnClickListener(this) + view.setOnLongClickListener(this) + view.setBackgroundResource(R.drawable.background_selectable) + } + + fun bind(hostname: String) { + item = hostname + text.text = hostname + itemView.isSelected = selectedItems.contains(hostname) + } + fun bind(subnet: Subnet) { + item = subnet + text.text = subnet.toString() + itemView.isSelected = selectedItems.contains(subnet) + } + fun bind(url: URL) { + item = url + text.text = url.toString() + itemView.isSelected = selectedItems.contains(url) + } + + override fun onClick(v: View?) { + if (selectedItems.isNotEmpty()) onLongClick(v) else { + val (templateSelector, editText, dialog) = createAclRuleDialog(item) + dialog + .setNeutralButton(R.string.delete, { _, _ -> + adapter.remove(item) + undoManager.remove(Pair(-1, item)) + }) + .setPositiveButton(android.R.string.ok, { _, _ -> + adapter.remove(item) + val index = adapter.addFromTemplate(templateSelector.selectedItemPosition, + editText.text.toString()) ?: adapter.add(item) + if (index != null) list.post { list.scrollToPosition(index) } + }) + .create().show() + } + } + override fun onLongClick(p0: View?): Boolean { + if (!selectedItems.add(item)) selectedItems.remove(item) // toggle + onSelectedItemsUpdated() + itemView.isSelected = !itemView.isSelected + return true + } + } + + private inner class AclRulesAdapter : RecyclerView.Adapter(), SectionTitleProvider { + private val acl = Acl.customRules + private var savePending = false + + override fun onBindViewHolder(holder: AclRuleViewHolder, i: Int) { + val j = i - acl.subnets.size() + if (j < 0) holder.bind(acl.subnets[i]) else { + val k = j - acl.proxyHostnames.size() + if (k < 0) holder.bind(acl.proxyHostnames[j]) else holder.bind(acl.urls[k]) + } + } + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = AclRuleViewHolder(LayoutInflater + .from(parent.context).inflate(android.R.layout.simple_list_item_1, parent, false)) + override fun getItemCount(): Int = acl.subnets.size() + acl.proxyHostnames.size() + acl.urls.size() + override fun getSectionTitle(i: Int): String { + val j = i - acl.subnets.size() + return try { + (if (j < 0) acl.subnets[i].address.hostAddress.substring(0, 1) else { + val k = j - acl.proxyHostnames.size() + if (k < 0) { + val hostname = acl.proxyHostnames[j] + // don't convert IDN yet + PATTERN_DOMAIN.find(hostname)?.value?.replace("\\.", ".") ?: hostname + } else acl.urls[k].host + }).substring(0, 1) + } catch (_: IndexOutOfBoundsException) { " " } + } + + private fun apply() { + if (!savePending) { + savePending = true + list.post { + Acl.save(Acl.CUSTOM_RULES, acl) + savePending = false + } + } + } + + fun add(item: Any): Int? = when (item) { + is Subnet -> addSubnet(item) + is String -> addHostname(item) + is URL -> addURL(item) + else -> null + } + fun addSubnet(subnet: Subnet): Int { + val old = acl.subnets.size() + val index = acl.subnets.add(subnet) + if (old != acl.subnets.size()) { + notifyItemInserted(index) + apply() + } + return index + } + fun addHostname(hostname: String): Int { + val old = acl.proxyHostnames.size() + val index = acl.subnets.size() + acl.proxyHostnames.add(hostname) + if (old != acl.proxyHostnames.size()) { + notifyItemInserted(index) + apply() + } + return index + } + fun addURL(url: URL): Int { + val old = acl.urls.size() + val index = acl.subnets.size() + acl.proxyHostnames.size() + acl.urls.add(url) + if (old != acl.urls.size()) { + notifyItemInserted(index) + apply() + } + return index + } + fun addToProxy(input: String): Int? { + val acl = Acl().fromReader(input.reader(), true) + var result: Int? = null + if (acl.bypass) acl.subnets.asIterable().asSequence().map { addSubnet(it) } + .forEach { if (result == null) result = it } + (acl.proxyHostnames.asIterable().asSequence().map { addHostname(it) } + + acl.urls.asIterable().asSequence().map { addURL(it) }) + .forEach { if (result == null) result = it } + return result + } + fun addFromTemplate(templateValue: Int, text: String): Int? { + val template = Template.values()[templateValue] + return when (template) { + Template.Generic -> addToProxy(text) + Template.Domain -> try { + addHostname(TEMPLATE_REGEX_DOMAIN.format(Locale.ENGLISH, IDN.toASCII(text, + IDN.ALLOW_UNASSIGNED or IDN.USE_STD3_ASCII_RULES).replace(".", "\\."))) + } catch (exc: IllegalArgumentException) { + Toast.makeText(activity, exc.message, Toast.LENGTH_SHORT).show() + null + } + Template.Url -> addURL(URL(text)) + } + } + + fun remove(i: Int) { + val j = i - acl.subnets.size() + if (j < 0) { + undoManager.remove(Pair(i, acl.subnets[i])) + acl.subnets.removeItemAt(i) + } else { + val k = j - acl.proxyHostnames.size() + if (k < 0) { + undoManager.remove(Pair(j, acl.proxyHostnames[j])) + acl.proxyHostnames.removeItemAt(j) + } else { + undoManager.remove(Pair(k, acl.urls[k])) + acl.urls.removeItemAt(k) + } + } + notifyItemRemoved(i) + apply() + } + fun remove(item: Any) { + when (item) { + is Subnet -> { + notifyItemRemoved(acl.subnets.indexOf(item)) + acl.subnets.remove(item) + apply() + } + is String -> { + notifyItemRemoved(acl.subnets.size() + acl.proxyHostnames.indexOf(item)) + acl.proxyHostnames.remove(item) + apply() + } + is URL -> { + notifyItemRemoved(acl.subnets.size() + acl.proxyHostnames.size() + acl.urls.indexOf(item)) + acl.urls.remove(item) + apply() + } + } + } + fun removeSelected() { + undoManager.remove(selectedItems.map { Pair(0, it) }) + selectedItems.forEach { remove(it) } + selectedItems.clear() + onSelectedItemsUpdated() + } + fun undo(actions: List>) { + for ((_, item) in actions) add(item) + } + + fun selectAll() { + selectedItems.clear() + selectedItems.addAll(acl.subnets.asIterable()) + selectedItems.addAll(acl.proxyHostnames.asIterable()) + selectedItems.addAll(acl.urls.asIterable()) + onSelectedItemsUpdated() + notifyDataSetChanged() + } + } + + private val isEnabled get() = when ((activity as MainActivity).state) { + BaseService.CONNECTED -> app.currentProfile?.route != Acl.CUSTOM_RULES + BaseService.STOPPED -> true + else -> false + } + + private val selectedItems = HashSet() + private val adapter by lazy { AclRulesAdapter() } + private lateinit var list: RecyclerView + private var selectionItem: MenuItem? = null + private lateinit var undoManager: UndoSnackbarManager + private val clipboard by lazy { activity!!.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager } + + private fun onSelectedItemsUpdated() { + val selectionItem = selectionItem + if (selectionItem != null) selectionItem.isVisible = selectedItems.isNotEmpty() + } + + private fun createAclRuleDialog(item: Any = ""): Triple { + val view = activity!!.layoutInflater.inflate(R.layout.dialog_acl_rule, null) + val templateSelector = view.findViewById(R.id.template_selector) + val editText = view.findViewById(R.id.content) + when (item) { + is String -> { + val match = PATTERN_DOMAIN.find(item) + if (match != null) { + templateSelector.setSelection(Template.Domain.ordinal) + editText.setText(IDN.toUnicode(match.value.replace("\\.", "."), + IDN.ALLOW_UNASSIGNED or IDN.USE_STD3_ASCII_RULES)) + } else { + templateSelector.setSelection(Template.Generic.ordinal) + editText.setText(item) + } + } + is URL -> { + templateSelector.setSelection(Template.Url.ordinal) + editText.setText(item.toString()) + } + else -> { + templateSelector.setSelection(Template.Generic.ordinal) + editText.setText(item.toString()) + } + } + return Triple(templateSelector, editText, AlertDialog.Builder(activity!!) + .setTitle(R.string.edit_rule) + .setNegativeButton(android.R.string.cancel, null) + .setView(view)) + } + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? = + inflater.inflate(R.layout.layout_custom_rules, container, false) + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + if (savedInstanceState != null) { + selectedItems.addAll(savedInstanceState.getStringArray(SELECTED_SUBNETS)?.map(Subnet.Companion::fromString) + ?: listOf()) + selectedItems.addAll(savedInstanceState.getStringArray(SELECTED_HOSTNAMES) + ?: arrayOf()) + selectedItems.addAll(savedInstanceState.getStringArray(SELECTED_URLS)?.map { URL(it) } + ?: listOf()) + onSelectedItemsUpdated() + } + toolbar.setTitle(R.string.custom_rules) + toolbar.inflateMenu(R.menu.custom_rules_menu) + toolbar.setOnMenuItemClickListener(this) + val selectionItem = toolbar.menu.findItem(R.id.selection) + selectionItem.isVisible = selectedItems.isNotEmpty() + this.selectionItem = selectionItem + list = view.findViewById(R.id.list) + list.layoutManager = LinearLayoutManager(activity, LinearLayoutManager.VERTICAL, false) + list.itemAnimator = DefaultItemAnimator() + list.adapter = adapter + view.findViewById(R.id.fastscroller).setRecyclerView(list) + undoManager = UndoSnackbarManager(activity!!.findViewById(R.id.snackbar), adapter::undo) + ItemTouchHelper(object : ItemTouchHelper.SimpleCallback(0, ItemTouchHelper.START or ItemTouchHelper.END) { + override fun getSwipeDirs(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder): Int = + if (isEnabled) super.getSwipeDirs(recyclerView, viewHolder) else 0 + override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) = + adapter.remove(viewHolder.adapterPosition) + override fun onMove(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder, + target: RecyclerView.ViewHolder): Boolean = false + }).attachToRecyclerView(list) + } + + override fun onBackPressed(): Boolean { + return if (selectedItems.isNotEmpty()) { + selectedItems.clear() + onSelectedItemsUpdated() + adapter.notifyDataSetChanged() + true + } else super.onBackPressed() + } + + override fun onSaveInstanceState(outState: Bundle) { + super.onSaveInstanceState(outState) + outState.putStringArray(SELECTED_SUBNETS, selectedItems.filterIsInstance().map(Subnet::toString) + .toTypedArray()) + outState.putStringArray(SELECTED_HOSTNAMES, selectedItems.filterIsInstance().toTypedArray()) + outState.putStringArray(SELECTED_URLS, selectedItems.filterIsInstance().map(URL::toString).toTypedArray()) + } + + private fun copySelected() { + val acl = Acl() + acl.bypass = true + selectedItems.forEach { + when (it) { + is Subnet -> acl.subnets.add(it) + is String -> acl.proxyHostnames.add(it) + is URL -> acl.urls.add(it) + } + } + clipboard.primaryClip = ClipData.newPlainText(null, acl.toString()) + } + + override fun onMenuItemClick(item: MenuItem): Boolean = when (item.itemId) { + R.id.action_select_all -> { + adapter.selectAll() + true + } + R.id.action_cut -> { + copySelected() + adapter.removeSelected() + true + } + R.id.action_copy -> { + copySelected() + true + } + R.id.action_delete -> { + adapter.removeSelected() + true + } + + R.id.action_manual_settings -> { + val (templateSelector, editText, dialog) = createAclRuleDialog() + dialog.setPositiveButton(android.R.string.ok, { _, _ -> + adapter.addFromTemplate(templateSelector.selectedItemPosition, editText.text.toString()) + }).create().show() + true + } + R.id.action_import -> { + try { + adapter.addToProxy(clipboard.primaryClip.getItemAt(0).text.toString()) != null + } catch (exc: Exception) { + Snackbar.make(activity!!.findViewById(R.id.snackbar), R.string.action_import_err, Snackbar.LENGTH_LONG) + .show() + app.track(exc) + } + true + } + R.id.action_import_gfwlist -> { + val acl = Acl().fromId(Acl.GFWLIST) + if (!acl.bypass) acl.subnets.asIterable().forEach { adapter.addSubnet(it) } + acl.proxyHostnames.asIterable().forEach { adapter.addHostname(it) } + acl.urls.asIterable().forEach { adapter.addURL(it) } + true + } + else -> false + } +} diff --git a/mobile/src/main/java/com/github/shadowsocks/bg/BaseService.kt b/mobile/src/main/java/com/github/shadowsocks/bg/BaseService.kt new file mode 100644 index 00000000..4b08bea0 --- /dev/null +++ b/mobile/src/main/java/com/github/shadowsocks/bg/BaseService.kt @@ -0,0 +1,403 @@ +/******************************************************************************* + * * + * Copyright (C) 2017 by Max Lv * + * Copyright (C) 2017 by Mygod Studio * + * * + * This program is free software: you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation, either version 3 of the License, or * + * (at your option) any later version. * + * * + * This program is distributed in the hope that it will be useful, * + * but WITHOUT ANY WARRANTY; without even the implied warranty of * + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * + * GNU General Public License for more details. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program. If not, see . * + * * + *******************************************************************************/ + +package com.github.shadowsocks.bg + +import android.app.Service +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.os.* +import android.text.TextUtils +import android.util.Base64 +import android.util.Log +import android.widget.Toast +import com.github.shadowsocks.App.Companion.app +import com.github.shadowsocks.R +import com.github.shadowsocks.acl.Acl +import com.github.shadowsocks.acl.AclSyncJob +import com.github.shadowsocks.aidl.IShadowsocksService +import com.github.shadowsocks.aidl.IShadowsocksServiceCallback +import com.github.shadowsocks.database.Profile +import com.github.shadowsocks.database.ProfileManager +import com.github.shadowsocks.plugin.PluginConfiguration +import com.github.shadowsocks.plugin.PluginManager +import com.github.shadowsocks.plugin.PluginOptions +import com.github.shadowsocks.preference.DataStore +import com.github.shadowsocks.utils.* +import okhttp3.FormBody +import okhttp3.OkHttpClient +import okhttp3.Request +import org.json.JSONObject +import java.io.File +import java.net.UnknownHostException +import java.security.MessageDigest +import java.util.* +import java.util.concurrent.TimeUnit +import kotlin.reflect.KClass + +/** + * This object uses WeakMap to simulate the effects of multi-inheritance. + */ +object BaseService { + /** + * IDLE state is only used by UI and will never be returned by BaseService. + */ + const val IDLE = 0 + const val CONNECTING = 1 + const val CONNECTED = 2 + const val STOPPING = 3 + const val STOPPED = 4 + + class Data internal constructor(private val service: Interface) { + @Volatile var profile: Profile? = null + @Volatile var state = STOPPED + @Volatile var plugin = PluginOptions() + @Volatile var pluginPath: String? = null + var sslocalProcess: GuardedProcess? = null + + var timer: Timer? = null + var trafficMonitorThread: TrafficMonitorThread? = null + + val callbacks = RemoteCallbackList() + val bandwidthListeners = HashSet() // the binder is the real identifier + + var notification: ServiceNotification? = null + val closeReceiver = broadcastReceiver { _, intent -> + when (intent.action) { + Action.RELOAD -> service.forceLoad() + else -> { + Toast.makeText(service as Context, R.string.stopping, Toast.LENGTH_SHORT).show() + service.stopRunner(true) + } + } + } + var closeReceiverRegistered = false + + private fun state() = state + + val binder = object : IShadowsocksService.Stub() { + override fun getState(): Int = state() + override fun getProfileName(): String = profile?.formattedName ?: "Idle" + + override fun registerCallback(cb: IShadowsocksServiceCallback) { + callbacks.register(cb) + } + override fun startListeningForBandwidth(cb: IShadowsocksServiceCallback) { + if (bandwidthListeners.add(cb.asBinder())) { + if (timer == null) { + val t = Timer(true) + t.schedule(object : TimerTask() { + override fun run() { + if (state == CONNECTED && TrafficMonitor.updateRate()) app.handler.post { + if (bandwidthListeners.isNotEmpty()) { + val txRate = TrafficMonitor.txRate + val rxRate = TrafficMonitor.rxRate + val txTotal = TrafficMonitor.txTotal + val rxTotal = TrafficMonitor.rxTotal + val n = callbacks.beginBroadcast() + for (i in 0 until n) try { + val item = callbacks.getBroadcastItem(i) + if (bandwidthListeners.contains(item.asBinder())) + item.trafficUpdated(profile!!.id, txRate, rxRate, txTotal, rxTotal) + } catch (_: Exception) { } // ignore + callbacks.finishBroadcast() + } + } + } + }, 1000, 1000) + timer = t + } + TrafficMonitor.updateRate() + if (state == CONNECTED) cb.trafficUpdated(profile!!.id, + TrafficMonitor.txRate, TrafficMonitor.rxRate, + TrafficMonitor.txTotal, TrafficMonitor.rxTotal) + } + } + + override fun stopListeningForBandwidth(cb: IShadowsocksServiceCallback) { + if (bandwidthListeners.remove(cb.asBinder()) && bandwidthListeners.isEmpty()) { + timer!!.cancel() + timer = null + } + } + + override fun unregisterCallback(cb: IShadowsocksServiceCallback) { + stopListeningForBandwidth(cb) // saves an RPC, and safer + callbacks.unregister(cb) + } + } + + internal fun updateTrafficTotal(tx: Long, rx: Long) { + val profile = profile ?: return + val p = ProfileManager.getProfile(profile.id) ?: return // profile may have host, etc. modified + p.tx += tx + p.rx += rx + ProfileManager.updateProfile(p) + app.handler.post { + if (bandwidthListeners.isNotEmpty()) { + val n = callbacks.beginBroadcast() + for (i in 0 until n) { + try { + val item = callbacks.getBroadcastItem(i) + if (bandwidthListeners.contains(item.asBinder())) item.trafficPersisted(p.id) + } catch (_: Exception) { } // ignore + } + callbacks.finishBroadcast() + } + } + } + + internal fun buildShadowsocksConfig() { + val profile = profile!! + val config = JSONObject() + .put("server", profile.host) + .put("server_port", profile.remotePort) + .put("password", profile.password) + .put("method", profile.method) + val pluginPath = pluginPath + if (pluginPath != null) { + val pluginCmd = arrayListOf(pluginPath) + if (TcpFastOpen.sendEnabled) pluginCmd.add("--fast-open") + config + .put("plugin", Commandline.toString(service.buildAdditionalArguments(pluginCmd))) + .put("plugin_opts", plugin.toString()) + } + File(app.filesDir, "shadowsocks.json").bufferedWriter().use { it.write(config.toString()) } + } + } + interface Interface { + val tag: String + + fun onBind(intent: Intent): IBinder? = if (intent.action == Action.SERVICE) data.binder else null + + fun checkProfile(profile: Profile): Boolean = + if (TextUtils.isEmpty(profile.host) || TextUtils.isEmpty(profile.password)) { + stopRunner(true, (this as Context).getString(R.string.proxy_empty)) + false + } else true + fun forceLoad() { + val p = app.currentProfile + ?: return stopRunner(true, (this as Context).getString(R.string.profile_empty)) + if (!checkProfile(p)) return + val s = data.state + when (s) { + STOPPED -> startRunner() + CONNECTED -> { + stopRunner(false) + startRunner() + } + else -> Log.w(tag, "Illegal state when invoking use: $s") + } + } + + fun buildAdditionalArguments(cmd: ArrayList): ArrayList = cmd + + fun startNativeProcesses() { + val data = data + data.buildShadowsocksConfig() + val cmd = buildAdditionalArguments(arrayListOf( + File((this as Context).applicationInfo.nativeLibraryDir, Executable.SS_LOCAL).absolutePath, + "-u", + "-b", "127.0.0.1", + "-l", DataStore.portProxy.toString(), + "-t", "600", + "-c", "shadowsocks.json")) + + val route = data.profile!!.route + if (route != Acl.ALL) { + cmd += "--acl" + cmd += Acl.getFile(if (route == Acl.CUSTOM_RULES) Acl.CUSTOM_RULES_FLATTENED else route).absolutePath + } + + if (TcpFastOpen.sendEnabled) cmd += "--fast-open" + + data.sslocalProcess = GuardedProcess(cmd).start() + } + + fun createNotification(): ServiceNotification + + fun startRunner() { + this as Context + if (Build.VERSION.SDK_INT >= 26) startForegroundService(Intent(this, javaClass)) + else startService(Intent(this, javaClass)) + } + + fun killProcesses() { + val data = data + data.sslocalProcess?.destroy() + data.sslocalProcess = null + } + + fun stopRunner(stopService: Boolean, msg: String? = null) { + // channge the state + changeState(STOPPING) + + app.track(tag, "stop") + + killProcesses() + + // clean up recevier + val data = data + this as Service + if (data.closeReceiverRegistered) { + unregisterReceiver(data.closeReceiver) + data.closeReceiverRegistered = false + } + + data.notification?.destroy() + data.notification = null + + // Make sure update total traffic when stopping the runner + data.updateTrafficTotal(TrafficMonitor.txTotal, TrafficMonitor.rxTotal) + + TrafficMonitor.reset() + data.trafficMonitorThread?.stopThread() + data.trafficMonitorThread = null + + // change the state + changeState(STOPPED, msg) + + // stop the service if nothing has bound to it + if (stopService) stopSelf() + + data.profile = null + } + + val data: Data get() = instances[this]!! + + fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + val data = data + if (data.state != STOPPED) return Service.START_NOT_STICKY + val profile = app.currentProfile + if (profile == null) { + stopRunner(true) + return Service.START_NOT_STICKY + } + data.profile = profile + profile.name = profile.formattedName + + TrafficMonitor.reset() + val thread = TrafficMonitorThread() + thread.start() + data.trafficMonitorThread = thread + + if (!data.closeReceiverRegistered) { + // register close receiver + val filter = IntentFilter() + filter.addAction(Action.RELOAD) + filter.addAction(Intent.ACTION_SHUTDOWN) + filter.addAction(Action.CLOSE) + (this as Context).registerReceiver(data.closeReceiver, filter) + data.closeReceiverRegistered = true + } + + data.notification = createNotification() + app.track(tag, "start") + + changeState(CONNECTING) + + thread { + this as Context + try { + if (profile.host == "198.199.101.152") { + val client = OkHttpClient.Builder() + .dns { + listOf((Dns.resolve(it, false) ?: throw UnknownHostException()) + .parseNumericAddress()) + } + .connectTimeout(10, TimeUnit.SECONDS) + .writeTimeout(10, TimeUnit.SECONDS) + .readTimeout(30, TimeUnit.SECONDS) + .build() + val mdg = MessageDigest.getInstance("SHA-1") + mdg.update(app.info.signatures[0].toByteArray()) + val requestBody = FormBody.Builder() + .add("sig", String(Base64.encode(mdg.digest(), 0))) + .build() + val request = Request.Builder() + .url(app.remoteConfig.getString("proxy_url")) + .post(requestBody) + .build() + + val proxies = client.newCall(request).execute() + .body()!!.string().split('|').toMutableList() + proxies.shuffle() + val proxy = proxies.first().split(':') + profile.host = proxy[0].trim() + profile.remotePort = proxy[1].trim().toInt() + profile.password = proxy[2].trim() + profile.method = proxy[3].trim() + } + + if (profile.route == Acl.CUSTOM_RULES) + Acl.save(Acl.CUSTOM_RULES_FLATTENED, Acl.customRules.flatten(10)) + + data.plugin = PluginConfiguration(profile.plugin ?: "").selectedOptions + data.pluginPath = PluginManager.init(data.plugin) + + // Clean up + killProcesses() + + if (!profile.host.isNumericAddress()) + profile.host = Dns.resolve(profile.host, true) ?: throw UnknownHostException() + + startNativeProcesses() + + if (profile.route !in arrayOf(Acl.ALL, Acl.CUSTOM_RULES)) AclSyncJob.schedule(profile.route) + + changeState(CONNECTED) + } catch (_: UnknownHostException) { + stopRunner(true, getString(R.string.invalid_server)) + } catch (_: VpnService.NullConnectionException) { + stopRunner(true, getString(R.string.reboot_required)) + } catch (exc: Throwable) { + stopRunner(true, "${getString(R.string.service_failed)}: ${exc.message}") + app.track(exc) + } + } + return Service.START_NOT_STICKY + } + + fun changeState(s: Int, msg: String? = null) { + val data = instances[this]!! + if (data.state == s && msg == null) return + if (data.callbacks.registeredCallbackCount > 0) app.handler.post { + val n = data.callbacks.beginBroadcast() + for (i in 0 until n) try { + data.callbacks.getBroadcastItem(i).stateChanged(s, data.binder.profileName, msg) + } catch (_: Exception) { } // ignore + data.callbacks.finishBroadcast() + } + data.state = s + } + } + + private val instances = WeakHashMap() + internal fun register(instance: Interface) = instances.put(instance, Data(instance)) + + val usingVpnMode: Boolean get() = DataStore.serviceMode == Key.modeVpn + val serviceClass: KClass get() = when (DataStore.serviceMode) { + Key.modeProxy -> ProxyService::class + Key.modeVpn -> VpnService::class + Key.modeTransproxy -> TransproxyService::class + else -> throw UnknownError() + } +} diff --git a/mobile/src/main/java/com/github/shadowsocks/bg/Dns.kt b/mobile/src/main/java/com/github/shadowsocks/bg/Dns.kt new file mode 100644 index 00000000..05ab6bc9 --- /dev/null +++ b/mobile/src/main/java/com/github/shadowsocks/bg/Dns.kt @@ -0,0 +1,105 @@ +/******************************************************************************* + * * + * Copyright (C) 2017 by Max Lv * + * Copyright (C) 2017 by Mygod Studio * + * * + * This program is free software: you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation, either version 3 of the License, or * + * (at your option) any later version. * + * * + * This program is distributed in the hope that it will be useful, * + * but WITHOUT ANY WARRANTY; without even the implied warranty of * + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * + * GNU General Public License for more details. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program. If not, see . * + * * + *******************************************************************************/ + +package com.github.shadowsocks.bg + +import android.content.Context +import android.net.ConnectivityManager +import android.net.LinkProperties +import android.os.Build +import android.util.Log +import com.github.shadowsocks.App.Companion.app +import com.github.shadowsocks.BuildConfig +import okhttp3.Dns as Okdns +import org.xbill.DNS.* +import java.net.Inet6Address +import java.net.InetAddress +import java.net.NetworkInterface +import java.net.UnknownHostException +import java.util.* + +object Dns { + private const val TAG = "Dns" + + @Throws(Exception::class) + fun getDnsResolver(context: Context): String { + val dnsResolvers = getDnsResolvers(context) + if (dnsResolvers.isEmpty()) throw Exception("Couldn't find an active DNS resolver") + val dnsResolver = dnsResolvers.iterator().next().toString() + if (dnsResolver.startsWith("/")) return dnsResolver.substring(1) + return dnsResolver + } + + @Throws(Exception::class) + private fun getDnsResolvers(context: Context): Collection { + val addresses = ArrayList() + val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager + val classLinkProperties = Class.forName("android.net.LinkProperties") + val getActiveLinkPropertiesMethod = ConnectivityManager::class.java.getMethod("getActiveLinkProperties") + val linkProperties = getActiveLinkPropertiesMethod.invoke(connectivityManager) + if (linkProperties != null) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) { + val dnses = classLinkProperties.getMethod("getDnses").invoke(linkProperties) as Collection<*> + dnses.mapTo(addresses) { it as InetAddress } + } else addresses.addAll((linkProperties as LinkProperties).dnsServers) + } + return addresses + } + + private val hasIPv6Support get() = try { + val result = NetworkInterface.getNetworkInterfaces().asSequence().flatMap { it.inetAddresses.asSequence() } + .count { it is Inet6Address && !it.isLoopbackAddress && !it.isLinkLocalAddress } > 0 + if (result && BuildConfig.DEBUG) Log.d(TAG, "IPv6 address detected") + result + } catch (ex: Exception) { + Log.e(TAG, "Failed to get interfaces' addresses.") + app.track(ex) + false + } + + private fun resolve(host: String, addrType: Int): String? { + try { + val lookup = Lookup(host, addrType) + val resolver = SimpleResolver("208.67.220.220") + resolver.setTCP(true) + resolver.setPort(443) + resolver.setTimeout(5) + lookup.setResolver(resolver) + val records = (lookup.run() ?: return null).toMutableList() + records.shuffle() + for (r in records) { + when (addrType) { + Type.A -> return (r as ARecord).address.hostAddress + Type.AAAA -> return (r as AAAARecord).address.hostAddress + } + } + } catch (_: Exception) { } + return null + } + private fun resolve(host: String): String? = try { + InetAddress.getByName(host).hostAddress + } catch (_: UnknownHostException) { + null + } + fun resolve(host: String, enableIPv6: Boolean): String? = + (if (enableIPv6 && hasIPv6Support) resolve(host, Type.AAAA) else null) + ?: resolve(host, Type.A) + ?: resolve(host) +} diff --git a/mobile/src/main/java/com/github/shadowsocks/bg/Executable.kt b/mobile/src/main/java/com/github/shadowsocks/bg/Executable.kt new file mode 100644 index 00000000..db3ab18f --- /dev/null +++ b/mobile/src/main/java/com/github/shadowsocks/bg/Executable.kt @@ -0,0 +1,49 @@ +/******************************************************************************* + * * + * Copyright (C) 2017 by Max Lv * + * Copyright (C) 2017 by Mygod Studio * + * * + * This program is free software: you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation, either version 3 of the License, or * + * (at your option) any later version. * + * * + * This program is distributed in the hope that it will be useful, * + * but WITHOUT ANY WARRANTY; without even the implied warranty of * + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * + * GNU General Public License for more details. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program. If not, see . * + * * + *******************************************************************************/ + +package com.github.shadowsocks.bg + +import android.text.TextUtils +import android.util.Log +import com.github.shadowsocks.App.Companion.app +import com.github.shadowsocks.JniHelper +import java.io.File + +object Executable { + const val REDSOCKS = "libredsocks.so" + const val PDNSD = "libpdnsd.so" + const val SS_LOCAL = "libss-local.so" + const val SS_TUNNEL = "libss-tunnel.so" + const val TUN2SOCKS = "libtun2socks.so" + const val OVERTURE = "liboverture.so" + + val EXECUTABLES = setOf(SS_LOCAL, SS_TUNNEL, PDNSD, REDSOCKS, TUN2SOCKS, OVERTURE) + + fun killAll() { + for (process in File("/proc").listFiles { _, name -> TextUtils.isDigitsOnly(name) }) { + val exe = File(File(process, "cmdline").bufferedReader().use { it.readText() } + .split(Character.MIN_VALUE, limit = 2).first()) + if (exe.parent == app.applicationInfo.nativeLibraryDir && EXECUTABLES.contains(exe.name)) { + val errno = JniHelper.sigkill(process.name.toInt()) + if (errno != 0) Log.w("kill", "SIGKILL ${exe.absolutePath} (${process.name}) failed with $errno") + } + } + } +} diff --git a/mobile/src/main/java/com/github/shadowsocks/bg/GuardedProcess.kt b/mobile/src/main/java/com/github/shadowsocks/bg/GuardedProcess.kt new file mode 100644 index 00000000..46a3bfb3 --- /dev/null +++ b/mobile/src/main/java/com/github/shadowsocks/bg/GuardedProcess.kt @@ -0,0 +1,113 @@ +/******************************************************************************* + * * + * Copyright (C) 2017 by Max Lv * + * Copyright (C) 2017 by Mygod Studio * + * * + * This program is free software: you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation, either version 3 of the License, or * + * (at your option) any later version. * + * * + * This program is distributed in the hope that it will be useful, * + * but WITHOUT ANY WARRANTY; without even the implied warranty of * + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * + * GNU General Public License for more details. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program. If not, see . * + * * + *******************************************************************************/ + +package com.github.shadowsocks.bg + +import android.os.Build +import android.os.SystemClock +import android.util.Log +import com.github.shadowsocks.App.Companion.app +import com.github.shadowsocks.BuildConfig +import com.github.shadowsocks.JniHelper +import com.github.shadowsocks.utils.Commandline +import java.io.IOException +import java.io.InputStream +import java.util.concurrent.Semaphore + +class GuardedProcess(private val cmd: List) { + companion object { + const val TAG = "GuardedProcess" + } + + private lateinit var guardThread: Thread + @Volatile + private var isDestroyed = false + @Volatile + private lateinit var process: Process + + private fun streamLogger(input: InputStream, logger: (String, String) -> Int) = Thread { + try { + input.bufferedReader().useLines { it.forEach { logger(TAG, it) } } + } catch (_: IOException) { } // ignore + } + + fun start(onRestartCallback: (() -> Unit)? = null): GuardedProcess { + val semaphore = Semaphore(1) + semaphore.acquire() + var ioException: IOException? = null + guardThread = Thread({ + try { + var callback: (() -> Unit)? = null + while (!isDestroyed) { + if (BuildConfig.DEBUG) Log.d(TAG, "start process: " + Commandline.toString(cmd)) + val startTime = SystemClock.elapsedRealtime() + + process = ProcessBuilder(cmd) + .redirectErrorStream(true) + .directory(app.filesDir) + .start() + + streamLogger(process.inputStream, Log::i).start() + streamLogger(process.errorStream, Log::e).start() + + if (callback == null) callback = onRestartCallback else callback() + + semaphore.release() + process.waitFor() + + synchronized(this) { + if (SystemClock.elapsedRealtime() - startTime < 1000) { + Log.w(TAG, "process exit too fast, stop guard: " + Commandline.toString(cmd)) + isDestroyed = true + } + } + } + } catch (_: InterruptedException) { + if (BuildConfig.DEBUG) Log.d(TAG, "thread interrupt, destroy process: " + Commandline.toString(cmd)) + destroyProcess() + } catch (e: IOException) { + ioException = e + } finally { + semaphore.release() + } + }, "GuardThread-" + cmd.first()) + guardThread.start() + semaphore.acquire() + if (ioException != null) throw ioException!! + return this + } + + fun destroy() { + isDestroyed = true + guardThread.interrupt() + destroyProcess() + try { + guardThread.join() + } catch (_: InterruptedException) { } + } + + private fun destroyProcess() { + if (Build.VERSION.SDK_INT < 24) @Suppress("DEPRECATION") { + JniHelper.sigtermCompat(process) + JniHelper.waitForCompat(process, 500) + } + process.destroy() + } +} diff --git a/mobile/src/main/java/com/github/shadowsocks/bg/LocalDnsService.kt b/mobile/src/main/java/com/github/shadowsocks/bg/LocalDnsService.kt new file mode 100644 index 00000000..e7d86115 --- /dev/null +++ b/mobile/src/main/java/com/github/shadowsocks/bg/LocalDnsService.kt @@ -0,0 +1,117 @@ +/******************************************************************************* + * * + * Copyright (C) 2017 by Max Lv * + * Copyright (C) 2017 by Mygod Studio * + * * + * This program is free software: you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation, either version 3 of the License, or * + * (at your option) any later version. * + * * + * This program is distributed in the hope that it will be useful, * + * but WITHOUT ANY WARRANTY; without even the implied warranty of * + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * + * GNU General Public License for more details. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program. If not, see . * + * * + *******************************************************************************/ + +package com.github.shadowsocks.bg + +import android.content.Context +import com.github.shadowsocks.App.Companion.app +import com.github.shadowsocks.acl.Acl +import com.github.shadowsocks.preference.DataStore +import com.github.shadowsocks.utils.parseNumericAddress +import org.json.JSONArray +import org.json.JSONObject +import java.io.File +import java.net.Inet6Address +import java.util.* + +/** + * This object also uses WeakMap to simulate the effects of multi-inheritance, but more lightweight. + */ +object LocalDnsService { + interface Interface : BaseService.Interface { + var overtureProcess: GuardedProcess? + get() = overtureProcesses[this] + set(value) { + if (value == null) overtureProcesses.remove(this) else overtureProcesses[this] = value + } + + override fun startNativeProcesses() { + super.startNativeProcesses() + val profile = data.profile!! + + fun buildOvertureConfig(file: String): String { + val config = JSONObject() + .put("BindAddress", "127.0.0.1:" + DataStore.portLocalDns) + .put("RedirectIPv6Record", true) + .put("DomainBase64Decode", true) + .put("HostsFile", "hosts") + .put("MinimumTTL", 3600) + .put("CacheSize", 4096) + fun makeDns(name: String, address: String, edns: Boolean = true) = { + val dns = JSONObject() + .put("Name", name) + .put("Address", (when (address.parseNumericAddress()) { + is Inet6Address -> "[$address]" + else -> address + }) + ":53") + .put("Timeout", 6) + .put("EDNSClientSubnet", JSONObject().put("Policy", "disable")) + if (edns) dns + .put("Protocol", "tcp") + .put("Socks5Address", "127.0.0.1:" + DataStore.portProxy) + else dns.put("Protocol", "udp") + dns + } + val remoteDns = JSONArray(profile.remoteDns.split(",") + .mapIndexed { i, dns -> makeDns("UserDef-" + i, dns.trim())() }) + val localDns = JSONArray(arrayOf( + makeDns("Primary-1", "119.29.29.29", false), + makeDns("Primary-2", "114.114.114.114", false) + )) + + this as Context + try { + val localLinkDns = Dns.getDnsResolver(this) + localDns.put(makeDns("Primary-3", localLinkDns, false)) + } catch (_: Exception) { } // ignore + + when (profile.route) { + Acl.BYPASS_CHN, Acl.BYPASS_LAN_CHN, Acl.GFWLIST, Acl.CUSTOM_RULES -> config + .put("PrimaryDNS", localDns) + .put("AlternativeDNS", remoteDns) + .put("IPNetworkFile", "china_ip_list.txt") + .put("DomainFile", "gfwlist.txt") + Acl.CHINALIST -> config + .put("PrimaryDNS", localDns) + .put("AlternativeDNS", remoteDns) + else -> config + .put("PrimaryDNS", remoteDns) + // no need to setup AlternativeDNS in Acl.ALL/BYPASS_LAN mode + .put("OnlyPrimaryDNS", true) + } + File(filesDir, file).bufferedWriter().use { it.write(config.toString()) } + return file + } + + if (!profile.udpdns) overtureProcess = GuardedProcess(buildAdditionalArguments(arrayListOf( + File(app.applicationInfo.nativeLibraryDir, Executable.OVERTURE).absolutePath, + "-c", buildOvertureConfig("overture.conf") + ))).start() + } + + override fun killProcesses() { + super.killProcesses() + overtureProcess?.destroy() + overtureProcess = null + } + } + + private val overtureProcesses = WeakHashMap() +} diff --git a/mobile/src/main/java/com/github/shadowsocks/bg/LocalSocketListener.kt b/mobile/src/main/java/com/github/shadowsocks/bg/LocalSocketListener.kt new file mode 100644 index 00000000..c2f334d1 --- /dev/null +++ b/mobile/src/main/java/com/github/shadowsocks/bg/LocalSocketListener.kt @@ -0,0 +1,77 @@ +/******************************************************************************* + * * + * Copyright (C) 2017 by Max Lv * + * Copyright (C) 2017 by Mygod Studio * + * * + * This program is free software: you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation, either version 3 of the License, or * + * (at your option) any later version. * + * * + * This program is distributed in the hope that it will be useful, * + * but WITHOUT ANY WARRANTY; without even the implied warranty of * + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * + * GNU General Public License for more details. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program. If not, see . * + * * + *******************************************************************************/ + +package com.github.shadowsocks.bg + +import android.net.LocalServerSocket +import android.net.LocalSocket +import android.net.LocalSocketAddress +import android.util.Log +import com.github.shadowsocks.App.Companion.app +import java.io.File +import java.io.IOException +import java.util.concurrent.atomic.AtomicReference + +abstract class LocalSocketListener(private val tag: String) : Thread() { + init { + setUncaughtExceptionHandler(app::track) + } + + protected abstract val socketFile: File + private val serverSocket = AtomicReference() + + /** + * Inherited class do not need to close input/output streams as they will be closed automatically. + */ + protected abstract fun accept(socket: LocalSocket) + override fun run() { + socketFile.delete() + try { + val localSocket = LocalSocket() + localSocket.bind(LocalSocketAddress(socketFile.absolutePath, LocalSocketAddress.Namespace.FILESYSTEM)) + serverSocket.set(LocalServerSocket(localSocket.fileDescriptor)) + } catch (e: IOException) { + Log.e(tag, "unable to bind", e) + return + } + while (true) { + val serverSocket = serverSocket.get() ?: return + try { + try { + serverSocket.accept() + } catch (e: IOException) { + Log.e(tag, "Error when accept socket", e) + app.track(e) + null + }?.use(this::accept) + } catch (e: Exception) { + Log.e(tag, "Error when recv traffic stat", e) + app.track(e) + } + } + } + + fun stopThread() { + val old = serverSocket.getAndSet(null) ?: return + try { + old.close() + } catch (_: Exception) { } // ignore + } +} diff --git a/mobile/src/main/java/com/github/shadowsocks/bg/ProxyService.kt b/mobile/src/main/java/com/github/shadowsocks/bg/ProxyService.kt new file mode 100644 index 00000000..8d3bd5ff --- /dev/null +++ b/mobile/src/main/java/com/github/shadowsocks/bg/ProxyService.kt @@ -0,0 +1,42 @@ +/******************************************************************************* + * * + * Copyright (C) 2017 by Max Lv * + * Copyright (C) 2017 by Mygod Studio * + * * + * This program is free software: you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation, either version 3 of the License, or * + * (at your option) any later version. * + * * + * This program is distributed in the hope that it will be useful, * + * but WITHOUT ANY WARRANTY; without even the implied warranty of * + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * + * GNU General Public License for more details. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program. If not, see . * + * * + *******************************************************************************/ + +package com.github.shadowsocks.bg + +import android.app.Service +import android.content.Intent +import android.os.IBinder + +/** + * Shadowsocks service at its minimum. + */ +class ProxyService : Service(), BaseService.Interface { + init { + BaseService.register(this) + } + + override val tag: String get() = "ShadowsocksProxyService" + override fun createNotification(): ServiceNotification = + ServiceNotification(this, data.profile!!.name, "service-proxy", true) + + override fun onBind(intent: Intent): IBinder? = super.onBind(intent) + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int = + super.onStartCommand(intent, flags, startId) +} diff --git a/mobile/src/main/java/com/github/shadowsocks/bg/ServiceNotification.kt b/mobile/src/main/java/com/github/shadowsocks/bg/ServiceNotification.kt new file mode 100644 index 00000000..c8c064aa --- /dev/null +++ b/mobile/src/main/java/com/github/shadowsocks/bg/ServiceNotification.kt @@ -0,0 +1,130 @@ +/******************************************************************************* + * * + * Copyright (C) 2017 by Max Lv * + * Copyright (C) 2017 by Mygod Studio * + * * + * This program is free software: you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation, either version 3 of the License, or * + * (at your option) any later version. * + * * + * This program is distributed in the hope that it will be useful, * + * but WITHOUT ANY WARRANTY; without even the implied warranty of * + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * + * GNU General Public License for more details. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program. If not, see . * + * * + *******************************************************************************/ + +package com.github.shadowsocks.bg + +import android.app.KeyguardManager +import android.app.NotificationManager +import android.app.PendingIntent +import android.app.Service +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.os.Build +import android.os.PowerManager +import android.support.v4.app.NotificationCompat +import android.support.v4.content.ContextCompat +import com.github.shadowsocks.MainActivity +import com.github.shadowsocks.R +import com.github.shadowsocks.aidl.IShadowsocksServiceCallback +import com.github.shadowsocks.utils.Action +import com.github.shadowsocks.utils.broadcastReceiver +import java.util.* + +class ServiceNotification(private val service: BaseService.Interface, profileName: String, + channel: String, private val visible: Boolean = false) { + private val keyGuard = (service as Context).getSystemService(Context.KEYGUARD_SERVICE) as KeyguardManager + private val nm by lazy { + (service as Context).getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + } + private val callback by lazy { + object : IShadowsocksServiceCallback.Stub() { + override fun stateChanged(state: Int, profileName: String?, msg: String?) { } // ignore + override fun trafficUpdated(profileId: Int, txRate: Long, rxRate: Long, txTotal: Long, rxTotal: Long) { + service as Context + val txr = service.getString(R.string.speed, TrafficMonitor.formatTraffic(txRate)) + val rxr = service.getString(R.string.speed, TrafficMonitor.formatTraffic(rxRate)) + builder.setContentText("$txr↑\t$rxr↓") + style.bigText(service.getString(R.string.stat_summary).format(Locale.ENGLISH, txr, rxr, + TrafficMonitor.formatTraffic(txTotal), TrafficMonitor.formatTraffic(rxTotal))) + show() + } + override fun trafficPersisted(profileId: Int) { } + } + } + private val lockReceiver = broadcastReceiver { _, intent -> update(intent.action) } + private var callbackRegistered = false + + private val builder = NotificationCompat.Builder(service as Context, channel) + .setWhen(0) + .setColor(ContextCompat.getColor(service, R.color.material_primary_500)) + .setTicker(service.getString(R.string.forward_success)) + .setContentTitle(profileName) + .setContentIntent(PendingIntent.getActivity(service, 0, Intent(service, MainActivity::class.java) + .setFlags(Intent.FLAG_ACTIVITY_REORDER_TO_FRONT), 0)) + .setSmallIcon(R.drawable.ic_stat_shadowsocks) + private val style = NotificationCompat.BigTextStyle(builder) + private var isVisible = true + + init { + service as Context + if (Build.VERSION.SDK_INT < 24) builder.addAction(R.drawable.ic_navigation_close, + service.getString(R.string.stop), PendingIntent.getBroadcast(service, 0, Intent(Action.CLOSE), 0)) + val power = service.getSystemService(Context.POWER_SERVICE) as PowerManager + update(if (if (Build.VERSION.SDK_INT >= 20) power.isInteractive else @Suppress("DEPRECATION") power.isScreenOn) + Intent.ACTION_SCREEN_ON else Intent.ACTION_SCREEN_OFF, true) + val screenFilter = IntentFilter() + screenFilter.addAction(Intent.ACTION_SCREEN_ON) + screenFilter.addAction(Intent.ACTION_SCREEN_OFF) + if (visible && Build.VERSION.SDK_INT in 21 until 26) screenFilter.addAction(Intent.ACTION_USER_PRESENT) + service.registerReceiver(lockReceiver, screenFilter) + } + + private fun update(action: String, forceShow: Boolean = false) { + if (forceShow || service.data.state == BaseService.CONNECTED) when (action) { + Intent.ACTION_SCREEN_OFF -> { + setVisible(visible && Build.VERSION.SDK_INT < 21, forceShow) + unregisterCallback() // unregister callback to save battery + } + Intent.ACTION_SCREEN_ON -> { + setVisible(visible && (Build.VERSION.SDK_INT < 21 || !keyGuard.inKeyguardRestrictedInputMode()), + forceShow) + service.data.binder.registerCallback(callback) + service.data.binder.startListeningForBandwidth(callback) + callbackRegistered = true + } + Intent.ACTION_USER_PRESENT -> setVisible(true, forceShow) + } + } + + private fun unregisterCallback() { + if (callbackRegistered) { + service.data.binder.unregisterCallback(callback) + callbackRegistered = false + } + } + + private fun setVisible(visible: Boolean, forceShow: Boolean = false) { + if (isVisible != visible) { + isVisible = visible + builder.priority = if (visible) NotificationCompat.PRIORITY_LOW else NotificationCompat.PRIORITY_MIN + show() + } else if (forceShow) show() + } + + private fun show() = (service as Service).startForeground(1, builder.build()) + + fun destroy() { + (service as Service).unregisterReceiver(lockReceiver) + unregisterCallback() + service.stopForeground(true) + nm.cancel(1) + } +} diff --git a/mobile/src/main/java/com/github/shadowsocks/bg/TileService.kt b/mobile/src/main/java/com/github/shadowsocks/bg/TileService.kt new file mode 100644 index 00000000..39c04852 --- /dev/null +++ b/mobile/src/main/java/com/github/shadowsocks/bg/TileService.kt @@ -0,0 +1,92 @@ +/******************************************************************************* + * * + * Copyright (C) 2017 by Max Lv * + * Copyright (C) 2017 by Mygod Studio * + * * + * This program is free software: you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation, either version 3 of the License, or * + * (at your option) any later version. * + * * + * This program is distributed in the hope that it will be useful, * + * but WITHOUT ANY WARRANTY; without even the implied warranty of * + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * + * GNU General Public License for more details. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program. If not, see . * + * * + *******************************************************************************/ + +package com.github.shadowsocks.bg + +import android.graphics.drawable.Icon +import android.os.Build +import android.service.quicksettings.Tile +import android.support.annotation.RequiresApi +import com.github.shadowsocks.App.Companion.app +import com.github.shadowsocks.R +import com.github.shadowsocks.ShadowsocksConnection +import com.github.shadowsocks.aidl.IShadowsocksService +import com.github.shadowsocks.aidl.IShadowsocksServiceCallback +import android.service.quicksettings.TileService as BaseTileService + +@RequiresApi(Build.VERSION_CODES.N) +class TileService : BaseTileService(), ShadowsocksConnection.Interface { + private val iconIdle by lazy { Icon.createWithResource(this, R.drawable.ic_start_idle).setTint(0x79ffffff) } + private val iconBusy by lazy { Icon.createWithResource(this, R.drawable.ic_start_busy) } + private val iconConnected by lazy { Icon.createWithResource(this, R.drawable.ic_start_connected) } + + override val serviceCallback: IShadowsocksServiceCallback.Stub by lazy { + @RequiresApi(Build.VERSION_CODES.N) + object : IShadowsocksServiceCallback.Stub() { + override fun stateChanged(state: Int, profileName: String?, msg: String?) { + val tile = qsTile ?: return + when (state) { + BaseService.STOPPED -> { + tile.icon = iconIdle + tile.label = getString(R.string.app_name) + tile.state = Tile.STATE_INACTIVE + } + BaseService.CONNECTED -> { + tile.icon = iconConnected + tile.label = profileName ?: getString(R.string.app_name) + tile.state = Tile.STATE_ACTIVE + } + else -> { + tile.icon = iconBusy + tile.label = getString(R.string.app_name) + tile.state = Tile.STATE_UNAVAILABLE + } + } + tile.updateTile() + } + override fun trafficUpdated(profileId: Int, txRate: Long, rxRate: Long, txTotal: Long, rxTotal: Long) { } + override fun trafficPersisted(profileId: Int) { } + } + } + + override fun onServiceConnected(service: IShadowsocksService) = + serviceCallback.stateChanged(service.state, service.profileName, null) + + override fun onStartListening() { + super.onStartListening() + connection.connect() + } + override fun onStopListening() { + super.onStopListening() + connection.disconnect() + } + + override fun onClick() { + if (isLocked) unlockAndRun(this::toggle) else toggle() + } + + private fun toggle() { + val service = connection.service ?: return + when (service.state) { + BaseService.STOPPED -> app.startService() + BaseService.CONNECTED -> app.stopService() + } + } +} diff --git a/mobile/src/main/java/com/github/shadowsocks/bg/TrafficMonitor.kt b/mobile/src/main/java/com/github/shadowsocks/bg/TrafficMonitor.kt new file mode 100644 index 00000000..0e0653e6 --- /dev/null +++ b/mobile/src/main/java/com/github/shadowsocks/bg/TrafficMonitor.kt @@ -0,0 +1,103 @@ +/******************************************************************************* + * * + * Copyright (C) 2017 by Max Lv * + * Copyright (C) 2017 by Mygod Studio * + * * + * This program is free software: you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation, either version 3 of the License, or * + * (at your option) any later version. * + * * + * This program is distributed in the hope that it will be useful, * + * but WITHOUT ANY WARRANTY; without even the implied warranty of * + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * + * GNU General Public License for more details. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program. If not, see . * + * * + *******************************************************************************/ + +package com.github.shadowsocks.bg + +import android.os.SystemClock +import com.github.shadowsocks.App.Companion.app +import com.github.shadowsocks.R +import java.text.DecimalFormat + +object TrafficMonitor { + // Bytes per second + var txRate = 0L + var rxRate = 0L + + // Bytes for the current session + var txTotal = 0L + var rxTotal = 0L + + // Bytes for the last query + var txLast = 0L + var rxLast = 0L + var timestampLast = 0L + @Volatile + var dirty = true + + private val units = arrayOf("KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB", "BB", "NB", "DB", "CB") + private val numberFormat = DecimalFormat("@@@") + fun formatTraffic(size: Long): String { + var n: Double = size.toDouble() + var i = -1 + while (n >= 999.5) { + n /= 1024 + ++i + } + return if (i < 0) "$size ${app.resources.getQuantityString(R.plurals.bytes, size.toInt())}" + else "${numberFormat.format(n)} ${units[i]}" + } + + fun updateRate(): Boolean { + val now = SystemClock.elapsedRealtime() + val delta = now - timestampLast + timestampLast = now + var updated = false + if (delta != 0L) + if (dirty) { + txRate = (txTotal - txLast) * 1000 / delta + rxRate = (rxTotal - rxLast) * 1000 / delta + txLast = txTotal + rxLast = rxTotal + dirty = false + updated = true + } else { + if (txRate != 0L) { + txRate = 0 + updated = true + } + if (rxRate != 0L) { + rxRate = 0 + updated = true + } + } + return updated + } + + fun update(tx: Long, rx: Long) { + if (txTotal != tx) { + txTotal = tx + dirty = true + } + if (rxTotal != rx) { + rxTotal = rx + dirty = true + } + } + + fun reset() { + txRate = 0 + rxRate = 0 + txTotal = 0 + rxTotal = 0 + txLast = 0 + rxLast = 0 + dirty = true + } +} diff --git a/mobile/src/main/java/com/github/shadowsocks/bg/TrafficMonitorThread.kt b/mobile/src/main/java/com/github/shadowsocks/bg/TrafficMonitorThread.kt new file mode 100644 index 00000000..c2dbe42a --- /dev/null +++ b/mobile/src/main/java/com/github/shadowsocks/bg/TrafficMonitorThread.kt @@ -0,0 +1,40 @@ +/******************************************************************************* + * * + * Copyright (C) 2017 by Max Lv * + * Copyright (C) 2017 by Mygod Studio * + * * + * This program is free software: you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation, either version 3 of the License, or * + * (at your option) any later version. * + * * + * This program is distributed in the hope that it will be useful, * + * but WITHOUT ANY WARRANTY; without even the implied warranty of * + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * + * GNU General Public License for more details. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program. If not, see . * + * * + *******************************************************************************/ + +package com.github.shadowsocks.bg + +import android.net.LocalSocket +import com.github.shadowsocks.App.Companion.app +import java.io.File +import java.io.IOException +import java.nio.ByteBuffer +import java.nio.ByteOrder + +class TrafficMonitorThread : LocalSocketListener("TrafficMonitorThread") { + override val socketFile = File(app.filesDir, "/stat_path") + + override fun accept(socket: LocalSocket) { + val buffer = ByteArray(16) + if (socket.inputStream.read(buffer) != 16) throw IOException("Unexpected traffic stat length") + val stat = ByteBuffer.wrap(buffer).order(ByteOrder.LITTLE_ENDIAN) + TrafficMonitor.update(stat.getLong(0), stat.getLong(8)) + socket.outputStream.write(0) + } +} diff --git a/mobile/src/main/java/com/github/shadowsocks/bg/TransproxyService.kt b/mobile/src/main/java/com/github/shadowsocks/bg/TransproxyService.kt new file mode 100644 index 00000000..0fd12385 --- /dev/null +++ b/mobile/src/main/java/com/github/shadowsocks/bg/TransproxyService.kt @@ -0,0 +1,92 @@ +/******************************************************************************* + * * + * Copyright (C) 2017 by Max Lv * + * Copyright (C) 2017 by Mygod Studio * + * * + * This program is free software: you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation, either version 3 of the License, or * + * (at your option) any later version. * + * * + * This program is distributed in the hope that it will be useful, * + * but WITHOUT ANY WARRANTY; without even the implied warranty of * + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * + * GNU General Public License for more details. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program. If not, see . * + * * + *******************************************************************************/ + +package com.github.shadowsocks.bg + +import android.app.Service +import android.content.Intent +import android.os.IBinder +import com.github.shadowsocks.preference.DataStore +import java.io.File + +class TransproxyService : Service(), LocalDnsService.Interface { + init { + BaseService.register(this) + } + + override val tag: String get() = "ShadowsocksTransproxyService" + override fun createNotification(): ServiceNotification = + ServiceNotification(this, data.profile!!.name, "service-transproxy", true) + + override fun onBind(intent: Intent): IBinder? = super.onBind(intent) + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int = + super.onStartCommand(intent, flags, startId) + + private var sstunnelProcess: GuardedProcess? = null + private var redsocksProcess: GuardedProcess? = null + + private fun startDNSTunnel() { + val cmd = arrayListOf(File(applicationInfo.nativeLibraryDir, Executable.SS_TUNNEL).absolutePath, + "-t", "10", + "-b", "127.0.0.1", + "-u", + "-l", DataStore.portLocalDns.toString(), // ss-tunnel listens on the same port as overture + "-L", data.profile!!.remoteDns.split(",").first().trim() + ":53", + "-c", "shadowsocks.json") // config is already built by BaseService.Interface + sstunnelProcess = GuardedProcess(cmd).start() + } + + private fun startRedsocksDaemon() { + val config = """base { + log_debug = off; + log_info = off; + log = stderr; + daemon = off; + redirector = iptables; +} +redsocks { + local_ip = 127.0.0.1; + local_port = ${DataStore.portTransproxy}; + ip = 127.0.0.1; + port = ${DataStore.portProxy}; + type = socks5; +} +""" + File(filesDir, "redsocks.conf").bufferedWriter().use { it.write(config) } + redsocksProcess = GuardedProcess(arrayListOf( + File(applicationInfo.nativeLibraryDir, Executable.REDSOCKS).absolutePath, + "-c", "redsocks.conf") + ).start() + } + + override fun startNativeProcesses() { + startRedsocksDaemon() + super.startNativeProcesses() + if (data.profile!!.udpdns) startDNSTunnel() + } + + override fun killProcesses() { + super.killProcesses() + sstunnelProcess?.destroy() + sstunnelProcess = null + redsocksProcess?.destroy() + redsocksProcess = null + } +} diff --git a/mobile/src/main/java/com/github/shadowsocks/bg/VpnService.kt b/mobile/src/main/java/com/github/shadowsocks/bg/VpnService.kt new file mode 100644 index 00000000..93582949 --- /dev/null +++ b/mobile/src/main/java/com/github/shadowsocks/bg/VpnService.kt @@ -0,0 +1,194 @@ +/******************************************************************************* + * * + * Copyright (C) 2017 by Max Lv * + * Copyright (C) 2017 by Mygod Studio * + * * + * This program is free software: you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation, either version 3 of the License, or * + * (at your option) any later version. * + * * + * This program is distributed in the hope that it will be useful, * + * but WITHOUT ANY WARRANTY; without even the implied warranty of * + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * + * GNU General Public License for more details. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program. If not, see . * + * * + *******************************************************************************/ + +package com.github.shadowsocks.bg + +import android.app.Service +import android.content.Intent +import android.content.pm.PackageManager +import android.net.LocalSocket +import android.os.Build +import android.os.IBinder +import android.os.ParcelFileDescriptor +import android.util.Log +import com.github.shadowsocks.App.Companion.app +import com.github.shadowsocks.JniHelper +import com.github.shadowsocks.R +import com.github.shadowsocks.VpnRequestActivity +import com.github.shadowsocks.acl.Acl +import com.github.shadowsocks.preference.DataStore +import com.github.shadowsocks.utils.Subnet +import com.github.shadowsocks.utils.parseNumericAddress +import java.io.File +import java.io.FileDescriptor +import java.lang.reflect.Method +import java.util.* +import android.net.VpnService as BaseVpnService + +class VpnService : BaseVpnService(), LocalDnsService.Interface { + companion object { + private const val VPN_MTU = 1500 + private const val PRIVATE_VLAN = "26.26.26.%s" + private const val PRIVATE_VLAN6 = "fdfe:dcba:9876::%s" + + private val getInt: Method = FileDescriptor::class.java.getDeclaredMethod("getInt$") + } + + private inner class ProtectWorker : LocalSocketListener("ShadowsocksVpnThread") { + override val socketFile: File = File(filesDir, "protect_path") + + override fun accept(socket: LocalSocket) { + socket.inputStream.read() + val fds = socket.ancillaryFileDescriptors + if (fds.isEmpty()) return + val fd = getInt.invoke(fds.first()) as Int + val ret = protect(fd) + JniHelper.close(fd) // Trick to close file decriptor + socket.outputStream.write(if (ret) 0 else 1) + } + } + class NullConnectionException : NullPointerException() + + init { + BaseService.register(this) + } + + override val tag: String get() = "ShadowsocksVpnService" + override fun createNotification(): ServiceNotification = + ServiceNotification(this, data.profile!!.name, "service-vpn") + + private var conn: ParcelFileDescriptor? = null + private var worker: ProtectWorker? = null + private var tun2socksProcess: GuardedProcess? = null + + override fun onBind(intent: Intent): IBinder? = when (intent.action) { + SERVICE_INTERFACE -> super.onBind(intent) + else -> super.onBind(intent) + } + + override fun onRevoke() = stopRunner(true) + + override fun killProcesses() { + worker?.stopThread() + worker = null + super.killProcesses() + tun2socksProcess?.destroy() + tun2socksProcess = null + conn?.close() + conn = null + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + if (BaseService.usingVpnMode) + if (BaseVpnService.prepare(this) != null) + startActivity(Intent(this, VpnRequestActivity::class.java) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)) + else return super.onStartCommand(intent, flags, startId) + stopRunner(true) + return Service.START_NOT_STICKY + } + + override fun startNativeProcesses() { + val worker = ProtectWorker() + worker.start() + this.worker = worker + + super.startNativeProcesses() + + val fd = startVpn() + if (!sendFd(fd)) throw Exception("sendFd failed") + } + + override fun buildAdditionalArguments(cmd: ArrayList): ArrayList { + cmd += "-V" + return cmd + } + + private fun startVpn(): Int { + val profile = data.profile!! + val builder = Builder() + .setSession(profile.formattedName) + .setMtu(VPN_MTU) + .addAddress(PRIVATE_VLAN.format(Locale.ENGLISH, "1"), 24) + + profile.remoteDns.split(",").forEach { builder.addDnsServer(it.trim()) } + + if (profile.ipv6) { + builder.addAddress(PRIVATE_VLAN6.format(Locale.ENGLISH, "1"), 126) + builder.addRoute("::", 0) + } + + if (Build.VERSION.SDK_INT >= 21 && profile.proxyApps) { + for (pkg in profile.individual.split('\n')) try { + if (profile.bypass) builder.addDisallowedApplication(pkg) else builder.addAllowedApplication(pkg) + } catch (ex: PackageManager.NameNotFoundException) { + Log.e(tag, "Invalid package name", ex) + } + } + + when (profile.route) { + Acl.ALL, Acl.BYPASS_CHN, Acl.CUSTOM_RULES -> builder.addRoute("0.0.0.0", 0) + else -> { + resources.getStringArray(R.array.bypass_private_route).forEach { + val subnet = Subnet.fromString(it) + builder.addRoute(subnet.address.hostAddress, subnet.prefixSize) + } + profile.remoteDns.split(",").map { it.trim().parseNumericAddress() } + .forEach { builder.addRoute(it, it.address.size shl 3) } + } + } + + val conn = builder.establish() ?: throw NullConnectionException() + this.conn = conn + val fd = conn.fd + + val cmd = arrayListOf(File(applicationInfo.nativeLibraryDir, Executable.TUN2SOCKS).absolutePath, + "--netif-ipaddr", PRIVATE_VLAN.format(Locale.ENGLISH, "2"), + "--netif-netmask", "255.255.255.0", + "--socks-server-addr", "127.0.0.1:${DataStore.portProxy}", + "--tunfd", fd.toString(), + "--tunmtu", VPN_MTU.toString(), + "--sock-path", "sock_path", + "--loglevel", "3") + if (profile.ipv6) { + cmd += "--netif-ip6addr" + cmd += PRIVATE_VLAN6.format(Locale.ENGLISH, "2") + } + cmd += "--enable-udprelay" + if (!profile.udpdns) { + cmd += "--dnsgw" + cmd += "127.0.0.1:${DataStore.portLocalDns}" + } + tun2socksProcess = GuardedProcess(cmd).start { sendFd(fd) } + return fd + } + + private fun sendFd(fd: Int): Boolean { + if (fd != -1) { + var tries = 1 + while (tries < 5) { + Thread.sleep(1000L * tries) + if (JniHelper.sendFd(fd, File(filesDir, "sock_path").absolutePath) != -1) return true + tries += 1 + } + } + return false + } +} diff --git a/mobile/src/main/java/com/github/shadowsocks/database/DBHelper.kt b/mobile/src/main/java/com/github/shadowsocks/database/DBHelper.kt new file mode 100644 index 00000000..3c7d6aa6 --- /dev/null +++ b/mobile/src/main/java/com/github/shadowsocks/database/DBHelper.kt @@ -0,0 +1,152 @@ +/******************************************************************************* + * * + * Copyright (C) 2017 by Max Lv * + * Copyright (C) 2017 by Mygod Studio * + * * + * This program is free software: you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation, either version 3 of the License, or * + * (at your option) any later version. * + * * + * This program is distributed in the hope that it will be useful, * + * but WITHOUT ANY WARRANTY; without even the implied warranty of * + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * + * GNU General Public License for more details. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program. If not, see . * + * * + *******************************************************************************/ + +package com.github.shadowsocks.database + +import android.database.sqlite.SQLiteDatabase +import android.support.v7.preference.PreferenceManager +import android.text.TextUtils +import com.github.shadowsocks.App.Companion.app +import com.github.shadowsocks.utils.Key +import com.j256.ormlite.android.AndroidDatabaseConnection +import com.j256.ormlite.android.apptools.OrmLiteSqliteOpenHelper +import com.j256.ormlite.dao.Dao +import com.j256.ormlite.support.ConnectionSource +import com.j256.ormlite.table.TableUtils + +object DBHelper : OrmLiteSqliteOpenHelper(app, Key.PROFILE, null, 24) { + @Suppress("UNCHECKED_CAST") + val profileDao: Dao by lazy { getDao(Profile::class.java) as Dao } + @Suppress("UNCHECKED_CAST") + val kvPairDao: Dao by lazy { getDao(KeyValuePair::class.java) as Dao } + + override fun onCreate(database: SQLiteDatabase?, connectionSource: ConnectionSource?) { + TableUtils.createTable(connectionSource, Profile::class.java) + TableUtils.createTable(connectionSource, KeyValuePair::class.java) + } + + private fun recreate(database: SQLiteDatabase?, connectionSource: ConnectionSource?) { + TableUtils.dropTable(connectionSource, Profile::class.java, true) + TableUtils.dropTable(connectionSource, KeyValuePair::class.java, true) + onCreate(database, connectionSource) + } + + override fun onUpgrade(database: SQLiteDatabase?, connectionSource: ConnectionSource?, + oldVersion: Int, newVersion: Int) { + if (oldVersion < 7) { + recreate(database, connectionSource) + return + } + + try { + if (oldVersion < 8) { + profileDao.executeRawNoArgs("ALTER TABLE `profile` ADD COLUMN udpdns SMALLINT;") + } + if (oldVersion < 9) { + profileDao.executeRawNoArgs("ALTER TABLE `profile` ADD COLUMN route VARCHAR DEFAULT 'all';") + } else if (oldVersion < 19) { + profileDao.executeRawNoArgs("UPDATE `profile` SET route = 'all' WHERE route IS NULL;") + } + if (oldVersion < 11) { + profileDao.executeRawNoArgs("ALTER TABLE `profile` ADD COLUMN ipv6 SMALLINT;") + } + if (oldVersion < 12) { + profileDao.executeRawNoArgs("BEGIN TRANSACTION;") + profileDao.executeRawNoArgs("ALTER TABLE `profile` RENAME TO `tmp`;") + TableUtils.createTable(connectionSource, Profile::class.java) + profileDao.executeRawNoArgs( + "INSERT INTO `profile`(id, name, host, localPort, remotePort, password, method, route, proxyApps, bypass," + + " udpdns, ipv6, individual) " + + "SELECT id, name, host, localPort, remotePort, password, method, route, 1 - global, bypass, udpdns, ipv6," + + " individual FROM `tmp`;") + profileDao.executeRawNoArgs("DROP TABLE `tmp`;") + profileDao.executeRawNoArgs("COMMIT;") + } else if (oldVersion < 13) { + profileDao.executeRawNoArgs("ALTER TABLE `profile` ADD COLUMN tx LONG;") + profileDao.executeRawNoArgs("ALTER TABLE `profile` ADD COLUMN rx LONG;") + profileDao.executeRawNoArgs("ALTER TABLE `profile` ADD COLUMN date VARCHAR;") + } + + if (oldVersion < 15) { + if (oldVersion >= 12) profileDao.executeRawNoArgs("ALTER TABLE `profile` ADD COLUMN userOrder LONG;") + var i = 0L + val apps by lazy { app.packageManager.getInstalledApplications(0) } + for (profile in profileDao.queryForAll()) { + if (oldVersion < 14) { + val uidSet = profile.individual.split('|').filter(TextUtils::isDigitsOnly) + .map(String::toInt).toSet() + profile.individual = apps.filter { uidSet.contains(it.uid) } + .joinToString("\n") { it.packageName } + } + profile.userOrder = i + profileDao.update(profile) + i += 1 + } + } + + if (oldVersion < 16) { + profileDao.executeRawNoArgs( + "UPDATE `profile` SET route = 'bypass-lan-china' WHERE route = 'bypass-china'") + } + + if (oldVersion < 21) { + profileDao.executeRawNoArgs("ALTER TABLE `profile` ADD COLUMN remoteDns VARCHAR DEFAULT '8.8.8.8';") + } + + if (oldVersion < 17) { + profileDao.executeRawNoArgs("ALTER TABLE `profile` ADD COLUMN plugin VARCHAR;") + } else if (oldVersion < 22) { + // upgrade kcptun to SIP003 plugin + profileDao.executeRawNoArgs("BEGIN TRANSACTION;") + profileDao.executeRawNoArgs("ALTER TABLE `profile` RENAME TO `tmp`;") + TableUtils.createTable(connectionSource, Profile::class.java) + profileDao.executeRawNoArgs( + "INSERT INTO `profile`(id, name, host, localPort, remotePort, password, method, route, " + + "remoteDns, proxyApps, bypass, udpdns, ipv6, individual, tx, rx, date, userOrder, " + + "plugin) " + + "SELECT id, name, host, localPort, " + + "CASE WHEN kcp = 1 THEN kcpPort ELSE remotePort END, password, method, route, " + + "remoteDns, proxyApps, bypass, udpdns, ipv6, individual, tx, rx, date, userOrder, " + + "CASE WHEN kcp = 1 THEN 'kcptun ' || kcpcli ELSE NULL END FROM `tmp`;") + profileDao.executeRawNoArgs("DROP TABLE `tmp`;") + profileDao.executeRawNoArgs("COMMIT;") + } + + if (oldVersion < 23) { + profileDao.executeRawNoArgs("BEGIN TRANSACTION;") + TableUtils.createTable(connectionSource, KeyValuePair::class.java) + profileDao.executeRawNoArgs("COMMIT;") + val old = PreferenceManager.getDefaultSharedPreferences(app) + kvPairDao.createOrUpdate(KeyValuePair(Key.id).put(old.getInt(Key.id, 0))) + kvPairDao.createOrUpdate(KeyValuePair(Key.tfo).put(old.getBoolean(Key.tfo, false))) + } + } catch (ex: Exception) { + app.track(ex) + recreate(database, connectionSource) + } + } + + override fun onDowngrade(db: SQLiteDatabase?, oldVersion: Int, newVersion: Int) { + val connection = AndroidDatabaseConnection(db, true) + connectionSource.saveSpecialConnection(connection) + recreate(db, connectionSource) + connectionSource.clearSpecialConnection(connection) + } +} diff --git a/mobile/src/main/java/com/github/shadowsocks/database/KeyValuePair.kt b/mobile/src/main/java/com/github/shadowsocks/database/KeyValuePair.kt new file mode 100644 index 00000000..970e7550 --- /dev/null +++ b/mobile/src/main/java/com/github/shadowsocks/database/KeyValuePair.kt @@ -0,0 +1,108 @@ +/******************************************************************************* + * * + * Copyright (C) 2017 by Max Lv * + * Copyright (C) 2017 by Mygod Studio * + * * + * This program is free software: you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation, either version 3 of the License, or * + * (at your option) any later version. * + * * + * This program is distributed in the hope that it will be useful, * + * but WITHOUT ANY WARRANTY; without even the implied warranty of * + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * + * GNU General Public License for more details. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program. If not, see . * + * * + *******************************************************************************/ + +package com.github.shadowsocks.database + +import com.j256.ormlite.field.DataType +import com.j256.ormlite.field.DatabaseField +import java.io.ByteArrayOutputStream +import java.nio.ByteBuffer + +class KeyValuePair() { + companion object { + const val TYPE_UNINITIALIZED = 0 + const val TYPE_BOOLEAN = 1 + const val TYPE_FLOAT = 2 + const val TYPE_INT = 3 + const val TYPE_LONG = 4 + const val TYPE_STRING = 5 + const val TYPE_STRING_SET = 6 + } + + @DatabaseField(id = true) + var key: String? = "" + @DatabaseField + var valueType: Int = TYPE_UNINITIALIZED + @DatabaseField(dataType = DataType.BYTE_ARRAY) + var value: ByteArray = ByteArray(0) + + val boolean: Boolean? + get() = if (valueType == TYPE_BOOLEAN) ByteBuffer.wrap(value).get() != 0.toByte() else null + val float: Float? + get() = if (valueType == TYPE_FLOAT) ByteBuffer.wrap(value).float else null + val int: Int? + get() = if (valueType == TYPE_INT) ByteBuffer.wrap(value).int else null + val long: Long? + get() = if (valueType == TYPE_LONG) ByteBuffer.wrap(value).long else null + val string: String? + get() = if (valueType == TYPE_STRING) String(value) else null + val stringSet: MutableSet? + get() = if (valueType == TYPE_STRING_SET) { + val buffer = ByteBuffer.wrap(value) + val result = HashSet() + while (buffer.hasRemaining()) { + val chArr = ByteArray(buffer.int) + buffer.get(chArr) + result.add(String(chArr)) + } + result + } else null + + constructor(key: String?) : this() { + this.key = key + } + + // putting null requires using DataStore + fun put(value: Boolean): KeyValuePair { + valueType = TYPE_BOOLEAN + this.value = ByteBuffer.allocate(1).put((if (value) 1 else 0).toByte()).array() + return this + } + fun put(value: Float): KeyValuePair { + valueType = TYPE_FLOAT + this.value = ByteBuffer.allocate(4).putFloat(value).array() + return this + } + fun put(value: Int): KeyValuePair { + valueType = TYPE_INT + this.value = ByteBuffer.allocate(4).putInt(value).array() + return this + } + fun put(value: Long): KeyValuePair { + valueType = TYPE_LONG + this.value = ByteBuffer.allocate(8).putLong(value).array() + return this + } + fun put(value: String): KeyValuePair { + valueType = TYPE_STRING + this.value = value.toByteArray() + return this + } + fun put(value: MutableSet): KeyValuePair { + valueType = TYPE_STRING_SET + val stream = ByteArrayOutputStream() + for (v in value) { + stream.write(ByteBuffer.allocate(4).putInt(v.length).array()) + stream.write(v.toByteArray()) + } + this.value = stream.toByteArray() + return this + } +} diff --git a/mobile/src/main/java/com/github/shadowsocks/database/Profile.kt b/mobile/src/main/java/com/github/shadowsocks/database/Profile.kt new file mode 100644 index 00000000..3263c6b2 --- /dev/null +++ b/mobile/src/main/java/com/github/shadowsocks/database/Profile.kt @@ -0,0 +1,187 @@ +/******************************************************************************* + * * + * Copyright (C) 2017 by Max Lv * + * Copyright (C) 2017 by Mygod Studio * + * * + * This program is free software: you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation, either version 3 of the License, or * + * (at your option) any later version. * + * * + * This program is distributed in the hope that it will be useful, * + * but WITHOUT ANY WARRANTY; without even the implied warranty of * + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * + * GNU General Public License for more details. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program. If not, see . * + * * + *******************************************************************************/ + +package com.github.shadowsocks.database + +import android.net.Uri +import android.util.Base64 +import android.util.Log +import com.github.shadowsocks.plugin.PluginConfiguration +import com.github.shadowsocks.preference.DataStore +import com.github.shadowsocks.utils.Key +import com.github.shadowsocks.utils.parsePort +import com.j256.ormlite.field.DataType +import com.j256.ormlite.field.DatabaseField +import java.net.URI +import java.util.* + +class Profile { + companion object { + private const val TAG = "ShadowParser" + private val pattern = """(?i)ss://[-a-zA-Z0-9+&@#/%?=~_|!:,.;\[\]]*[-a-zA-Z0-9+&@#/%=~_|\[\]]""".toRegex() + private val userInfoPattern = "^(.+?):(.*)$".toRegex() + private val legacyPattern = "^(.+?):(.*)@(.+?):(\\d+?)$".toRegex() + + fun findAll(data: CharSequence?) = pattern.findAll(data ?: "").map { + val uri = Uri.parse(it.value) + if (uri.userInfo == null) { + val match = legacyPattern.matchEntire(String(Base64.decode(uri.host, Base64.NO_PADDING))) + if (match != null) { + val profile = Profile() + profile.method = match.groupValues[1].toLowerCase() + profile.password = match.groupValues[2] + profile.host = match.groupValues[3] + profile.remotePort = match.groupValues[4].toInt() + profile.plugin = uri.getQueryParameter(Key.plugin) + profile.name = uri.fragment + profile + } else { + Log.e(TAG, "Unrecognized URI: ${it.value}") + null + } + } else { + val match = userInfoPattern.matchEntire(String(Base64.decode(uri.userInfo, + Base64.NO_PADDING or Base64.NO_WRAP or Base64.URL_SAFE))) + if (match != null) { + val profile = Profile() + profile.method = match.groupValues[1] + profile.password = match.groupValues[2] + // bug in Android: https://code.google.com/p/android/issues/detail?id=192855 + val javaURI = URI(it.value) + profile.host = javaURI.host + if (profile.host.firstOrNull() == '[' && profile.host.lastOrNull() == ']') + profile.host = profile.host.substring(1, profile.host.length - 1) + profile.remotePort = javaURI.port + profile.plugin = uri.getQueryParameter(Key.plugin) + profile.name = uri.fragment ?: "" + profile + } else { + Log.e(TAG, "Unknown user info: ${it.value}") + null + } + } + }.filterNotNull() + } + + @DatabaseField(generatedId = true) + var id: Int = 0 + + @DatabaseField + var name: String = "" + + @DatabaseField + var host: String = "198.199.101.152" + + @DatabaseField + var remotePort: Int = 8388 + + @DatabaseField + var password: String = "u1rRWTssNv0p" + + @DatabaseField + var method: String = "aes-256-cfb" + + @DatabaseField + var route: String = "all" + + @DatabaseField + var remoteDns: String = "8.8.8.8" + + @DatabaseField + var proxyApps: Boolean = false + + @DatabaseField + var bypass: Boolean = false + + @DatabaseField + var udpdns: Boolean = false + + @DatabaseField + var ipv6: Boolean = true + + @DatabaseField(dataType = DataType.LONG_STRING) + var individual: String = "" + + @DatabaseField + var tx: Long = 0 + + @DatabaseField + var rx: Long = 0 + + @DatabaseField + val date: Date = Date() + + @DatabaseField + var userOrder: Long = 0 + + @DatabaseField + var plugin: String? = null + + val formattedAddress get() = (if (host.contains(":")) "[%s]:%d" else "%s:%d").format(host, remotePort) + val formattedName get() = if (name.isEmpty()) formattedAddress else name + + fun toUri(): Uri { + val builder = Uri.Builder() + .scheme("ss") + .encodedAuthority("%s@%s:%d".format(Locale.ENGLISH, + Base64.encodeToString("%s:%s".format(Locale.ENGLISH, method, password).toByteArray(), + Base64.NO_PADDING or Base64.NO_WRAP or Base64.URL_SAFE), + if (host.contains(':')) "[$host]" else host, remotePort)) + val configuration = PluginConfiguration(plugin ?: "") + if (configuration.selected.isNotEmpty()) + builder.appendQueryParameter(Key.plugin, configuration.selectedOptions.toString(false)) + if (name.isNotEmpty()) builder.fragment(name) + return builder.build() + } + override fun toString() = toUri().toString() + + fun serialize() { + DataStore.putString(Key.name, name) + DataStore.putString(Key.host, host) + DataStore.putString(Key.remotePort, remotePort.toString()) + DataStore.putString(Key.password, password) + DataStore.putString(Key.route, route) + DataStore.putString(Key.remoteDns, remoteDns) + DataStore.putString(Key.method, method) + DataStore.proxyApps = proxyApps + DataStore.bypass = bypass + DataStore.putBoolean(Key.udpdns, udpdns) + DataStore.putBoolean(Key.ipv6, ipv6) + DataStore.individual = individual + DataStore.plugin = plugin ?: "" + DataStore.remove(Key.dirty) + } + fun deserialize() { + // It's assumed that default values are never used, so 0/false/null is always used even if that isn't the case + name = DataStore.getString(Key.name) ?: "" + host = DataStore.getString(Key.host) ?: "" + remotePort = parsePort(DataStore.getString(Key.remotePort), 8388, 1) + password = DataStore.getString(Key.password) ?: "" + method = DataStore.getString(Key.method) ?: "" + route = DataStore.getString(Key.route) ?: "" + remoteDns = DataStore.getString(Key.remoteDns) ?: "" + proxyApps = DataStore.proxyApps + bypass = DataStore.bypass + udpdns = DataStore.getBoolean(Key.udpdns, false) + ipv6 = DataStore.getBoolean(Key.ipv6, false) + individual = DataStore.individual + plugin = DataStore.plugin + } +} diff --git a/mobile/src/main/java/com/github/shadowsocks/database/ProfileManager.kt b/mobile/src/main/java/com/github/shadowsocks/database/ProfileManager.kt new file mode 100644 index 00000000..f5d00745 --- /dev/null +++ b/mobile/src/main/java/com/github/shadowsocks/database/ProfileManager.kt @@ -0,0 +1,99 @@ +/******************************************************************************* + * * + * Copyright (C) 2017 by Max Lv * + * Copyright (C) 2017 by Mygod Studio * + * * + * This program is free software: you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation, either version 3 of the License, or * + * (at your option) any later version. * + * * + * This program is distributed in the hope that it will be useful, * + * but WITHOUT ANY WARRANTY; without even the implied warranty of * + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * + * GNU General Public License for more details. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program. If not, see . * + * * + *******************************************************************************/ + +package com.github.shadowsocks.database + +import android.util.Log +import com.github.shadowsocks.App.Companion.app +import com.github.shadowsocks.ProfilesFragment + +object ProfileManager { + const val TAG = "ProfileManager" + + fun createProfile(p: Profile? = null): Profile { + val profile = p ?: Profile() + profile.id = 0 + try { + val oldProfile = app.currentProfile + if (oldProfile != null) { + // Copy Feature Settings from old profile + profile.route = oldProfile.route + profile.ipv6 = oldProfile.ipv6 + profile.proxyApps = oldProfile.proxyApps + profile.bypass = oldProfile.bypass + profile.individual = oldProfile.individual + profile.udpdns = oldProfile.udpdns + } + val last = DBHelper.profileDao.queryRaw(DBHelper.profileDao.queryBuilder().selectRaw("MAX(userOrder)") + .prepareStatementString()).firstResult + if (last != null && last.size == 1 && last[0] != null) profile.userOrder = last[0].toLong() + 1 + DBHelper.profileDao.createOrUpdate(profile) + ProfilesFragment.instance?.profilesAdapter?.add(profile) + } catch (ex: Exception) { + Log.e(TAG, "addProfile", ex) + app.track(ex) + } + return profile + } + + fun updateProfile(profile: Profile): Boolean = try { + DBHelper.profileDao.update(profile) + true + } catch (ex: Exception) { + Log.e(TAG, "updateProfile", ex) + app.track(ex) + false + } + + fun getProfile(id: Int): Profile? = try { + DBHelper.profileDao.queryForId(id) + } catch (ex: Exception) { + Log.e(TAG, "getProfile", ex) + app.track(ex) + null + } + + fun delProfile(id: Int): Boolean = try { + DBHelper.profileDao.deleteById(id) + ProfilesFragment.instance?.profilesAdapter?.removeId(id) + true + } catch (ex: Exception) { + Log.e(TAG, "delProfile", ex) + app.track(ex) + false + } + + fun getFirstProfile(): Profile? = try { + val result = DBHelper.profileDao.query(DBHelper.profileDao.queryBuilder().limit(1L).prepare()) + if (result.size == 1) result[0] else null + } catch (ex: Exception) { + Log.e(TAG, "getAllProfiles", ex) + app.track(ex) + null + } + + fun getAllProfiles(): List? = try { + DBHelper.profileDao.query(DBHelper.profileDao.queryBuilder().orderBy("userOrder", true).prepare()) + } catch (ex: Exception) { + Log.e(TAG, "getAllProfiles", ex) + app.track(ex) + null + } +} diff --git a/mobile/src/main/java/com/github/shadowsocks/plugin/NativePlugin.kt b/mobile/src/main/java/com/github/shadowsocks/plugin/NativePlugin.kt new file mode 100644 index 00000000..dc234213 --- /dev/null +++ b/mobile/src/main/java/com/github/shadowsocks/plugin/NativePlugin.kt @@ -0,0 +1,32 @@ +/******************************************************************************* + * * + * Copyright (C) 2017 by Max Lv * + * Copyright (C) 2017 by Mygod Studio * + * * + * This program is free software: you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation, either version 3 of the License, or * + * (at your option) any later version. * + * * + * This program is distributed in the hope that it will be useful, * + * but WITHOUT ANY WARRANTY; without even the implied warranty of * + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * + * GNU General Public License for more details. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program. If not, see . * + * * + *******************************************************************************/ + +package com.github.shadowsocks.plugin + +import android.content.pm.ResolveInfo + +class NativePlugin(resolveInfo: ResolveInfo) : ResolvedPlugin(resolveInfo) { + init { + assert(resolveInfo.providerInfo != null) + } + + override val metaData get() = resolveInfo.providerInfo.metaData + override val packageName get() = resolveInfo.providerInfo.packageName +} diff --git a/mobile/src/main/java/com/github/shadowsocks/plugin/NoPlugin.kt b/mobile/src/main/java/com/github/shadowsocks/plugin/NoPlugin.kt new file mode 100644 index 00000000..62067de3 --- /dev/null +++ b/mobile/src/main/java/com/github/shadowsocks/plugin/NoPlugin.kt @@ -0,0 +1,8 @@ +package com.github.shadowsocks.plugin + +import com.github.shadowsocks.App.Companion.app + +object NoPlugin : Plugin() { + override val id: String get() = "" + override val label: CharSequence get() = app.getText(com.github.shadowsocks.R.string.plugin_disabled) +} diff --git a/mobile/src/main/java/com/github/shadowsocks/plugin/Plugin.kt b/mobile/src/main/java/com/github/shadowsocks/plugin/Plugin.kt new file mode 100644 index 00000000..0e864843 --- /dev/null +++ b/mobile/src/main/java/com/github/shadowsocks/plugin/Plugin.kt @@ -0,0 +1,32 @@ +/******************************************************************************* + * * + * Copyright (C) 2017 by Max Lv * + * Copyright (C) 2017 by Mygod Studio * + * * + * This program is free software: you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation, either version 3 of the License, or * + * (at your option) any later version. * + * * + * This program is distributed in the hope that it will be useful, * + * but WITHOUT ANY WARRANTY; without even the implied warranty of * + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * + * GNU General Public License for more details. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program. If not, see . * + * * + *******************************************************************************/ + +package com.github.shadowsocks.plugin + +import android.graphics.drawable.Drawable + +abstract class Plugin { + abstract val id: String + abstract val label: CharSequence + open val icon: Drawable? get() = null + open val defaultConfig: String? get() = null + open val packageName: String get() = "" + open val trusted: Boolean get() = true +} diff --git a/mobile/src/main/java/com/github/shadowsocks/plugin/PluginConfiguration.kt b/mobile/src/main/java/com/github/shadowsocks/plugin/PluginConfiguration.kt new file mode 100644 index 00000000..6c4a6a9f --- /dev/null +++ b/mobile/src/main/java/com/github/shadowsocks/plugin/PluginConfiguration.kt @@ -0,0 +1,60 @@ +/******************************************************************************* + * * + * Copyright (C) 2017 by Max Lv * + * Copyright (C) 2017 by Mygod Studio * + * * + * This program is free software: you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation, either version 3 of the License, or * + * (at your option) any later version. * + * * + * This program is distributed in the hope that it will be useful, * + * but WITHOUT ANY WARRANTY; without even the implied warranty of * + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * + * GNU General Public License for more details. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program. If not, see . * + * * + *******************************************************************************/ + +package com.github.shadowsocks.plugin + +import android.util.Log +import com.github.shadowsocks.utils.Commandline +import java.util.* + +class PluginConfiguration(val pluginsOptions: Map, val selected: String) { + private constructor(plugins: List) : this( + plugins.filter { it.id.isNotEmpty() }.associate { it.id to it }, + if (plugins.isEmpty()) "" else plugins[0].id) + constructor(plugin: String) : this(plugin.split('\n').map { line -> + if (line.startsWith("kcptun ")) { + val opt = PluginOptions() + opt.id = "kcptun" + try { + val iterator = Commandline.translateCommandline(line).drop(1).iterator() + while (iterator.hasNext()) { + val option = iterator.next() + if (option == "--nocomp") opt.put("nocomp", null) + else if (option.startsWith("--")) opt.put(option.substring(2), iterator.next()) + else throw IllegalArgumentException("Unknown kcptun parameter: " + option) + } + } catch (exc: Exception) { + Log.w("PluginConfiguration", exc.message) + } + opt + } else PluginOptions(line) + }) + + fun getOptions(id: String): PluginOptions = if (id.isEmpty()) PluginOptions() else + pluginsOptions.get(id) ?: PluginOptions(id, PluginManager.fetchPlugins()[id]?.defaultConfig) + val selectedOptions: PluginOptions get() = getOptions(selected) + + override fun toString(): String { + val result = LinkedList() + for ((id, opt) in pluginsOptions) if (id == this.selected) result.addFirst(opt) else result.addLast(opt) + if (!pluginsOptions.contains(selected)) result.addFirst(selectedOptions) + return result.map { it.toString(false) }.joinToString("\n") + } +} diff --git a/mobile/src/main/java/com/github/shadowsocks/plugin/PluginManager.kt b/mobile/src/main/java/com/github/shadowsocks/plugin/PluginManager.kt new file mode 100644 index 00000000..2a3f0d52 --- /dev/null +++ b/mobile/src/main/java/com/github/shadowsocks/plugin/PluginManager.kt @@ -0,0 +1,182 @@ +/******************************************************************************* + * * + * Copyright (C) 2017 by Max Lv * + * Copyright (C) 2017 by Mygod Studio * + * * + * This program is free software: you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation, either version 3 of the License, or * + * (at your option) any later version. * + * * + * This program is distributed in the hope that it will be useful, * + * but WITHOUT ANY WARRANTY; without even the implied warranty of * + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * + * GNU General Public License for more details. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program. If not, see . * + * * + *******************************************************************************/ + +package com.github.shadowsocks.plugin + +import android.annotation.SuppressLint +import android.content.ContentResolver +import android.content.Intent +import android.content.pm.PackageManager +import android.content.pm.Signature +import android.net.Uri +import android.os.Bundle +import android.util.Base64 +import android.util.Log +import com.github.shadowsocks.App.Companion.app +import com.github.shadowsocks.utils.Commandline +import eu.chainfire.libsuperuser.Shell +import java.io.File +import java.io.FileNotFoundException + +object PluginManager { + /** + * Trusted signatures by the app. Third-party fork should add their public key to their fork if the developer wishes + * to publish or has published plugins for this app. You can obtain your public key by executing: + * + * $ keytool -export -alias key-alias -keystore /path/to/keystore.jks -rfc + * + * If you don't plan to publish any plugin but is developing/has developed some, it's not necessary to add your + * public key yet since it will also automatically trust packages signed by the same signatures, e.g. debug keys. + */ + val trustedSignatures by lazy { + app.info.signatures.toSet() + + Signature(Base64.decode( // @Mygod + """ + |MIIDWzCCAkOgAwIBAgIEUzfv8DANBgkqhkiG9w0BAQsFADBdMQswCQYDVQQGEwJD + |TjEOMAwGA1UECBMFTXlnb2QxDjAMBgNVBAcTBU15Z29kMQ4wDAYDVQQKEwVNeWdv + |ZDEOMAwGA1UECxMFTXlnb2QxDjAMBgNVBAMTBU15Z29kMCAXDTE0MDUwMjA5MjQx + |OVoYDzMwMTMwOTAyMDkyNDE5WjBdMQswCQYDVQQGEwJDTjEOMAwGA1UECBMFTXln + |b2QxDjAMBgNVBAcTBU15Z29kMQ4wDAYDVQQKEwVNeWdvZDEOMAwGA1UECxMFTXln + |b2QxDjAMBgNVBAMTBU15Z29kMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKC + |AQEAjm5ikHoP3w6zavvZU5bRo6Birz41JL/nZidpdww21q/G9APA+IiJMUeeocy0 + |L7/QY8MQZABVwNq79LXYWJBcmmFXM9xBPgDqQP4uh9JsvazCI9bvDiMn92mz9HiS + |Sg9V4KGg0AcY0r230KIFo7hz+2QBp1gwAAE97myBfA3pi3IzJM2kWsh4LWkKQMfL + |M6KDhpb4mdDQnHlgi4JWe3SYbLtpB6whnTqjHaOzvyiLspx1tmrb0KVxssry9KoX + |YQzl56scfE/QJX0jJ5qYmNAYRCb4PibMuNSGB2NObDabSOMAdT4JLueOcHZ/x9tw + |agGQ9UdymVZYzf8uqc+29ppKdQIDAQABoyEwHzAdBgNVHQ4EFgQUBK4uJ0cqmnho + |6I72VmOVQMvVCXowDQYJKoZIhvcNAQELBQADggEBABZQ3yNESQdgNJg+NRIcpF9l + |YSKZvrBZ51gyrC7/2ZKMpRIyXruUOIrjuTR5eaONs1E4HI/uA3xG1eeW2pjPxDnO + |zgM4t7EPH6QbzibihoHw1MAB/mzECzY8r11PBhDQlst0a2hp+zUNR8CLbpmPPqTY + |RSo6EooQ7+NBejOXysqIF1q0BJs8Y5s/CaTOmgbL7uPCkzArB6SS/hzXgDk5gw6v + |wkGeOtzcj1DlbUTvt1s5GlnwBTGUmkbLx+YUje+n+IBgMbohLUDYBtUHylRVgMsc + |1WS67kDqeJiiQZvrxvyW6CZZ/MIGI+uAkkj3DqJpaZirkwPgvpcOIrjZy0uFvQM= + """, Base64.DEFAULT)) + + Signature(Base64.decode( // @madeye + """ + |MIICQzCCAaygAwIBAgIETV9OhjANBgkqhkiG9w0BAQUFADBmMQswCQYDVQQGEwJjbjERMA8GA1UE + |CBMIU2hhbmdoYWkxDzANBgNVBAcTBlB1ZG9uZzEUMBIGA1UEChMLRnVkYW4gVW5pdi4xDDAKBgNV + |BAsTA1BQSTEPMA0GA1UEAxMGTWF4IEx2MB4XDTExMDIxOTA1MDA1NFoXDTM2MDIxMzA1MDA1NFow + |ZjELMAkGA1UEBhMCY24xETAPBgNVBAgTCFNoYW5naGFpMQ8wDQYDVQQHEwZQdWRvbmcxFDASBgNV + |BAoTC0Z1ZGFuIFVuaXYuMQwwCgYDVQQLEwNQUEkxDzANBgNVBAMTBk1heCBMdjCBnzANBgkqhkiG + |9w0BAQEFAAOBjQAwgYkCgYEAq6lA8LqdeEI+es9SDX85aIcx8LoL3cc//iRRi+2mFIWvzvZ+bLKr + |4Wd0rhu/iU7OeMm2GvySFyw/GdMh1bqh5nNPLiRxAlZxpaZxLOdRcxuvh5Nc5yzjM+QBv8ECmuvu + |AOvvT3UDmA0AMQjZqSCmxWIxc/cClZ/0DubreBo2st0CAwEAATANBgkqhkiG9w0BAQUFAAOBgQAQ + |Iqonxpwk2ay+Dm5RhFfZyG9SatM/JNFx2OdErU16WzuK1ItotXGVJaxCZv3u/tTwM5aaMACGED5n + |AvHaDGCWynY74oDAopM4liF/yLe1wmZDu6Zo/7fXrH+T03LBgj2fcIkUfN1AA4dvnBo8XWAm9VrI + |1iNuLIssdhDz3IL9Yg== + """, Base64.DEFAULT)) + } + + private val receiver by lazy { app.listenForPackageChanges { synchronized(this) { cachedPlugins = null } } } + private var cachedPlugins: Map? = null + fun fetchPlugins(): Map { + receiver + return synchronized(this) { + if (cachedPlugins == null) { + val pm = app.packageManager + cachedPlugins = (pm.queryIntentContentProviders(Intent(PluginContract.ACTION_NATIVE_PLUGIN), + PackageManager.GET_META_DATA).map { NativePlugin(it) } + NoPlugin).associate { it.id to it } + } + cachedPlugins!! + } + } + + private fun buildUri(id: String) = Uri.Builder() + .scheme(PluginContract.SCHEME) + .authority(PluginContract.AUTHORITY) + .path('/' + id) + .build() + fun buildIntent(id: String, action: String): Intent = Intent(action, buildUri(id)) + + // the following parts are meant to be used by :bg + @Throws(Throwable::class) + fun init(options: PluginOptions): String? { + if (options.id.isEmpty()) return null + var throwable: Throwable? = null + + try { + val path = initNative(options) + if (path != null) return path + } catch (t: Throwable) { + t.printStackTrace() + if (throwable == null) throwable = t + } + + // add other plugin types here + + throw if (throwable != null) throwable else + FileNotFoundException(app.getString(com.github.shadowsocks.R.string.plugin_unknown, options.id)) + } + + private fun initNative(options: PluginOptions): String? { + val providers = app.packageManager.queryIntentContentProviders( + Intent(PluginContract.ACTION_NATIVE_PLUGIN, buildUri(options.id)), 0) + assert(providers.size == 1) + val uri = Uri.Builder() + .scheme(ContentResolver.SCHEME_CONTENT) + .authority(providers[0].providerInfo.authority) + .build() + val cr = app.contentResolver + return try { + initNativeFast(cr, options, uri) + } catch (t: Throwable) { + t.printStackTrace() + Log.w("PluginManager", "Initializing native plugin fast mode failed. Falling back to slow mode.") + initNativeSlow(cr, options, uri) + } + } + + private fun initNativeFast(cr: ContentResolver, options: PluginOptions, uri: Uri): String { + val out = Bundle() + out.putString(PluginContract.EXTRA_OPTIONS, options.id) + val result = cr.call(uri, PluginContract.METHOD_GET_EXECUTABLE, null, out) + .getString(PluginContract.EXTRA_ENTRY) + assert(File(result).canExecute()) + return result + } + + @SuppressLint("Recycle") + private fun initNativeSlow(cr: ContentResolver, options: PluginOptions, uri: Uri): String? { + var initialized = false + fun entryNotFound(): Nothing = throw IndexOutOfBoundsException("Plugin entry binary not found") + val list = ArrayList() + val pluginDir = File(app.filesDir, "plugin") + (cr.query(uri, arrayOf(PluginContract.COLUMN_PATH, PluginContract.COLUMN_MODE), null, null, null) + ?: return null).use { cursor -> + if (!cursor.moveToFirst()) entryNotFound() + pluginDir.deleteRecursively() + if (!pluginDir.mkdirs()) throw FileNotFoundException("Unable to create plugin directory") + val pluginDirPath = pluginDir.absolutePath + '/' + do { + val path = cursor.getString(0) + val file = File(pluginDir, path) + assert(file.absolutePath.startsWith(pluginDirPath)) + cr.openInputStream(uri.buildUpon().path(path).build()).use { inStream -> + file.outputStream().use { outStream -> inStream.copyTo(outStream) } + } + list += Commandline.toString(arrayOf("chmod", cursor.getString(1), file.absolutePath)) + if (path == options.id) initialized = true + } while (cursor.moveToNext()) + } + if (!initialized) entryNotFound() + Shell.SH.run(list) + return File(pluginDir, options.id).absolutePath + } +} diff --git a/mobile/src/main/java/com/github/shadowsocks/plugin/ResolvedPlugin.kt b/mobile/src/main/java/com/github/shadowsocks/plugin/ResolvedPlugin.kt new file mode 100644 index 00000000..bc0a6617 --- /dev/null +++ b/mobile/src/main/java/com/github/shadowsocks/plugin/ResolvedPlugin.kt @@ -0,0 +1,37 @@ +/******************************************************************************* + * * + * Copyright (C) 2017 by Max Lv * + * Copyright (C) 2017 by Mygod Studio * + * * + * This program is free software: you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation, either version 3 of the License, or * + * (at your option) any later version. * + * * + * This program is distributed in the hope that it will be useful, * + * but WITHOUT ANY WARRANTY; without even the implied warranty of * + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * + * GNU General Public License for more details. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program. If not, see . * + * * + *******************************************************************************/ + +package com.github.shadowsocks.plugin + +import android.content.pm.ResolveInfo +import android.graphics.drawable.Drawable +import android.os.Bundle +import com.github.shadowsocks.App.Companion.app + +abstract class ResolvedPlugin(protected val resolveInfo: ResolveInfo) : Plugin() { + protected abstract val metaData: Bundle + + override val id by lazy { metaData.getString(PluginContract.METADATA_KEY_ID) } + override val label by lazy { resolveInfo.loadLabel(app.packageManager) } + override val icon by lazy { resolveInfo.loadIcon(app.packageManager) } + override val defaultConfig by lazy { metaData.getString(PluginContract.METADATA_KEY_DEFAULT_CONFIG) } + override val packageName get() = resolveInfo.resolvePackageName + override val trusted by lazy { app.info.signatures.any(PluginManager.trustedSignatures::contains) } +} diff --git a/mobile/src/main/java/com/github/shadowsocks/preference/BottomSheetPreferenceDialogFragment.kt b/mobile/src/main/java/com/github/shadowsocks/preference/BottomSheetPreferenceDialogFragment.kt new file mode 100644 index 00000000..003b70a8 --- /dev/null +++ b/mobile/src/main/java/com/github/shadowsocks/preference/BottomSheetPreferenceDialogFragment.kt @@ -0,0 +1,121 @@ +/******************************************************************************* + * * + * Copyright (C) 2017 by Max Lv * + * Copyright (C) 2017 by Mygod Studio * + * * + * This program is free software: you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation, either version 3 of the License, or * + * (at your option) any later version. * + * * + * This program is distributed in the hope that it will be useful, * + * but WITHOUT ANY WARRANTY; without even the implied warranty of * + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * + * GNU General Public License for more details. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program. If not, see . * + * * + *******************************************************************************/ + +package com.github.shadowsocks.preference + +import android.app.Dialog +import android.content.ActivityNotFoundException +import android.content.Intent +import android.graphics.Typeface +import android.net.Uri +import android.os.Bundle +import android.provider.Settings +import android.support.design.widget.BottomSheetDialog +import android.support.v7.preference.PreferenceDialogFragmentCompat +import android.support.v7.widget.LinearLayoutManager +import android.support.v7.widget.RecyclerView +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.ImageView +import android.widget.TextView +import com.github.shadowsocks.R + +class BottomSheetPreferenceDialogFragment : PreferenceDialogFragmentCompat() { + private inner class IconListViewHolder(val dialog: BottomSheetDialog, view: View) : RecyclerView.ViewHolder(view), + View.OnClickListener, View.OnLongClickListener { + private var index = 0 + private val text1 = view.findViewById(android.R.id.text1) + private val text2 = view.findViewById(android.R.id.text2) + private val icon = view.findViewById(android.R.id.icon) + + init { + view.setOnClickListener(this) + view.setOnLongClickListener(this) + } + + fun bind(i: Int, selected: Boolean = false) { + text1.text = preference.entries[i] + text2.text = preference.entryValues[i] + val typeface = if (selected) Typeface.BOLD else Typeface.NORMAL + text1.setTypeface(null, typeface) + text2.setTypeface(null, typeface) + text2.visibility = if (preference.entryValues[i].isNotEmpty() && + preference.entries[i] != preference.entryValues[i]) View.VISIBLE else View.GONE + icon.setImageDrawable(preference.entryIcons?.get(i)) + index = i + } + + override fun onClick(p0: View?) { + clickedIndex = index + dialog.dismiss() + } + + override fun onLongClick(p0: View?): Boolean { + val pn = preference.entryPackageNames?.get(index) ?: return false + return try { + startActivity(Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, Uri.Builder() + .scheme("package") + .opaquePart(pn) + .build())) + true + } catch (_: ActivityNotFoundException) { + false + } + } + + } + private inner class IconListAdapter(private val dialog: BottomSheetDialog) : + RecyclerView.Adapter() { + override fun getItemCount(): Int = preference.entries.size + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = IconListViewHolder(dialog, + LayoutInflater.from(parent.context).inflate(R.layout.icon_list_item_2, parent, false)) + override fun onBindViewHolder(holder: IconListViewHolder, position: Int) { + if (preference.selectedEntry < 0) holder.bind(position) else when (position) { + 0 -> holder.bind(preference.selectedEntry, true) + in preference.selectedEntry + 1 .. Int.MAX_VALUE -> holder.bind(position) + else -> holder.bind(position - 1) + } + } + } + + private val preference by lazy { getPreference() as IconListPreference } + private var clickedIndex = -1 + + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + val activity = activity + val dialog = BottomSheetDialog(activity!!, theme) + val recycler = RecyclerView(activity) + recycler.setHasFixedSize(true) + recycler.layoutManager = LinearLayoutManager(activity) + recycler.adapter = IconListAdapter(dialog) + recycler.layoutParams = ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT) + dialog.setContentView(recycler) + return dialog + } + + override fun onDialogClosed(positiveResult: Boolean) { + if (clickedIndex >= 0 && clickedIndex != preference.selectedEntry) { + val value = preference.entryValues[clickedIndex].toString() + if (preference.callChangeListener(value)) preference.value = value + } + } +} diff --git a/mobile/src/main/java/com/github/shadowsocks/preference/DataStore.kt b/mobile/src/main/java/com/github/shadowsocks/preference/DataStore.kt new file mode 100644 index 00000000..b8ad0d65 --- /dev/null +++ b/mobile/src/main/java/com/github/shadowsocks/preference/DataStore.kt @@ -0,0 +1,134 @@ +/******************************************************************************* + * * + * Copyright (C) 2017 by Max Lv * + * Copyright (C) 2017 by Mygod Studio * + * * + * This program is free software: you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation, either version 3 of the License, or * + * (at your option) any later version. * + * * + * This program is distributed in the hope that it will be useful, * + * but WITHOUT ANY WARRANTY; without even the implied warranty of * + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * + * GNU General Public License for more details. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program. If not, see . * + * * + *******************************************************************************/ + +package com.github.shadowsocks.preference + +import android.os.Binder +import android.support.v7.preference.PreferenceDataStore +import com.github.shadowsocks.database.DBHelper +import com.github.shadowsocks.database.KeyValuePair +import com.github.shadowsocks.utils.Key +import com.github.shadowsocks.utils.parsePort +import java.util.* + +object DataStore : PreferenceDataStore() { + fun getBoolean(key: String?) = DBHelper.kvPairDao.queryForId(key)?.boolean + fun getFloat(key: String?) = DBHelper.kvPairDao.queryForId(key)?.float + fun getInt(key: String?) = DBHelper.kvPairDao.queryForId(key)?.int + fun getLong(key: String?) = DBHelper.kvPairDao.queryForId(key)?.long + fun getString(key: String?) = DBHelper.kvPairDao.queryForId(key)?.string + fun getStringSet(key: String?) = DBHelper.kvPairDao.queryForId(key)?.stringSet + + override fun getBoolean(key: String?, defValue: Boolean) = getBoolean(key) ?: defValue + override fun getFloat(key: String?, defValue: Float) = getFloat(key) ?: defValue + override fun getInt(key: String?, defValue: Int) = getInt(key) ?: defValue + override fun getLong(key: String?, defValue: Long) = getLong(key) ?: defValue + override fun getString(key: String?, defValue: String?) = getString(key) ?: defValue + override fun getStringSet(key: String?, defValue: MutableSet?) = getStringSet(key) ?: defValue + + fun putBoolean(key: String?, value: Boolean?) = if (value == null) remove(key) else putBoolean(key, value) + fun putFloat(key: String?, value: Float?) = if (value == null) remove(key) else putFloat(key, value) + fun putInt(key: String?, value: Int?) = if (value == null) remove(key) else putInt(key, value) + fun putLong(key: String?, value: Long?) = if (value == null) remove(key) else putLong(key, value) + override fun putBoolean(key: String?, value: Boolean) { + DBHelper.kvPairDao.createOrUpdate(KeyValuePair(key).put(value)) + fireChangeListener(key) + } + override fun putFloat(key: String?, value: Float) { + DBHelper.kvPairDao.createOrUpdate(KeyValuePair(key).put(value)) + fireChangeListener(key) + } + override fun putInt(key: String?, value: Int) { + DBHelper.kvPairDao.createOrUpdate(KeyValuePair(key).put(value)) + fireChangeListener(key) + } + override fun putLong(key: String?, value: Long) { + DBHelper.kvPairDao.createOrUpdate(KeyValuePair(key).put(value)) + fireChangeListener(key) + } + override fun putString(key: String?, value: String?) = if (value == null) remove(key) else { + DBHelper.kvPairDao.createOrUpdate(KeyValuePair(key).put(value)) + fireChangeListener(key) + } + override fun putStringSet(key: String?, values: MutableSet?) = if (values == null) remove(key) else { + DBHelper.kvPairDao.createOrUpdate(KeyValuePair(key).put(values)) + fireChangeListener(key) + } + + fun remove(key: String?) { + DBHelper.kvPairDao.deleteById(key) + fireChangeListener(key) + } + + private val listeners = HashSet() + private fun fireChangeListener(key: String?) = listeners.forEach { it.onPreferenceDataStoreChanged(this, key) } + fun registerChangeListener(listener: OnPreferenceDataStoreChangeListener) = listeners.add(listener) + fun unregisterChangeListener(listener: OnPreferenceDataStoreChangeListener) = listeners.remove(listener) + + // hopefully hashCode = mHandle doesn't change, currently this is true from KitKat to Nougat + private val userIndex by lazy { Binder.getCallingUserHandle().hashCode() } + private fun getLocalPort(key: String, default: Int): Int { + val value = getInt(key) + return if (value != null) { + putString(key, value.toString()) + value + } else parsePort(getString(key), default + userIndex) + } + + var profileId: Int + get() = getInt(Key.id) ?: 0 + set(value) = putInt(Key.id, value) + var serviceMode: String + get() = getString(Key.serviceMode) ?: Key.modeVpn + set(value) = putString(Key.serviceMode, value) + var portProxy: Int + get() = getLocalPort(Key.portProxy, 1080) + set(value) = putString(Key.portProxy, value.toString()) + var portLocalDns: Int + get() = getLocalPort(Key.portLocalDns, 5450) + set(value) = putString(Key.portLocalDns, value.toString()) + var portTransproxy: Int + get() = getLocalPort(Key.portTransproxy, 8200) + set(value) = putString(Key.portTransproxy, value.toString()) + + var proxyApps: Boolean + get() = getBoolean(Key.proxyApps) ?: false + set(value) = putBoolean(Key.proxyApps, value) + var bypass: Boolean + get() = getBoolean(Key.bypass) ?: false + set(value) = putBoolean(Key.bypass, value) + var individual: String + get() = getString(Key.individual) ?: "" + set(value) = putString(Key.individual, value) + var plugin: String + get() = getString(Key.plugin) ?: "" + set(value) = putString(Key.plugin, value) + var dirty: Boolean + get() = getBoolean(Key.dirty) ?: false + set(value) = putBoolean(Key.dirty, value) + + fun initGlobal() { + // temporary workaround for support lib bug + if (getString(Key.serviceMode) == null) serviceMode = serviceMode + if (getString(Key.portProxy) == null) portProxy = portProxy + if (getString(Key.portLocalDns) == null) portLocalDns = portLocalDns + if (getString(Key.portTransproxy) == null) portTransproxy = portTransproxy + } +} diff --git a/mobile/src/main/java/com/github/shadowsocks/preference/IconListPreference.kt b/mobile/src/main/java/com/github/shadowsocks/preference/IconListPreference.kt new file mode 100644 index 00000000..c4036568 --- /dev/null +++ b/mobile/src/main/java/com/github/shadowsocks/preference/IconListPreference.kt @@ -0,0 +1,78 @@ +/******************************************************************************* + * * + * Copyright (C) 2017 by Max Lv * + * Copyright (C) 2017 by Mygod Studio * + * * + * This program is free software: you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation, either version 3 of the License, or * + * (at your option) any later version. * + * * + * This program is distributed in the hope that it will be useful, * + * but WITHOUT ANY WARRANTY; without even the implied warranty of * + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * + * GNU General Public License for more details. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program. If not, see . * + * * + *******************************************************************************/ + +package com.github.shadowsocks.preference + +import android.content.Context +import android.graphics.drawable.Drawable +import android.preference.Preference.OnPreferenceChangeListener +import android.support.v7.preference.ListPreference +import android.util.AttributeSet + +class IconListPreference(context: Context, attrs: AttributeSet? = null) : ListPreference(context, attrs) { + var entryIcons: Array? = null + val selectedEntry: Int get() = entryValues.indexOf(value) + val entryIcon: Drawable? get() = try { + entryIcons?.get(selectedEntry) + } catch (_: ArrayIndexOutOfBoundsException) { + null + } +// fun setEntryIcons(@ArrayRes entryIconsResId: Int) { +// val array = getContext().getResources().obtainTypedArray(entryIconsResId) +// entryIcons = Array(array.length(), { i -> array.getDrawable(i) }) +// array.recycle() +// } + + var unknownValueSummary: String? = null + var entryPackageNames: Array? = null + + var listener: OnPreferenceChangeListener? = null + override fun getOnPreferenceChangeListener(): OnPreferenceChangeListener? = listener + override fun setOnPreferenceChangeListener(listener: OnPreferenceChangeListener?) { + this.listener = listener + } + + init { + super.setOnPreferenceChangeListener(OnPreferenceChangeListener { preference, newValue -> + val listener = listener + if (listener == null || listener.onPreferenceChange(preference, newValue)) { + setValue(newValue.toString()) + checkSummary() + if (entryIcons != null) setIcon(entryIcon) + true + } else false + }) +// val a = context.obtainStyledAttributes(attrs, R.styleable.IconListPreference) +// val entryIconsResId: Int = a.getResourceId(R.styleable.IconListPreference_entryIcons, -1) +// if (entryIconsResId != -1) entryIcons = entryIconsResId +// a.recycle() + } + + fun checkSummary() { + val unknownValueSummary = unknownValueSummary + if (unknownValueSummary != null) summary = if (selectedEntry < 0) unknownValueSummary.format(value) else "%s" + } + + fun init() = setIcon(entryIcon) + override fun onSetInitialValue(restoreValue: Boolean, defaultValue: Any?) { + super.onSetInitialValue(restoreValue, defaultValue) + init() + } +} diff --git a/mobile/src/main/java/com/github/shadowsocks/preference/OnPreferenceDataStoreChangeListener.kt b/mobile/src/main/java/com/github/shadowsocks/preference/OnPreferenceDataStoreChangeListener.kt new file mode 100644 index 00000000..625ad0a7 --- /dev/null +++ b/mobile/src/main/java/com/github/shadowsocks/preference/OnPreferenceDataStoreChangeListener.kt @@ -0,0 +1,27 @@ +/******************************************************************************* + * * + * Copyright (C) 2017 by Max Lv * + * Copyright (C) 2017 by Mygod Studio * + * * + * This program is free software: you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation, either version 3 of the License, or * + * (at your option) any later version. * + * * + * This program is distributed in the hope that it will be useful, * + * but WITHOUT ANY WARRANTY; without even the implied warranty of * + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * + * GNU General Public License for more details. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program. If not, see . * + * * + *******************************************************************************/ + +package com.github.shadowsocks.preference + +import android.support.v7.preference.PreferenceDataStore + +interface OnPreferenceDataStoreChangeListener { + fun onPreferenceDataStoreChanged(store: PreferenceDataStore, key: String?) +} diff --git a/mobile/src/main/java/com/github/shadowsocks/preference/PluginConfigurationDialogFragment.kt b/mobile/src/main/java/com/github/shadowsocks/preference/PluginConfigurationDialogFragment.kt new file mode 100644 index 00000000..0cf060bb --- /dev/null +++ b/mobile/src/main/java/com/github/shadowsocks/preference/PluginConfigurationDialogFragment.kt @@ -0,0 +1,53 @@ +/******************************************************************************* + * * + * Copyright (C) 2017 by Max Lv * + * Copyright (C) 2017 by Mygod Studio * + * * + * This program is free software: you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation, either version 3 of the License, or * + * (at your option) any later version. * + * * + * This program is distributed in the hope that it will be useful, * + * but WITHOUT ANY WARRANTY; without even the implied warranty of * + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * + * GNU General Public License for more details. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program. If not, see . * + * * + *******************************************************************************/ + +package com.github.shadowsocks.preference + +import android.support.v7.app.AlertDialog +import android.view.View +import android.widget.EditText +import com.github.shadowsocks.ProfileConfigActivity +import com.github.shadowsocks.plugin.PluginContract +import com.github.shadowsocks.plugin.PluginManager +import com.takisoft.fix.support.v7.preference.EditTextPreferenceDialogFragmentCompat + +class PluginConfigurationDialogFragment : EditTextPreferenceDialogFragmentCompat() { + companion object { + const val PLUGIN_ID_FRAGMENT_TAG = + "com.github.shadowsocks.preference.PluginConfigurationDialogFragment.PLUGIN_ID" + } + + private lateinit var editText: EditText + + override fun onPrepareDialogBuilder(builder: AlertDialog.Builder) { + super.onPrepareDialogBuilder(builder) + val intent = PluginManager.buildIntent(arguments!!.getString(PLUGIN_ID_FRAGMENT_TAG), + PluginContract.ACTION_HELP) + if (intent.resolveActivity(activity!!.packageManager) != null) builder.setNeutralButton("?", { _, _ -> + activity!!.startActivityForResult(intent.putExtra(PluginContract.EXTRA_OPTIONS, editText.text.toString()), + ProfileConfigActivity.REQUEST_CODE_PLUGIN_HELP) + }) + } + + override fun onBindDialogView(view: View) { + super.onBindDialogView(view) + editText = view.findViewById(android.R.id.edit) + } +} diff --git a/mobile/src/main/java/com/github/shadowsocks/tasker/ActionListener.kt b/mobile/src/main/java/com/github/shadowsocks/tasker/ActionListener.kt new file mode 100644 index 00000000..89d6dfec --- /dev/null +++ b/mobile/src/main/java/com/github/shadowsocks/tasker/ActionListener.kt @@ -0,0 +1,42 @@ +/******************************************************************************* + * * + * Copyright (C) 2017 by Max Lv * + * Copyright (C) 2017 by Mygod Studio * + * * + * This program is free software: you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation, either version 3 of the License, or * + * (at your option) any later version. * + * * + * This program is distributed in the hope that it will be useful, * + * but WITHOUT ANY WARRANTY; without even the implied warranty of * + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * + * GNU General Public License for more details. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program. If not, see . * + * * + *******************************************************************************/ + +package com.github.shadowsocks.tasker + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import com.github.shadowsocks.App.Companion.app +import com.github.shadowsocks.database.ProfileManager + +class ActionListener : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + val settings = Settings.fromIntent(intent) + var changed = false + if (ProfileManager.getProfile(settings.profileId) != null) { + app.switchProfile(settings.profileId) + changed = true + } + if (settings.switchOn) { + app.startService() + if (changed) app.reloadService() + } else app.stopService() + } +} diff --git a/mobile/src/main/java/com/github/shadowsocks/tasker/ConfigActivity.kt b/mobile/src/main/java/com/github/shadowsocks/tasker/ConfigActivity.kt new file mode 100644 index 00000000..1e2f459e --- /dev/null +++ b/mobile/src/main/java/com/github/shadowsocks/tasker/ConfigActivity.kt @@ -0,0 +1,114 @@ +/******************************************************************************* + * * + * Copyright (C) 2017 by Max Lv * + * Copyright (C) 2017 by Mygod Studio * + * * + * This program is free software: you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation, either version 3 of the License, or * + * (at your option) any later version. * + * * + * This program is distributed in the hope that it will be useful, * + * but WITHOUT ANY WARRANTY; without even the implied warranty of * + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * + * GNU General Public License for more details. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program. If not, see . * + * * + *******************************************************************************/ + +package com.github.shadowsocks.tasker + +import android.app.Activity +import android.content.res.Resources +import android.os.Build +import android.os.Bundle +import android.support.v7.app.AppCompatActivity +import android.support.v7.widget.DefaultItemAnimator +import android.support.v7.widget.LinearLayoutManager +import android.support.v7.widget.RecyclerView +import android.support.v7.widget.Toolbar +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.CheckedTextView +import android.widget.Switch +import com.github.shadowsocks.R +import com.github.shadowsocks.database.Profile +import com.github.shadowsocks.database.ProfileManager + +class ConfigActivity : AppCompatActivity() { + inner class ProfileViewHolder(view: View) : RecyclerView.ViewHolder(view), View.OnClickListener { + private var item: Profile? = null + private val text = itemView.findViewById(android.R.id.text1) + + init { + val typedArray = obtainStyledAttributes(intArrayOf(android.R.attr.selectableItemBackground)) + view.setBackgroundResource(typedArray.getResourceId(0, 0)) + typedArray.recycle() + itemView.setOnClickListener(this) + } + + fun bindDefault() { + item = null + text.setText(R.string.profile_default) + text.isChecked = taskerOption.profileId < 0 + } + fun bind(item: Profile) { + this.item = item + text.text = item.formattedName + text.isChecked = taskerOption.profileId == item.id + } + + override fun onClick(v: View?) { + taskerOption.switchOn = switch.isChecked + val item = item + taskerOption.profileId = item?.id ?: -1 + setResult(Activity.RESULT_OK, taskerOption.toIntent(this@ConfigActivity)) + finish() + } + } + + inner class ProfilesAdapter : RecyclerView.Adapter() { + internal val profiles = ProfileManager.getAllProfiles()?.toMutableList() ?: mutableListOf() + private val name = "select_dialog_singlechoice_" + (if (Build.VERSION.SDK_INT >= 21) "material" else "holo") + + override fun onBindViewHolder(holder: ProfileViewHolder, position: Int) = + if (position == 0) holder.bindDefault() else holder.bind(profiles[position - 1]) + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ProfileViewHolder = ProfileViewHolder( + LayoutInflater.from(parent.context).inflate(Resources.getSystem() + .getIdentifier(name, "layout", "android"), parent, false)) + override fun getItemCount(): Int = 1 + profiles.size + } + + private lateinit var taskerOption: Settings + private lateinit var switch: Switch + private val profilesAdapter = ProfilesAdapter() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + try { + taskerOption = Settings.fromIntent(intent!!) + } catch (_: Exception) { + finish() + return + } + setContentView(R.layout.layout_tasker) + + val toolbar = findViewById(R.id.toolbar) + toolbar.setTitle(R.string.app_name) + toolbar.setNavigationIcon(R.drawable.ic_navigation_close) + toolbar.setNavigationOnClickListener { finish() } + + switch = findViewById(R.id.serviceSwitch) + switch.isChecked = taskerOption.switchOn + val profilesList = findViewById(R.id.list) + val lm = LinearLayoutManager(this, LinearLayoutManager.VERTICAL, false) + profilesList.layoutManager = lm + profilesList.itemAnimator = DefaultItemAnimator() + profilesList.adapter = profilesAdapter + if (taskerOption.profileId >= 0) + lm.scrollToPosition(profilesAdapter.profiles.indexOfFirst { it.id == taskerOption.profileId } + 1) + } +} diff --git a/mobile/src/main/java/com/github/shadowsocks/tasker/Settings.kt b/mobile/src/main/java/com/github/shadowsocks/tasker/Settings.kt new file mode 100644 index 00000000..ec798dbc --- /dev/null +++ b/mobile/src/main/java/com/github/shadowsocks/tasker/Settings.kt @@ -0,0 +1,51 @@ +/******************************************************************************* + * * + * Copyright (C) 2017 by Max Lv * + * Copyright (C) 2017 by Mygod Studio * + * * + * This program is free software: you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation, either version 3 of the License, or * + * (at your option) any later version. * + * * + * This program is distributed in the hope that it will be useful, * + * but WITHOUT ANY WARRANTY; without even the implied warranty of * + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * + * GNU General Public License for more details. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program. If not, see . * + * * + *******************************************************************************/ + +package com.github.shadowsocks.tasker + +import android.content.Context +import android.content.Intent +import android.os.Bundle +import com.github.shadowsocks.R +import com.github.shadowsocks.database.ProfileManager +import com.twofortyfouram.locale.api.Intent as ApiIntent + +class Settings(bundle: Bundle) { + companion object { + private val KEY_SWITCH_ON = "switch_on" + private val KEY_PROFILE_ID = "profile_id" + + fun fromIntent(intent: Intent) = Settings(intent.getBundleExtra(ApiIntent.EXTRA_BUNDLE)) + } + + var switchOn: Boolean = bundle.getBoolean(KEY_SWITCH_ON, true) + var profileId: Int = bundle.getInt(KEY_PROFILE_ID, -1) + + fun toIntent(context: Context): Intent { + val bundle = Bundle() + if (!switchOn) bundle.putBoolean(KEY_SWITCH_ON, false) + if (profileId >= 0) bundle.putInt(KEY_PROFILE_ID, profileId) + val profile = ProfileManager.getProfile(profileId) + return Intent().putExtra(ApiIntent.EXTRA_BUNDLE, bundle).putExtra(ApiIntent.EXTRA_STRING_BLURB, + if (profile != null) + context.getString(if (switchOn) R.string.start_service else R.string.stop_service, profile.formattedName) + else context.getString(if (switchOn) R.string.start_service_default else R.string.stop)) + } +} diff --git a/mobile/src/main/java/com/github/shadowsocks/utils/Commandline.java b/mobile/src/main/java/com/github/shadowsocks/utils/Commandline.java deleted file mode 100644 index e364c567..00000000 --- a/mobile/src/main/java/com/github/shadowsocks/utils/Commandline.java +++ /dev/null @@ -1,155 +0,0 @@ -package com.github.shadowsocks.utils; - -import java.util.ArrayList; -import java.util.Arrays; -import java.util.StringTokenizer; - -/** - * Commandline objects help handling command lines specifying processes to - * execute. - * - * The class can be used to define a command line as nested elements or as a - * helper to define a command line by an application. - *

- * - * <someelement>
- *   <acommandline executable="/executable/to/run">
- *     <argument value="argument 1" />
- *     <argument line="argument_1 argument_2 argument_3" />
- *     <argument value="argument 4" />
- *   </acommandline>
- * </someelement>
- *
- * - * Based on: https://github.com/apache/ant/blob/588ce1f/src/main/org/apache/tools/ant/types/Commandline.java - * - * Adds support for escape character '\'. - * - * @author Mygod - */ -public final class Commandline { - private Commandline() { } - - /** - * Quote the parts of the given array in way that makes them - * usable as command line arguments. - * @param args the list of arguments to quote. - * @return empty string for null or no command, else every argument split - * by spaces and quoted by quoting rules. - */ - public static String toString(Iterable args) { - // empty path return empty string - if (args == null) { - return ""; - } - // path containing one or more elements - final StringBuilder result = new StringBuilder(); - for (String arg : args) { - if (result.length() > 0) result.append(' '); - for (int j = 0; j < arg.length(); ++j) { - char ch = arg.charAt(j); - switch (ch) { - case ' ': case '\\': case '"': case '\'': result.append('\\'); // intentionally no break - default: result.append(ch); - } - } - } - return result.toString(); - } - /** - * Quote the parts of the given array in way that makes them - * usable as command line arguments. - * @param args the list of arguments to quote. - * @return empty string for null or no command, else every argument split - * by spaces and quoted by quoting rules. - */ - public static String toString(String[] args) { - return toString(Arrays.asList(args)); // thanks to Java, arrays aren't iterable - } - - /** - * Crack a command line. - * @param toProcess the command line to process. - * @return the command line broken into strings. - * An empty or null toProcess parameter results in a zero sized array. - */ - public static String[] translateCommandline(String toProcess) { - if (toProcess == null || toProcess.length() == 0) { - //no command? no string - return new String[0]; - } - // parse with a simple finite state machine - - final int normal = 0; - final int inQuote = 1; - final int inDoubleQuote = 2; - int state = normal; - final StringTokenizer tok = new StringTokenizer(toProcess, "\\\"\' ", true); - final ArrayList result = new ArrayList<>(); - final StringBuilder current = new StringBuilder(); - boolean lastTokenHasBeenQuoted = false; - boolean lastTokenIsSlash = false; - - while (tok.hasMoreTokens()) { - String nextTok = tok.nextToken(); - switch (state) { - case inQuote: - if ("\'".equals(nextTok)) { - lastTokenHasBeenQuoted = true; - state = normal; - } else { - current.append(nextTok); - } - break; - case inDoubleQuote: - if ("\"".equals(nextTok)) { - if (lastTokenIsSlash) { - current.append(nextTok); - lastTokenIsSlash = false; - } else { - lastTokenHasBeenQuoted = true; - state = normal; - } - } else if ("\\".equals(nextTok)) { - if (lastTokenIsSlash) { - current.append(nextTok); - lastTokenIsSlash = false; - } else lastTokenIsSlash = true; - } else { - if (lastTokenIsSlash) { - current.append("\\"); // unescaped - lastTokenIsSlash = false; - } - current.append(nextTok); - } - break; - default: - if (lastTokenIsSlash) { - current.append(nextTok); - lastTokenIsSlash = false; - } else if ("\\".equals(nextTok)) lastTokenIsSlash = true; else if ("\'".equals(nextTok)) { - state = inQuote; - } else if ("\"".equals(nextTok)) { - state = inDoubleQuote; - } else if (" ".equals(nextTok)) { - if (lastTokenHasBeenQuoted || current.length() != 0) { - result.add(current.toString()); - current.setLength(0); - } - } else { - current.append(nextTok); - } - lastTokenHasBeenQuoted = false; - break; - } - } - if (lastTokenHasBeenQuoted || current.length() != 0) { - result.add(current.toString()); - } - if (state == inQuote || state == inDoubleQuote) { - throw new IllegalArgumentException("unbalanced quotes in " + toProcess); - } - if (lastTokenIsSlash) throw new IllegalArgumentException("escape character following nothing in " + toProcess); - return result.toArray(new String[result.size()]); - } -} diff --git a/mobile/src/main/java/com/github/shadowsocks/utils/Commandline.kt b/mobile/src/main/java/com/github/shadowsocks/utils/Commandline.kt new file mode 100644 index 00000000..35096d5e --- /dev/null +++ b/mobile/src/main/java/com/github/shadowsocks/utils/Commandline.kt @@ -0,0 +1,177 @@ +/******************************************************************************* + * * + * Copyright (C) 2017 by Max Lv * + * Copyright (C) 2017 by Mygod Studio * + * * + * This program is free software: you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation, either version 3 of the License, or * + * (at your option) any later version. * + * * + * This program is distributed in the hope that it will be useful, * + * but WITHOUT ANY WARRANTY; without even the implied warranty of * + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * + * GNU General Public License for more details. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program. If not, see . * + * * + *******************************************************************************/ + +package com.github.shadowsocks.utils + +import java.util.ArrayList +import java.util.Arrays +import java.util.StringTokenizer + +/** + * Commandline objects help handling command lines specifying processes to + * execute. + * + * The class can be used to define a command line as nested elements or as a + * helper to define a command line by an application. + * + * + * ` + *

+ *   

+ *     

+ *     

+ *     

+ *   


+ *


+` * + * + * Based on: https://github.com/apache/ant/blob/588ce1f/src/main/org/apache/tools/ant/types/Commandline.java + * + * Adds support for escape character '\'. + */ +object Commandline { + + /** + * Quote the parts of the given array in way that makes them + * usable as command line arguments. + * @param args the list of arguments to quote. + * @return empty string for null or no command, else every argument split + * by spaces and quoted by quoting rules. + */ + fun toString(args: Iterable?): String { + // empty path return empty string + if (args == null) { + return "" + } + // path containing one or more elements + val result = StringBuilder() + for (arg in args) { + if (result.isNotEmpty()) result.append(' ') + (0 until arg.length) + .map { arg[it] } + .forEach { + when (it) { + ' ', '\\', '"', '\'' -> { + result.append('\\') // intentionally no break + result.append(it) + } + else -> result.append(it) + } + } + } + return result.toString() + } + + /** + * Quote the parts of the given array in way that makes them + * usable as command line arguments. + * @param args the list of arguments to quote. + * @return empty string for null or no command, else every argument split + * by spaces and quoted by quoting rules. + */ + fun toString(args: Array): String { + return toString(Arrays.asList(*args)) // thanks to Java, arrays aren't iterable + } + + /** + * Crack a command line. + * @param toProcess the command line to process. + * @return the command line broken into strings. + * An empty or null toProcess parameter results in a zero sized array. + */ + fun translateCommandline(toProcess: String?): Array { + if (toProcess == null || toProcess.isEmpty()) { + //no command? no string + return arrayOf() + } + // parse with a simple finite state machine + + val normal = 0 + val inQuote = 1 + val inDoubleQuote = 2 + var state = normal + val tok = StringTokenizer(toProcess, "\\\"\' ", true) + val result = ArrayList() + val current = StringBuilder() + var lastTokenHasBeenQuoted = false + var lastTokenIsSlash = false + + while (tok.hasMoreTokens()) { + val nextTok = tok.nextToken() + when (state) { + inQuote -> if ("\'" == nextTok) { + lastTokenHasBeenQuoted = true + state = normal + } else { + current.append(nextTok) + } + inDoubleQuote -> if ("\"" == nextTok) { + if (lastTokenIsSlash) { + current.append(nextTok) + lastTokenIsSlash = false + } else { + lastTokenHasBeenQuoted = true + state = normal + } + } else if ("\\" == nextTok) { + lastTokenIsSlash = if (lastTokenIsSlash) { + current.append(nextTok) + false + } else + true + } else { + if (lastTokenIsSlash) { + current.append("\\") // unescaped + lastTokenIsSlash = false + } + current.append(nextTok) + } + else -> { + if (lastTokenIsSlash) { + current.append(nextTok) + lastTokenIsSlash = false + } else if ("\\" == nextTok) + lastTokenIsSlash = true + else if ("\'" == nextTok) { + state = inQuote + } else if ("\"" == nextTok) { + state = inDoubleQuote + } else if (" " == nextTok) { + if (lastTokenHasBeenQuoted || current.isNotEmpty()) { + result.add(current.toString()) + current.setLength(0) + } + } else { + current.append(nextTok) + } + lastTokenHasBeenQuoted = false + } + } + } + if (lastTokenHasBeenQuoted || current.isNotEmpty()) { + result.add(current.toString()) + } + if (state == inQuote || state == inDoubleQuote) { + throw IllegalArgumentException("unbalanced quotes in " + toProcess) + } + if (lastTokenIsSlash) throw IllegalArgumentException("escape character following nothing in " + toProcess) + return result.toTypedArray() + } +} diff --git a/mobile/src/main/java/com/github/shadowsocks/utils/Constants.kt b/mobile/src/main/java/com/github/shadowsocks/utils/Constants.kt new file mode 100644 index 00000000..b8f04e22 --- /dev/null +++ b/mobile/src/main/java/com/github/shadowsocks/utils/Constants.kt @@ -0,0 +1,69 @@ +/******************************************************************************* + * * + * Copyright (C) 2017 by Max Lv * + * Copyright (C) 2017 by Mygod Studio * + * * + * This program is free software: you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation, either version 3 of the License, or * + * (at your option) any later version. * + * * + * This program is distributed in the hope that it will be useful, * + * but WITHOUT ANY WARRANTY; without even the implied warranty of * + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * + * GNU General Public License for more details. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program. If not, see . * + * * + *******************************************************************************/ + +package com.github.shadowsocks.utils + +object Key { + const val PROFILE = "profile.db" + + const val id = "profileId" + const val name = "profileName" + + const val individual = "Proxyed" + + const val serviceMode = "serviceMode" + const val modeProxy = "proxy" + const val modeVpn = "vpn" + const val modeTransproxy = "transproxy" + const val portProxy = "portProxy" + const val portLocalDns = "portLocalDns" + const val portTransproxy = "portTransproxy" + + const val route = "route" + + const val isAutoConnect = "isAutoConnect" + + const val proxyApps = "isProxyApps" + const val bypass = "isBypassApps" + const val udpdns = "isUdpDns" + const val ipv6 = "isIpv6" + + const val host = "proxy" + const val password = "sitekey" + const val method = "encMethod" + const val remotePort = "remotePortNum" + const val remoteDns = "remoteDns" + + const val plugin = "plugin" + const val pluginConfigure = "plugin.configure" + + const val dirty = "profileDirty" + + const val tfo = "tcp_fastopen" + const val assetUpdateTime = "assetUpdateTime" +} + +object Action { + const val SERVICE = "com.github.shadowsocks.SERVICE" + const val CLOSE = "com.github.shadowsocks.CLOSE" + const val RELOAD = "com.github.shadowsocks.RELOAD" + + const val EXTRA_PROFILE_ID = "com.github.shadowsocks.EXTRA_PROFILE_ID" +} diff --git a/mobile/src/main/java/com/github/shadowsocks/utils/Dns.java b/mobile/src/main/java/com/github/shadowsocks/utils/Dns.java deleted file mode 100644 index 921c652d..00000000 --- a/mobile/src/main/java/com/github/shadowsocks/utils/Dns.java +++ /dev/null @@ -1,64 +0,0 @@ -/* - * Copyright 2010 Brave New Software Project, Inc. - * - * 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 - * - * http://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. - */ - -package com.github.shadowsocks.utils; - -import android.annotation.TargetApi; -import android.content.Context; -import android.net.ConnectivityManager; -import android.net.LinkProperties; -import android.os.Build; - -import java.lang.reflect.Method; -import java.net.InetAddress; -import java.util.ArrayList; -import java.util.Collection; - -public class Dns { - public static String getDnsResolver(Context context) throws Exception { - Collection dnsResolvers = getDnsResolvers(context); - if (dnsResolvers.isEmpty()) { - throw new Exception("Couldn't find an active DNS resolver"); - } - String dnsResolver = dnsResolvers.iterator().next().toString(); - if (dnsResolver.startsWith("/")) { - dnsResolver = dnsResolver.substring(1); - } - return dnsResolver; - } - - private static Collection getDnsResolvers(Context context) throws Exception { - ArrayList addresses = new ArrayList(); - ConnectivityManager connectivityManager = - (ConnectivityManager)context.getSystemService(Context.CONNECTIVITY_SERVICE); - Class LinkPropertiesClass = Class.forName("android.net.LinkProperties"); - Method getActiveLinkPropertiesMethod = ConnectivityManager.class.getMethod("getActiveLinkProperties"); - Object linkProperties = getActiveLinkPropertiesMethod.invoke(connectivityManager); - if (linkProperties != null) { - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) { - Method getDnsesMethod = LinkPropertiesClass.getMethod("getDnses"); - Collection dnses = (Collection)getDnsesMethod.invoke(linkProperties); - for (Object dns : dnses) { - addresses.add((InetAddress)dns); - } - } else { - addresses.addAll(((LinkProperties) linkProperties).getDnsServers()); - } - } - - return addresses; - } -} diff --git a/mobile/src/main/java/com/github/shadowsocks/utils/Subnet.kt b/mobile/src/main/java/com/github/shadowsocks/utils/Subnet.kt new file mode 100644 index 00000000..e2948430 --- /dev/null +++ b/mobile/src/main/java/com/github/shadowsocks/utils/Subnet.kt @@ -0,0 +1,67 @@ +/******************************************************************************* + * * + * Copyright (C) 2017 by Max Lv * + * Copyright (C) 2017 by Mygod Studio * + * * + * This program is free software: you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation, either version 3 of the License, or * + * (at your option) any later version. * + * * + * This program is distributed in the hope that it will be useful, * + * but WITHOUT ANY WARRANTY; without even the implied warranty of * + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * + * GNU General Public License for more details. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program. If not, see . * + * * + *******************************************************************************/ + +package com.github.shadowsocks.utils + +import java.net.InetAddress +import java.util.* + +class Subnet(val address: InetAddress, val prefixSize: Int) : Comparable { + companion object { + @Throws(IllegalArgumentException::class) + fun fromString(value: String): Subnet { + val parts = value.split('/') + val addr = parts[0].parseNumericAddress() + return when (parts.size) { + 1 -> Subnet(addr, addr.address.size shl 3) + 2 -> Subnet(addr, parts[1].toInt()) + else -> throw IllegalArgumentException() + } + } + } + + private val addressLength get() = address.address.size shl 3 + + init { + if (prefixSize < 0 || prefixSize > addressLength) throw IllegalArgumentException() + } + + override fun toString(): String = + if (prefixSize == addressLength) address.hostAddress else address.hostAddress + '/' + prefixSize + + private fun Byte.unsigned() = toInt() and 0xFF + override fun compareTo(other: Subnet): Int { + val addrThis = address.address + val addrThat = other.address.address + var result = addrThis.size.compareTo(addrThat.size) // IPv4 address goes first + if (result != 0) return result + for ((x, y) in addrThis zip addrThat) { + result = x.unsigned().compareTo(y.unsigned()) // undo sign extension of signed byte + if (result != 0) return result + } + return prefixSize.compareTo(other.prefixSize) + } + + override fun equals(other: Any?): Boolean { + val that = other as? Subnet + return address == that?.address && prefixSize == that.prefixSize + } + override fun hashCode(): Int = Objects.hash(address, prefixSize) +} diff --git a/mobile/src/main/java/com/github/shadowsocks/utils/TcpFastOpen.kt b/mobile/src/main/java/com/github/shadowsocks/utils/TcpFastOpen.kt new file mode 100644 index 00000000..bd38cf62 --- /dev/null +++ b/mobile/src/main/java/com/github/shadowsocks/utils/TcpFastOpen.kt @@ -0,0 +1,54 @@ +/******************************************************************************* + * * + * Copyright (C) 2017 by Max Lv * + * Copyright (C) 2017 by Mygod Studio * + * * + * This program is free software: you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation, either version 3 of the License, or * + * (at your option) any later version. * + * * + * This program is distributed in the hope that it will be useful, * + * but WITHOUT ANY WARRANTY; without even the implied warranty of * + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * + * GNU General Public License for more details. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program. If not, see . * + * * + *******************************************************************************/ + +package com.github.shadowsocks.utils + +import eu.chainfire.libsuperuser.Shell +import java.io.File + +object TcpFastOpen { + /** + * Is kernel version >= 3.7.1. + */ + val supported by lazy { + val match = """^(\d+)\.(\d+)\.(\d+)""".toRegex().find(System.getProperty("os.version")) + if (match == null) false else when (match.groupValues[1].toInt()) { + in Int.MIN_VALUE .. 2 -> false + 3 -> when (match.groupValues[2].toInt()) { + in Int.MIN_VALUE .. 6 -> false + 7 -> match.groupValues[3].toInt() >= 1 + else -> true + } + else -> true + } + } + + val sendEnabled: Boolean get() { + val file = File("/proc/sys/net/ipv4/tcp_fastopen") + return file.canRead() && file.bufferedReader().use { it.readText() }.trim().toInt() and 1 > 0 + } + + fun enabled(value: Boolean): String? = if (sendEnabled == value) null else Shell.run("su", arrayOf( + "if echo " + (if (value) 3 else 0) + " > /proc/sys/net/ipv4/tcp_fastopen; then", + " echo Success.", + "else", + " echo Failed.", + "fi"), null, true)?.joinToString("\n") +} diff --git a/mobile/src/main/java/com/github/shadowsocks/utils/Utils.kt b/mobile/src/main/java/com/github/shadowsocks/utils/Utils.kt new file mode 100644 index 00000000..4b5decbc --- /dev/null +++ b/mobile/src/main/java/com/github/shadowsocks/utils/Utils.kt @@ -0,0 +1,95 @@ +package com.github.shadowsocks.utils + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.graphics.Rect +import android.os.Build +import android.support.v4.app.Fragment +import android.support.v4.app.FragmentManager +import android.support.v7.util.SortedList +import android.util.DisplayMetrics +import android.view.Gravity +import android.view.View +import android.view.Window +import android.widget.Toast +import com.github.shadowsocks.App.Companion.app +import java.lang.reflect.InvocationTargetException +import java.net.InetAddress +import java.net.URLConnection + +private val isNumericMethod by lazy { InetAddress::class.java.getMethod("isNumeric", String::class.java) } +private val parseNumericAddressMethod by lazy { + InetAddress::class.java.getMethod("parseNumericAddress", String::class.java) +} +private val fieldChildFragmentManager by lazy { + val field = Fragment::class.java.getDeclaredField("mChildFragmentManager") + field.isAccessible = true + field +} + +fun String.isNumericAddress(): Boolean = isNumericMethod.invoke(null, this) as Boolean +fun String.parseNumericAddress(): InetAddress = try { + parseNumericAddressMethod.invoke(null, this) as InetAddress +} catch (exc: InvocationTargetException) { + throw exc.cause ?: exc +} + +fun parsePort(str: String?, default: Int, min: Int = 1025): Int { + val x = str?.toIntOrNull() ?: default + return if (x < min || x > 65535) default else x +} + +fun broadcastReceiver(callback: (Context, Intent) -> Unit): BroadcastReceiver = object : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) = callback(context, intent) +} + +/** + * Wrapper for kotlin.concurrent.thread that tracks uncaught exceptions. + */ +fun thread(start: Boolean = true, isDaemon: Boolean = false, contextClassLoader: ClassLoader? = null, + name: String? = null, priority: Int = -1, block: () -> Unit): Thread { + val thread = kotlin.concurrent.thread(false, isDaemon, contextClassLoader, name, priority, block) + thread.setUncaughtExceptionHandler(app::track) + if (start) thread.start() + return thread +} + +val URLConnection.responseLength: Long + get() = if (Build.VERSION.SDK_INT >= 24) contentLengthLong else contentLength.toLong() + +/** + * Based on: http://stackoverflow.com/a/21026866/2245107 + */ +fun Toast.position(view: View, window: Window, offsetX: Int = 0, offsetY: Int = 0): Toast { + val rect = Rect() + window.decorView.getWindowVisibleDisplayFrame(rect) + val viewLocation = IntArray(2) + view.getLocationInWindow(viewLocation) + val metrics = DisplayMetrics() + window.windowManager.defaultDisplay.getMetrics(metrics) + val toastView = this.view + toastView.measure(View.MeasureSpec.makeMeasureSpec(metrics.widthPixels, View.MeasureSpec.UNSPECIFIED), + View.MeasureSpec.makeMeasureSpec(metrics.heightPixels, View.MeasureSpec.UNSPECIFIED)) + setGravity(Gravity.START or Gravity.TOP, + viewLocation[0] - rect.left + (view.width - toastView.measuredWidth) / 2 + offsetX, + viewLocation[1] - rect.top + view.height + offsetY) + return this +} + +/** + * Based on: https://stackoverflow.com/a/15656428/2245107 + */ +var Fragment.childFragManager: FragmentManager? + get() = childFragmentManager + set(value) = fieldChildFragmentManager.set(this, value) + +private class SortedListIterable(private val list: SortedList) : Iterable { + override fun iterator(): Iterator = SortedListIterator(list) +} +private class SortedListIterator(private val list: SortedList) : Iterator { + private var count = 0 + override fun hasNext() = count < list.size() + override fun next(): T = list[count++] +} +fun SortedList.asIterable(): Iterable = SortedListIterable(this) diff --git a/mobile/src/main/java/com/github/shadowsocks/widget/BoundedGridLayout.kt b/mobile/src/main/java/com/github/shadowsocks/widget/BoundedGridLayout.kt new file mode 100644 index 00000000..19a6efab --- /dev/null +++ b/mobile/src/main/java/com/github/shadowsocks/widget/BoundedGridLayout.kt @@ -0,0 +1,47 @@ +/******************************************************************************* + * * + * Copyright (C) 2017 by Max Lv * + * Copyright (C) 2017 by Mygod Studio * + * * + * This program is free software: you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation, either version 3 of the License, or * + * (at your option) any later version. * + * * + * This program is distributed in the hope that it will be useful, * + * but WITHOUT ANY WARRANTY; without even the implied warranty of * + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * + * GNU General Public License for more details. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program. If not, see . * + * * + *******************************************************************************/ + +package com.github.shadowsocks.widget + +import android.content.Context +import android.util.AttributeSet +import android.widget.GridLayout +import com.github.shadowsocks.R + +/** + * Based on: http://stackoverflow.com/a/6212120/2245107 + */ +class BoundedGridLayout(context: Context, attrs: AttributeSet) : GridLayout(context, attrs) { + private val boundedWidth: Int + private val boundedHeight: Int + + init { + val arr = context.obtainStyledAttributes(attrs, R.styleable.BoundedGridLayout) + boundedWidth = arr.getDimensionPixelSize(R.styleable.BoundedGridLayout_bounded_width, 0) + boundedHeight = arr.getDimensionPixelSize(R.styleable.BoundedGridLayout_bounded_height, 0) + arr.recycle() + } + + override fun onMeasure(widthSpec: Int, heightSpec: Int) = super.onMeasure( + if (boundedWidth <= 0 || boundedWidth >= MeasureSpec.getSize(widthSpec)) widthSpec + else MeasureSpec.makeMeasureSpec(boundedWidth, MeasureSpec.getMode(widthSpec)), + if (boundedHeight <= 0 || boundedHeight >= MeasureSpec.getSize(heightSpec)) heightSpec + else MeasureSpec.makeMeasureSpec(boundedHeight, MeasureSpec.getMode(heightSpec))) +} diff --git a/mobile/src/main/java/com/github/shadowsocks/widget/UndoSnackbarManager.kt b/mobile/src/main/java/com/github/shadowsocks/widget/UndoSnackbarManager.kt new file mode 100644 index 00000000..98bf8c99 --- /dev/null +++ b/mobile/src/main/java/com/github/shadowsocks/widget/UndoSnackbarManager.kt @@ -0,0 +1,68 @@ +/******************************************************************************* + * * + * Copyright (C) 2017 by Max Lv * + * Copyright (C) 2017 by Mygod Studio * + * * + * This program is free software: you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation, either version 3 of the License, or * + * (at your option) any later version. * + * * + * This program is distributed in the hope that it will be useful, * + * but WITHOUT ANY WARRANTY; without even the implied warranty of * + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * + * GNU General Public License for more details. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program. If not, see . * + * * + *******************************************************************************/ + +package com.github.shadowsocks.widget + +import android.support.design.widget.Snackbar +import android.view.View +import com.github.shadowsocks.R + +/** + * @param view The view to find a parent from. + * @param undo Callback for undoing removals. + * @param commit Callback for committing removals. + * @tparam T Item type. + */ +class UndoSnackbarManager(private val view: View, private val undo: (List>) -> Unit, + commit: ((List>) -> Unit)? = null) { + private val recycleBin = ArrayList>() + private val removedCallback = object : Snackbar.Callback() { + override fun onDismissed(transientBottomBar: Snackbar?, event: Int) { + when (event) { + DISMISS_EVENT_SWIPE, DISMISS_EVENT_MANUAL, DISMISS_EVENT_TIMEOUT -> { + commit?.invoke(recycleBin) + recycleBin.clear() + } + } + last = null + } + } + private var last: Snackbar? = null + + fun remove(items: Collection>) { + recycleBin.addAll(items) + val count = recycleBin.size + val snackbar = Snackbar + .make(view, view.resources.getQuantityString(R.plurals.removed, count, count), Snackbar.LENGTH_LONG) + .addCallback(removedCallback) + .setAction(R.string.undo, { + undo(recycleBin.reversed()) + recycleBin.clear() + }) + snackbar.show() + last = snackbar + } + fun remove(vararg items: Pair) = remove(items.toList()) + + fun flush() { + last?.dismiss() + last = null + } +} diff --git a/mobile/src/main/jni/Application.mk b/mobile/src/main/jni/Application.mk deleted file mode 100644 index f80bef09..00000000 --- a/mobile/src/main/jni/Application.mk +++ /dev/null @@ -1,4 +0,0 @@ -APP_ABI := armeabi-v7a arm64-v8a x86 -APP_PLATFORM := android-19 -APP_STL := c++_static -NDK_TOOLCHAIN_VERSION := clang diff --git a/mobile/src/main/res/font/iceland.xml b/mobile/src/main/res/font/iceland.xml new file mode 100644 index 00000000..b887eb61 --- /dev/null +++ b/mobile/src/main/res/font/iceland.xml @@ -0,0 +1,7 @@ + + + diff --git a/mobile/src/main/res/layout/layout_header.xml b/mobile/src/main/res/layout/layout_header.xml index d9fd41f5..0488a489 100644 --- a/mobile/src/main/res/layout/layout_header.xml +++ b/mobile/src/main/res/layout/layout_header.xml @@ -1,7 +1,8 @@ + xmlns:tools="http://schemas.android.com/tools" + android:layout_width="match_parent" + android:layout_height="wrap_content"> + android:id="@+id/drawer_title" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_centerVertical="true" + android:fontFamily="@font/iceland" + android:gravity="center" + android:text="shadowsocks" + android:textColor="@android:color/white" + android:textSize="32sp" + tools:ignore="HardcodedText"/> diff --git a/mobile/src/main/res/layout/layout_main.xml b/mobile/src/main/res/layout/layout_main.xml index b2bf4ebb..e995094d 100644 --- a/mobile/src/main/res/layout/layout_main.xml +++ b/mobile/src/main/res/layout/layout_main.xml @@ -30,40 +30,40 @@ - + - - + - - + - - + - + android:layout_width="0dp" android:layout_height="wrap_content" android:layout_columnWeight="1" + android:layout_gravity="fill_horizontal" android:gravity="end"/> + android:nextFocusRight="@+id/edit" + android:orientation="vertical"> + + android:paddingRight="4dp"> + + android:textAppearance="?android:attr/textAppearanceMedium" + android:textColor="?android:attr/textColorPrimary" + android:textStyle="bold"/> + + android:nextFocusLeft="@+id/container" + android:padding="8dp" + app:srcCompat="@drawable/ic_social_share"/> + + android:focusable="true" + android:padding="8dp" + app:srcCompat="@drawable/ic_image_edit"/> + + android:paddingLeft="16dp" + android:paddingRight="4dp"> + + diff --git a/mobile/src/main/res/values/arrays.xml b/mobile/src/main/res/values/arrays.xml index bc68c7e6..7bbb1adc 100644 --- a/mobile/src/main/res/values/arrays.xml +++ b/mobile/src/main/res/values/arrays.xml @@ -1,6 +1,6 @@ - + RC4-MD5 AES-128-CFB AES-192-CFB @@ -22,7 +22,7 @@ XCHACHA20-IETF-POLY1305 - + rc4-md5 aes-128-cfb aes-192-cfb @@ -53,7 +53,7 @@ @string/route_entry_chinalist @string/custom_rules - + all bypass-lan bypass-china @@ -63,7 +63,7 @@ custom-rules - + 1.0.0.0/8 2.0.0.0/7 4.0.0.0/6 @@ -221,7 +221,7 @@ 208.0.0.0/4 - + @string/acl_rule_templates_generic @string/acl_rule_templates_domain URL @@ -232,7 +232,7 @@ @string/service_mode_vpn @string/service_mode_transproxy - + proxy vpn transproxy diff --git a/mobile/src/main/res/values/attrs.xml b/mobile/src/main/res/values/attrs.xml index 755593c8..803a4ba6 100644 --- a/mobile/src/main/res/values/attrs.xml +++ b/mobile/src/main/res/values/attrs.xml @@ -1,6 +1,6 @@ - + diff --git a/mobile/src/main/res/values/font_certs.xml b/mobile/src/main/res/values/font_certs.xml new file mode 100644 index 00000000..ae0b8f0a --- /dev/null +++ b/mobile/src/main/res/values/font_certs.xml @@ -0,0 +1,17 @@ + + + + @array/com_google_android_gms_fonts_certs_dev + @array/com_google_android_gms_fonts_certs_prod + + + + MIIEqDCCA5CgAwIBAgIJANWFuGx90071MA0GCSqGSIb3DQEBBAUAMIGUMQswCQYDVQQGEwJVUzETMBEGA1UECBMKQ2FsaWZvcm5pYTEWMBQGA1UEBxMNTW91bnRhaW4gVmlldzEQMA4GA1UEChMHQW5kcm9pZDEQMA4GA1UECxMHQW5kcm9pZDEQMA4GA1UEAxMHQW5kcm9pZDEiMCAGCSqGSIb3DQEJARYTYW5kcm9pZEBhbmRyb2lkLmNvbTAeFw0wODA0MTUyMzM2NTZaFw0zNTA5MDEyMzM2NTZaMIGUMQswCQYDVQQGEwJVUzETMBEGA1UECBMKQ2FsaWZvcm5pYTEWMBQGA1UEBxMNTW91bnRhaW4gVmlldzEQMA4GA1UEChMHQW5kcm9pZDEQMA4GA1UECxMHQW5kcm9pZDEQMA4GA1UEAxMHQW5kcm9pZDEiMCAGCSqGSIb3DQEJARYTYW5kcm9pZEBhbmRyb2lkLmNvbTCCASAwDQYJKoZIhvcNAQEBBQADggENADCCAQgCggEBANbOLggKv+IxTdGNs8/TGFy0PTP6DHThvbbR24kT9ixcOd9W+EaBPWW+wPPKQmsHxajtWjmQwWfna8mZuSeJS48LIgAZlKkpFeVyxW0qMBujb8X8ETrWy550NaFtI6t9+u7hZeTfHwqNvacKhp1RbE6dBRGWynwMVX8XW8N1+UjFaq6GCJukT4qmpN2afb8sCjUigq0GuMwYXrFVee74bQgLHWGJwPmvmLHC69EH6kWr22ijx4OKXlSIx2xT1AsSHee70w5iDBiK4aph27yH3TxkXy9V89TDdexAcKk/cVHYNnDBapcavl7y0RiQ4biu8ymM8Ga/nmzhRKya6G0cGw8CAQOjgfwwgfkwHQYDVR0OBBYEFI0cxb6VTEM8YYY6FbBMvAPyT+CyMIHJBgNVHSMEgcEwgb6AFI0cxb6VTEM8YYY6FbBMvAPyT+CyoYGapIGXMIGUMQswCQYDVQQGEwJVUzETMBEGA1UECBMKQ2FsaWZvcm5pYTEWMBQGA1UEBxMNTW91bnRhaW4gVmlldzEQMA4GA1UEChMHQW5kcm9pZDEQMA4GA1UECxMHQW5kcm9pZDEQMA4GA1UEAxMHQW5kcm9pZDEiMCAGCSqGSIb3DQEJARYTYW5kcm9pZEBhbmRyb2lkLmNvbYIJANWFuGx90071MAwGA1UdEwQFMAMBAf8wDQYJKoZIhvcNAQEEBQADggEBABnTDPEF+3iSP0wNfdIjIz1AlnrPzgAIHVvXxunW7SBrDhEglQZBbKJEk5kT0mtKoOD1JMrSu1xuTKEBahWRbqHsXclaXjoBADb0kkjVEJu/Lh5hgYZnOjvlba8Ld7HCKePCVePoTJBdI4fvugnL8TsgK05aIskyY0hKI9L8KfqfGTl1lzOv2KoWD0KWwtAWPoGChZxmQ+nBli+gwYMzM1vAkP+aayLe0a1EQimlOalO762r0GXO0ks+UeXde2Z4e+8S/pf7pITEI/tP+MxJTALw9QUWEv9lKTk+jkbqxbsh8nfBUapfKqYn0eidpwq2AzVp3juYl7//fKnaPhJD9gs= + + + + + MIIEQzCCAyugAwIBAgIJAMLgh0ZkSjCNMA0GCSqGSIb3DQEBBAUAMHQxCzAJBgNVBAYTAlVTMRMwEQYDVQQIEwpDYWxpZm9ybmlhMRYwFAYDVQQHEw1Nb3VudGFpbiBWaWV3MRQwEgYDVQQKEwtHb29nbGUgSW5jLjEQMA4GA1UECxMHQW5kcm9pZDEQMA4GA1UEAxMHQW5kcm9pZDAeFw0wODA4MjEyMzEzMzRaFw0zNjAxMDcyMzEzMzRaMHQxCzAJBgNVBAYTAlVTMRMwEQYDVQQIEwpDYWxpZm9ybmlhMRYwFAYDVQQHEw1Nb3VudGFpbiBWaWV3MRQwEgYDVQQKEwtHb29nbGUgSW5jLjEQMA4GA1UECxMHQW5kcm9pZDEQMA4GA1UEAxMHQW5kcm9pZDCCASAwDQYJKoZIhvcNAQEBBQADggENADCCAQgCggEBAKtWLgDYO6IIrgqWbxJOKdoR8qtW0I9Y4sypEwPpt1TTcvZApxsdyxMJZ2JORland2qSGT2y5b+3JKkedxiLDmpHpDsz2WCbdxgxRczfey5YZnTJ4VZbH0xqWVW/8lGmPav5xVwnIiJS6HXk+BVKZF+JcWjAsb/GEuq/eFdpuzSqeYTcfi6idkyugwfYwXFU1+5fZKUaRKYCwkkFQVfcAs1fXA5V+++FGfvjJ/CxURaSxaBvGdGDhfXE28LWuT9ozCl5xw4Yq5OGazvV24mZVSoOO0yZ31j7kYvtwYK6NeADwbSxDdJEqO4k//0zOHKrUiGYXtqw/A0LFFtqoZKFjnkCAQOjgdkwgdYwHQYDVR0OBBYEFMd9jMIhF1Ylmn/Tgt9r45jk14alMIGmBgNVHSMEgZ4wgZuAFMd9jMIhF1Ylmn/Tgt9r45jk14aloXikdjB0MQswCQYDVQQGEwJVUzETMBEGA1UECBMKQ2FsaWZvcm5pYTEWMBQGA1UEBxMNTW91bnRhaW4gVmlldzEUMBIGA1UEChMLR29vZ2xlIEluYy4xEDAOBgNVBAsTB0FuZHJvaWQxEDAOBgNVBAMTB0FuZHJvaWSCCQDC4IdGZEowjTAMBgNVHRMEBTADAQH/MA0GCSqGSIb3DQEBBAUAA4IBAQBt0lLO74UwLDYKqs6Tm8/yzKkEu116FmH4rkaymUIE0P9KaMftGlMexFlaYjzmB2OxZyl6euNXEsQH8gjwyxCUKRJNexBiGcCEyj6z+a1fuHHvkiaai+KL8W1EyNmgjmyy8AW7P+LLlkR+ho5zEHatRbM/YAnqGcFh5iZBqpknHf1SKMXFh4dd239FJ1jWYfbMDMy3NS5CTMQ2XFI1MvcyUTdZPErjQfTbQe3aDQsQcafEQPD+nqActifKZ0Np0IS9L9kR/wbNvyz6ENwPiTrjV2KRkEjH78ZMcUQXg0L3BYHJ3lc69Vs5Ddf9uUGGMYldX3WfMBEmh/9iFBDAaTCK + + + diff --git a/mobile/src/main/res/values/preloaded_fonts.xml b/mobile/src/main/res/values/preloaded_fonts.xml new file mode 100644 index 00000000..d421ea09 --- /dev/null +++ b/mobile/src/main/res/values/preloaded_fonts.xml @@ -0,0 +1,6 @@ + + + + @font/iceland + + diff --git a/mobile/src/main/res/values/strings.xml b/mobile/src/main/res/values/strings.xml index dc03ecee..3b07b4f3 100644 --- a/mobile/src/main/res/values/strings.xml +++ b/mobile/src/main/res/values/strings.xml @@ -18,8 +18,7 @@ Remote DNS Sent: \t\t\t\t\t%3$s\t↑\t%1$s/s\nReceived: \t%4$s\t↓\t%2$s/s - %1$s↑\t%2$s↓ - %1$s/s↑\t%2$s/s↓ + %s/s Byte Bytes diff --git a/mobile/src/main/scala/com/github/shadowsocks/AboutFragment.scala b/mobile/src/main/scala/com/github/shadowsocks/AboutFragment.scala deleted file mode 100644 index 785a2110..00000000 --- a/mobile/src/main/scala/com/github/shadowsocks/AboutFragment.scala +++ /dev/null @@ -1,46 +0,0 @@ -/*******************************************************************************/ -/* */ -/* Copyright (C) 2017 by Max Lv */ -/* Copyright (C) 2017 by Mygod Studio */ -/* */ -/* This program is free software: you can redistribute it and/or modify */ -/* it under the terms of the GNU General Public License as published by */ -/* the Free Software Foundation, either version 3 of the License, or */ -/* (at your option) any later version. */ -/* */ -/* This program is distributed in the hope that it will be useful, */ -/* but WITHOUT ANY WARRANTY; without even the implied warranty of */ -/* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the */ -/* GNU General Public License for more details. */ -/* */ -/* You should have received a copy of the GNU General Public License */ -/* along with this program. If not, see . */ -/* */ -/*******************************************************************************/ - -package com.github.shadowsocks -import java.util.Locale - -import android.content.Intent -import android.net.Uri -import android.os.Bundle -import android.view.{LayoutInflater, View, ViewGroup} -import android.webkit.{WebView, WebViewClient} - -class AboutFragment extends ToolbarFragment { - override def onCreateView(inflater: LayoutInflater, container: ViewGroup, savedInstanceState: Bundle): View = - inflater.inflate(R.layout.layout_about, container, false) - - override def onViewCreated(view: View, savedInstanceState: Bundle) { - super.onViewCreated(view, savedInstanceState) - toolbar.setTitle(getString(R.string.about_title).formatLocal(Locale.ENGLISH, BuildConfig.VERSION_NAME)) - val web = view.findViewById[WebView](R.id.web_view) - web.loadUrl("file:///android_asset/pages/about.html") - web.setWebViewClient(new WebViewClient() { - override def shouldOverrideUrlLoading(view: WebView, url: String): Boolean = { - getActivity.asInstanceOf[MainActivity].launchUrl(url) - true - } - }) - } -} diff --git a/mobile/src/main/scala/com/github/shadowsocks/AppManager.scala b/mobile/src/main/scala/com/github/shadowsocks/AppManager.scala deleted file mode 100644 index c4e97ae7..00000000 --- a/mobile/src/main/scala/com/github/shadowsocks/AppManager.scala +++ /dev/null @@ -1,267 +0,0 @@ -/*******************************************************************************/ -/* */ -/* Copyright (C) 2017 by Max Lv */ -/* Copyright (C) 2017 by Mygod Studio */ -/* */ -/* This program is free software: you can redistribute it and/or modify */ -/* it under the terms of the GNU General Public License as published by */ -/* the Free Software Foundation, either version 3 of the License, or */ -/* (at your option) any later version. */ -/* */ -/* This program is distributed in the hope that it will be useful, */ -/* but WITHOUT ANY WARRANTY; without even the implied warranty of */ -/* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the */ -/* GNU General Public License for more details. */ -/* */ -/* You should have received a copy of the GNU General Public License */ -/* along with this program. If not, see . */ -/* */ -/*******************************************************************************/ - -package com.github.shadowsocks - -import java.util.concurrent.atomic.AtomicBoolean - -import android.Manifest.permission -import android.animation.{Animator, AnimatorListenerAdapter} -import android.app.TaskStackBuilder -import android.content._ -import android.content.pm.PackageManager -import android.graphics.drawable.Drawable -import android.os.{Bundle, Handler} -import android.support.v7.app.AppCompatActivity -import android.support.v7.widget.Toolbar.OnMenuItemClickListener -import android.support.v7.widget.{DefaultItemAnimator, LinearLayoutManager, RecyclerView, Toolbar} -import android.view._ -import android.widget._ -import com.futuremind.recyclerviewfastscroll.{FastScroller, SectionTitleProvider} -import com.github.shadowsocks.ShadowsocksApplication.app -import com.github.shadowsocks.utils.{Key, Utils} - -import scala.collection.JavaConversions._ -import scala.collection.mutable -import scala.language.implicitConversions - -object AppManager { - case class ProxiedApp(name: String, packageName: String, icon: Drawable) - private case class ListEntry(switch: Switch, text: TextView, icon: ImageView) - - private var instance: AppManager = _ - - private var receiver: BroadcastReceiver = _ - private var cachedApps: Array[ProxiedApp] = _ - private def getApps(pm: PackageManager) = { - if (receiver == null) receiver = app.listenForPackageChanges { - synchronized(cachedApps = null) - val instance = AppManager.instance - if (instance != null) instance.reloadApps() - } - synchronized { - if (cachedApps == null) cachedApps = pm.getInstalledPackages(PackageManager.GET_PERMISSIONS) - .filter(p => p.requestedPermissions != null && p.requestedPermissions.contains(permission.INTERNET)) - .map(p => ProxiedApp(pm.getApplicationLabel(p.applicationInfo).toString, p.packageName, - p.applicationInfo.loadIcon(pm))).toArray - cachedApps - } - } -} - -class AppManager extends AppCompatActivity with OnMenuItemClickListener { - import AppManager._ - - private final class AppViewHolder(val view: View) extends RecyclerView.ViewHolder(view) with View.OnClickListener { - private val icon = itemView.findViewById[ImageView](R.id.itemicon) - private val check = itemView.findViewById[Switch](R.id.itemcheck) - private var item: ProxiedApp = _ - itemView.setOnClickListener(this) - - private def proxied = proxiedApps.contains(item.packageName) - - def bind(app: ProxiedApp) { - this.item = app - icon.setImageDrawable(app.icon) - check.setText(app.name) - check.setChecked(proxied) - } - - def onClick(v: View) { - if (proxied) { - proxiedApps.remove(item.packageName) - check.setChecked(false) - } else { - proxiedApps.add(item.packageName) - check.setChecked(true) - } - if (!appsLoading.get) { - app.dataStore.individual = proxiedApps.mkString("\n") - app.dataStore.dirty = true - } - } - } - - private final class AppsAdapter extends RecyclerView.Adapter[AppViewHolder] with SectionTitleProvider { - private val apps = getApps(getPackageManager).sortWith((a, b) => { - val aProxied = proxiedApps.contains(a.packageName) - if (aProxied ^ proxiedApps.contains(b.packageName)) aProxied else a.name.compareToIgnoreCase(b.name) < 0 - }) - - def getItemCount: Int = apps.length - def onBindViewHolder(vh: AppViewHolder, i: Int): Unit = vh.bind(apps(i)) - def onCreateViewHolder(vg: ViewGroup, i: Int) = - new AppViewHolder(LayoutInflater.from(vg.getContext).inflate(R.layout.layout_apps_item, vg, false)) - def getSectionTitle(i: Int): String = apps(i).name.substring(0, 1) - } - - private var proxiedApps: mutable.HashSet[String] = _ - private var toolbar: Toolbar = _ - private var bypassSwitch: Switch = _ - private var appListView: RecyclerView = _ - private var fastScroller: FastScroller = _ - private var loadingView: View = _ - private val appsLoading = new AtomicBoolean - private var handler: Handler = _ - - private def initProxiedApps(str: String = app.dataStore.individual) = - proxiedApps = str.split('\n').to[mutable.HashSet] - - override def onDestroy() { - instance = null - super.onDestroy() - if (handler != null) { - handler.removeCallbacksAndMessages(null) - handler = null - } - } - - def onMenuItemClick(item: MenuItem): Boolean = { - val clipboard = getSystemService(Context.CLIPBOARD_SERVICE).asInstanceOf[ClipboardManager] - item.getItemId match { - case R.id.action_apply_all => - app.profileManager.getAllProfiles match { - case Some(profiles) => - val proxiedAppString = app.dataStore.individual - profiles.foreach(p => { - p.individual = proxiedAppString - app.profileManager.updateProfile(p) - }) - Toast.makeText(this, R.string.action_apply_all, Toast.LENGTH_SHORT).show() - case _ => Toast.makeText(this, R.string.action_export_err, Toast.LENGTH_SHORT).show() - } - return true - case R.id.action_export => - clipboard.setPrimaryClip(ClipData.newPlainText(Key.individual, - app.dataStore.bypass + "\n" + app.dataStore.individual)) - Toast.makeText(this, R.string.action_export_msg, Toast.LENGTH_SHORT).show() - return true - case R.id.action_import => - if (clipboard.hasPrimaryClip) { - val proxiedAppSequence = clipboard.getPrimaryClip.getItemAt(0).getText - if (proxiedAppSequence != null) { - val proxiedAppString = proxiedAppSequence.toString - if (!proxiedAppString.isEmpty) { - val i = proxiedAppString.indexOf('\n') - try { - val (enabled, apps) = if (i < 0) (proxiedAppString, "") - else (proxiedAppString.substring(0, i), proxiedAppString.substring(i + 1)) - bypassSwitch.setChecked(enabled.toBoolean) - app.dataStore.individual = apps - app.dataStore.dirty = true - Toast.makeText(this, R.string.action_import_msg, Toast.LENGTH_SHORT).show() - appListView.setVisibility(View.GONE) - fastScroller.setVisibility(View.GONE) - loadingView.setVisibility(View.VISIBLE) - initProxiedApps(apps) - reloadApps() - return true - } catch { - case _: IllegalArgumentException => - Toast.makeText(this, R.string.action_import_err, Toast.LENGTH_SHORT).show() - } - } - } - } - Toast.makeText(this, R.string.action_import_err, Toast.LENGTH_SHORT).show() - return false - } - false - } - - protected override def onCreate(savedInstanceState: Bundle) { - super.onCreate(savedInstanceState) - - handler = new Handler() - - this.setContentView(R.layout.layout_apps) - toolbar = findViewById(R.id.toolbar).asInstanceOf[Toolbar] - toolbar.setTitle(R.string.proxied_apps) - toolbar.setNavigationIcon(R.drawable.abc_ic_ab_back_material) - toolbar.setNavigationOnClickListener(_ => { - val intent = getParentActivityIntent - if (shouldUpRecreateTask(intent) || isTaskRoot) - TaskStackBuilder.create(this).addNextIntentWithParentStack(intent).startActivities() - else finish() - }) - toolbar.inflateMenu(R.menu.app_manager_menu) - toolbar.setOnMenuItemClickListener(this) - - if (!app.dataStore.proxyApps) { - app.dataStore.proxyApps = true - app.dataStore.dirty = true - } - findViewById(R.id.onSwitch).asInstanceOf[Switch] - .setOnCheckedChangeListener((_, checked) => { - app.dataStore.proxyApps = checked - app.dataStore.dirty = true - finish() - }) - - bypassSwitch = findViewById(R.id.bypassSwitch).asInstanceOf[Switch] - bypassSwitch.setChecked(app.dataStore.bypass) - bypassSwitch.setOnCheckedChangeListener((_, checked) => { - app.dataStore.bypass = checked - app.dataStore.dirty = true - }) - - initProxiedApps() - loadingView = findViewById(R.id.loading) - appListView = findViewById(R.id.list).asInstanceOf[RecyclerView] - appListView.setLayoutManager(new LinearLayoutManager(this, LinearLayoutManager.VERTICAL, false)) - appListView.setItemAnimator(new DefaultItemAnimator) - fastScroller = findViewById(R.id.fastscroller).asInstanceOf[FastScroller] - - instance = this - loadAppsAsync() - } - - def reloadApps(): Unit = if (!appsLoading.compareAndSet(true, false)) loadAppsAsync() - def loadAppsAsync() { - if (!appsLoading.compareAndSet(false, true)) return - Utils.ThrowableFuture { - var adapter: AppsAdapter = null - do { - appsLoading.set(true) - adapter = new AppsAdapter - } while (!appsLoading.compareAndSet(true, false)) - handler.post(() => { - appListView.setAdapter(adapter) - fastScroller.setRecyclerView(appListView) - val shortAnimTime = getResources.getInteger(android.R.integer.config_shortAnimTime) - appListView.setAlpha(0) - appListView.setVisibility(View.VISIBLE) - appListView.animate().alpha(1).setDuration(shortAnimTime) - fastScroller.setAlpha(0) - fastScroller.setVisibility(View.VISIBLE) - fastScroller.animate().alpha(1).setDuration(shortAnimTime) - loadingView.animate().alpha(0).setDuration(shortAnimTime).setListener(new AnimatorListenerAdapter { - override def onAnimationEnd(animation: Animator): Unit = loadingView.setVisibility(View.GONE) - }) - }) - } - } - - override def onKeyUp(keyCode: Int, event: KeyEvent): Boolean = keyCode match { - case KeyEvent.KEYCODE_MENU => - if (toolbar.isOverflowMenuShowing) toolbar.hideOverflowMenu else toolbar.showOverflowMenu - case _ => super.onKeyUp(keyCode, event) - } -} diff --git a/mobile/src/main/scala/com/github/shadowsocks/BootReceiver.scala b/mobile/src/main/scala/com/github/shadowsocks/BootReceiver.scala deleted file mode 100644 index b2825d4e..00000000 --- a/mobile/src/main/scala/com/github/shadowsocks/BootReceiver.scala +++ /dev/null @@ -1,40 +0,0 @@ -/*******************************************************************************/ -/* */ -/* Copyright (C) 2017 by Max Lv */ -/* Copyright (C) 2017 by Mygod Studio */ -/* */ -/* This program is free software: you can redistribute it and/or modify */ -/* it under the terms of the GNU General Public License as published by */ -/* the Free Software Foundation, either version 3 of the License, or */ -/* (at your option) any later version. */ -/* */ -/* This program is distributed in the hope that it will be useful, */ -/* but WITHOUT ANY WARRANTY; without even the implied warranty of */ -/* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the */ -/* GNU General Public License for more details. */ -/* */ -/* You should have received a copy of the GNU General Public License */ -/* along with this program. If not, see . */ -/* */ -/*******************************************************************************/ - -package com.github.shadowsocks - -import android.content.pm.PackageManager -import android.content.{BroadcastReceiver, ComponentName, Context, Intent} -import com.github.shadowsocks.utils._ - -object BootReceiver { - def getEnabled(context: Context): Boolean = PackageManager.COMPONENT_ENABLED_STATE_ENABLED == - context.getPackageManager.getComponentEnabledSetting(new ComponentName(context, classOf[BootReceiver])) - def setEnabled(context: Context, enabled: Boolean): Unit = context.getPackageManager.setComponentEnabledSetting( - new ComponentName(context, classOf[BootReceiver]), - if (enabled) PackageManager.COMPONENT_ENABLED_STATE_ENABLED else PackageManager.COMPONENT_ENABLED_STATE_DISABLED, - PackageManager.DONT_KILL_APP) -} - -class BootReceiver extends BroadcastReceiver { - def onReceive(context: Context, intent: Intent): Unit = { - Utils.startSsService(context) - } -} diff --git a/mobile/src/main/scala/com/github/shadowsocks/GlobalConfigFragment.scala b/mobile/src/main/scala/com/github/shadowsocks/GlobalConfigFragment.scala deleted file mode 100644 index 1811b727..00000000 --- a/mobile/src/main/scala/com/github/shadowsocks/GlobalConfigFragment.scala +++ /dev/null @@ -1,91 +0,0 @@ -/*******************************************************************************/ -/* */ -/* Copyright (C) 2017 by Max Lv */ -/* Copyright (C) 2017 by Mygod Studio */ -/* */ -/* This program is free software: you can redistribute it and/or modify */ -/* it under the terms of the GNU General Public License as published by */ -/* the Free Software Foundation, either version 3 of the License, or */ -/* (at your option) any later version. */ -/* */ -/* This program is distributed in the hope that it will be useful, */ -/* but WITHOUT ANY WARRANTY; without even the implied warranty of */ -/* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the */ -/* GNU General Public License for more details. */ -/* */ -/* You should have received a copy of the GNU General Public License */ -/* along with this program. If not, see . */ -/* */ -/*******************************************************************************/ - -package com.github.shadowsocks - -import android.os.Bundle -import android.support.design.widget.Snackbar -import android.support.v14.preference.SwitchPreference -import android.support.v7.preference.Preference -import com.github.shadowsocks.ShadowsocksApplication.app -import com.github.shadowsocks.bg.ServiceState -import com.github.shadowsocks.utils.{Key, TcpFastOpen} -import com.takisoft.fix.support.v7.preference.PreferenceFragmentCompatDividers - -class GlobalConfigFragment extends PreferenceFragmentCompatDividers { - override def onCreatePreferencesFix(bundle: Bundle, key: String) { - getPreferenceManager.setPreferenceDataStore(app.dataStore) - app.dataStore.initGlobal() - addPreferencesFromResource(R.xml.pref_global) - val switch = findPreference(Key.isAutoConnect).asInstanceOf[SwitchPreference] - switch.setOnPreferenceChangeListener((_, value) => { - BootReceiver.setEnabled(getActivity, value.asInstanceOf[Boolean]) - true - }) - switch.setChecked(BootReceiver.getEnabled(getActivity)) - - val tfo = findPreference(Key.tfo).asInstanceOf[SwitchPreference] - tfo.setChecked(TcpFastOpen.sendEnabled) - tfo.setOnPreferenceChangeListener((_, v) => { - val value = v.asInstanceOf[Boolean] - val result = TcpFastOpen.enabled(value) - if (result != null && result != "Success.") - Snackbar.make(getActivity.findViewById(R.id.snackbar), result, Snackbar.LENGTH_LONG).show() - value == TcpFastOpen.sendEnabled - }) - if (!TcpFastOpen.supported) { - tfo.setEnabled(false) - tfo.setSummary(getString(R.string.tcp_fastopen_summary_unsupported, java.lang.System.getProperty("os.version"))) - } - - val serviceMode = findPreference(Key.serviceMode) - val portProxy = findPreference(Key.portProxy) - val portLocalDns = findPreference(Key.portLocalDns) - val portTransproxy = findPreference(Key.portTransproxy) - def onServiceModeChange(p: Preference, v: Any) = { - val (enabledLocalDns, enabledTransproxy) = v match { - case Key.modeProxy => (false, false) - case Key.modeVpn => (true, false) - case Key.modeTransproxy => (true, true) - } - portLocalDns.setEnabled(enabledLocalDns) - portTransproxy.setEnabled(enabledTransproxy) - true - } - MainActivity.stateListener = { - case ServiceState.IDLE | ServiceState.STOPPED => - serviceMode.setEnabled(true) - portProxy.setEnabled(true) - onServiceModeChange(null, app.dataStore.serviceMode) - case _ => - serviceMode.setEnabled(false) - portProxy.setEnabled(false) - portLocalDns.setEnabled(false) - portTransproxy.setEnabled(false) - } - MainActivity.stateListener(getActivity.asInstanceOf[MainActivity].state) - serviceMode.setOnPreferenceChangeListener(onServiceModeChange) - } - - override def onDestroy() { - MainActivity.stateListener = null - super.onDestroy() - } -} diff --git a/mobile/src/main/scala/com/github/shadowsocks/GlobalSettingsFragment.scala b/mobile/src/main/scala/com/github/shadowsocks/GlobalSettingsFragment.scala deleted file mode 100644 index fd7f05a1..00000000 --- a/mobile/src/main/scala/com/github/shadowsocks/GlobalSettingsFragment.scala +++ /dev/null @@ -1,52 +0,0 @@ -/*******************************************************************************/ -/* */ -/* Copyright (C) 2017 by Max Lv */ -/* Copyright (C) 2017 by Mygod Studio */ -/* */ -/* This program is free software: you can redistribute it and/or modify */ -/* it under the terms of the GNU General Public License as published by */ -/* the Free Software Foundation, either version 3 of the License, or */ -/* (at your option) any later version. */ -/* */ -/* This program is distributed in the hope that it will be useful, */ -/* but WITHOUT ANY WARRANTY; without even the implied warranty of */ -/* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the */ -/* GNU General Public License for more details. */ -/* */ -/* You should have received a copy of the GNU General Public License */ -/* along with this program. If not, see . */ -/* */ -/*******************************************************************************/ - -package com.github.shadowsocks - -import android.os.Bundle -import android.view.{LayoutInflater, View, ViewGroup} -import android.app.Fragment - -class GlobalSettingsFragment extends ToolbarFragment { - - override def onCreateView(inflater: LayoutInflater, container: ViewGroup, savedInstanceState: Bundle): View = - inflater.inflate(R.layout.layout_global_settings, container, false) - - override def onViewCreated(view: View, savedInstanceState: Bundle) { - super.onViewCreated(view, savedInstanceState) - toolbar.setTitle(R.string.settings) - - val fm = getChildFragmentManager - fm.beginTransaction().replace(R.id.content, new GlobalConfigFragment()).commit() - fm.executePendingTransactions() - } - - override def onDetach() { - super.onDetach() - - try { - val childFragmentManager = classOf[Fragment].getDeclaredField("mChildFragmentManager") - childFragmentManager.setAccessible(true) - childFragmentManager.set(this, null) - } catch { - case _: Exception => // ignore - } - } -} diff --git a/mobile/src/main/scala/com/github/shadowsocks/GuardedProcess.scala b/mobile/src/main/scala/com/github/shadowsocks/GuardedProcess.scala deleted file mode 100644 index 5953d372..00000000 --- a/mobile/src/main/scala/com/github/shadowsocks/GuardedProcess.scala +++ /dev/null @@ -1,135 +0,0 @@ -/*******************************************************************************/ -/* */ -/* Copyright (C) 2017 by Max Lv */ -/* Copyright (C) 2017 by Mygod Studio */ -/* */ -/* This program is free software: you can redistribute it and/or modify */ -/* it under the terms of the GNU General Public License as published by */ -/* the Free Software Foundation, either version 3 of the License, or */ -/* (at your option) any later version. */ -/* */ -/* This program is distributed in the hope that it will be useful, */ -/* but WITHOUT ANY WARRANTY; without even the implied warranty of */ -/* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the */ -/* GNU General Public License for more details. */ -/* */ -/* You should have received a copy of the GNU General Public License */ -/* along with this program. If not, see . */ -/* */ -/*******************************************************************************/ - -package com.github.shadowsocks - -import java.io._ -import java.lang.System.currentTimeMillis -import java.util.concurrent.Semaphore - -import android.os.Build -import android.util.Log -import com.github.shadowsocks.utils.CloseUtils._ -import com.github.shadowsocks.utils.Commandline -import com.github.shadowsocks.ShadowsocksApplication.app - -import scala.collection.JavaConversions._ -import scala.collection.immutable.Stream - -class StreamLogger(is: InputStream, tag: String, logger: (String, String) => Int) extends Thread { - override def run(): Unit = autoClose(new BufferedReader(new InputStreamReader(is)))(br => - try Stream.continually(br.readLine()).takeWhile(_ != null).foreach(logger(tag, _)) catch { - case _: IOException => - }) -} - -/** - * @author ayanamist@gmail.com - */ -class GuardedProcess(cmd: String*) { - private val TAG = classOf[GuardedProcess].getSimpleName - - @volatile private var guardThread: Thread = _ - @volatile private var isDestroyed: Boolean = _ - @volatile private var process: Process = _ - @volatile private var isRestart = false - - def start(onRestartCallback: () => Unit = null): GuardedProcess = { - val semaphore = new Semaphore(1) - semaphore.acquire() - @volatile var ioException: IOException = null - - guardThread = new Thread(() => { - try { - var callback: () => Unit = null - while (!isDestroyed) { - if (BuildConfig.DEBUG) Log.d(TAG, "start process: " + Commandline.toString(cmd)) - val startTime = currentTimeMillis - - process = new ProcessBuilder(cmd) - .redirectErrorStream(true) - .directory(app.getFilesDir) - .start() - - new StreamLogger(process.getInputStream(), TAG, Log.i).start() - new StreamLogger(process.getErrorStream(), TAG, Log.e).start() - - if (callback == null) callback = onRestartCallback else callback() - - semaphore.release() - process.waitFor - - this.synchronized { - if (isRestart) { - isRestart = false - } else { - if (currentTimeMillis - startTime < 1000) { - Log.w(TAG, "process exit too fast, stop guard: " + Commandline.toString(cmd)) - isDestroyed = true - } - } - } - } - } catch { - case _: InterruptedException => - if (BuildConfig.DEBUG) Log.d(TAG, "thread interrupt, destroy process: " + Commandline.toString(cmd)) - destroyProcess() - case e: IOException => ioException = e - } finally semaphore.release() - }, "GuardThread-" + cmd.head) - - guardThread.start() - semaphore.acquire() - - if (ioException != null) throw ioException - - this - } - - def destroy() { - isDestroyed = true - guardThread.interrupt() - destroyProcess() - try guardThread.join() catch { - case _: InterruptedException => - } - } - - private def destroyProcess() { - if (Build.VERSION.SDK_INT < 24) { - JniHelper.sigtermCompat(process) - JniHelper.waitForCompat(process, 500) - } - process.destroy() - } - - def restart() { - this.synchronized { - isRestart = true - destroyProcess() - } - } - - @throws(classOf[InterruptedException]) - def waitFor: Int = { - guardThread.join() - 0 - } -} diff --git a/mobile/src/main/scala/com/github/shadowsocks/MainActivity.scala b/mobile/src/main/scala/com/github/shadowsocks/MainActivity.scala deleted file mode 100644 index 0dbbbee8..00000000 --- a/mobile/src/main/scala/com/github/shadowsocks/MainActivity.scala +++ /dev/null @@ -1,415 +0,0 @@ -/*******************************************************************************/ -/* */ -/* Copyright (C) 2017 by Max Lv */ -/* Copyright (C) 2017 by Mygod Studio */ -/* */ -/* This program is free software: you can redistribute it and/or modify */ -/* it under the terms of the GNU General Public License as published by */ -/* the Free Software Foundation, either version 3 of the License, or */ -/* (at your option) any later version. */ -/* */ -/* This program is distributed in the hope that it will be useful, */ -/* but WITHOUT ANY WARRANTY; without even the implied warranty of */ -/* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the */ -/* GNU General Public License for more details. */ -/* */ -/* You should have received a copy of the GNU General Public License */ -/* along with this program. If not, see . */ -/* */ -/*******************************************************************************/ - -package com.github.shadowsocks - -import java.lang.System.currentTimeMillis -import java.net.{HttpURLConnection, InetSocketAddress, URL, Proxy => JavaProxy} -import java.util.Locale - -import android.app.Activity -import android.app.backup.BackupManager -import android.content._ -import android.net.{Uri, VpnService} -import android.nfc.{NdefMessage, NfcAdapter} -import android.os.{Bundle, Handler} -import android.support.customtabs.CustomTabsIntent -import android.support.design.widget.{FloatingActionButton, Snackbar} -import android.support.v4.content.ContextCompat -import android.support.v7.app.{AlertDialog, AppCompatActivity} -import android.support.v7.content.res.AppCompatResources -import android.support.v7.preference.PreferenceDataStore -import android.support.v7.widget.RecyclerView.ViewHolder -import android.text.TextUtils -import android.util.Log -import android.view.View -import android.widget.{TextView, Toast} -import com.github.jorgecastilloprz.FABProgressCircle -import com.github.shadowsocks.ShadowsocksApplication.app -import com.github.shadowsocks.acl.{Acl, CustomRulesFragment} -import com.github.shadowsocks.aidl.IShadowsocksServiceCallback -import com.github.shadowsocks.bg.{Executable, ServiceState, TrafficMonitor} -import com.github.shadowsocks.preference.OnPreferenceDataStoreChangeListener -import com.github.shadowsocks.utils.CloseUtils.autoDisconnect -import com.github.shadowsocks.utils._ -import com.mikepenz.crossfader.Crossfader -import com.mikepenz.crossfader.view.CrossFadeSlidingPaneLayout -import com.mikepenz.materialdrawer.interfaces.ICrossfader -import com.mikepenz.materialdrawer.model.PrimaryDrawerItem -import com.mikepenz.materialdrawer.model.interfaces.IDrawerItem -import com.mikepenz.materialdrawer.{Drawer, DrawerBuilder} - -object MainActivity { - private final val TAG = "ShadowsocksMainActivity" - private final val REQUEST_CONNECT = 1 - - private final val DRAWER_PROFILES = 0L - private final val DRAWER_GLOBAL_SETTINGS = 1L - private final val DRAWER_ABOUT = 3L - private final val DRAWER_FAQ = 4L - private final val DRAWER_CUSTOM_RULES = 5L - - var stateListener: Int => Unit = _ -} - -class MainActivity extends AppCompatActivity with ServiceBoundContext with Drawer.OnDrawerItemClickListener - with OnPreferenceDataStoreChangeListener { - import MainActivity._ - - // UI - private val handler = new Handler() - private var fab: FloatingActionButton = _ - private var fabProgressCircle: FABProgressCircle = _ - var crossfader: Crossfader[CrossFadeSlidingPaneLayout] = _ - var drawer: Drawer = _ - - private var testCount: Int = _ - private var statusText: TextView = _ - private var txText: TextView = _ - private var rxText: TextView = _ - private var txRateText: TextView = _ - private var rxRateText: TextView = _ - - private lazy val customTabsIntent = new CustomTabsIntent.Builder() - .setToolbarColor(ContextCompat.getColor(this, R.color.material_primary_500)) - .build() - def launchUrl(url: String): Unit = try customTabsIntent.launchUrl(this, Uri.parse(url)) catch { - case _: ActivityNotFoundException => // Ignore - } - - // Services - var state: Int = _ - private val callback = new IShadowsocksServiceCallback.Stub { - def stateChanged(s: Int, profileName: String, m: String): Unit = handler.post(() => changeState(s, profileName, m)) - def trafficUpdated(profileId: Int, txRate: Long, rxRate: Long, txTotal: Long, rxTotal: Long): Unit = - handler.post(() => updateTraffic(profileId, txRate, rxRate, txTotal, rxTotal)) - override def trafficPersisted(profileId: Int): Unit = handler.post(() => if (ProfilesFragment.instance != null) - ProfilesFragment.instance.onTrafficPersisted(profileId)) - } - - private lazy val greyTint = ContextCompat.getColorStateList(MainActivity.this, R.color.material_primary_500) - private lazy val greenTint = ContextCompat.getColorStateList(MainActivity.this, R.color.material_green_700) - private def hideCircle() = try fabProgressCircle.hide() catch { - case _: NullPointerException => - } - private def changeState(s: Int, profileName: String = null, m: String = null) { - s match { - case ServiceState.CONNECTING => - fab.setImageResource(R.drawable.ic_start_busy) - fabProgressCircle.show() - statusText.setText(R.string.connecting) - case ServiceState.CONNECTED => - if (state == ServiceState.CONNECTING) fabProgressCircle.beginFinalAnimation() - else fabProgressCircle.postDelayed(hideCircle, 1000) - fab.setImageResource(R.drawable.ic_start_connected) - statusText.setText(R.string.vpn_connected) - case ServiceState.STOPPING => - fab.setImageResource(R.drawable.ic_start_busy) - if (state == ServiceState.CONNECTED) fabProgressCircle.show() // ignore for stopped - statusText.setText(R.string.stopping) - case _ => - fab.setImageResource(R.drawable.ic_start_idle) - fabProgressCircle.postDelayed(hideCircle, 1000) - if (m != null) { - Snackbar.make(findViewById(R.id.snackbar), - getString(R.string.vpn_error).formatLocal(Locale.ENGLISH, m), Snackbar.LENGTH_LONG).show() - Log.e(TAG, "Error to start VPN service: " + m) - } - statusText.setText(R.string.not_connected) - } - state = s - if (state == ServiceState.CONNECTED) fab.setBackgroundTintList(greenTint) else { - fab.setBackgroundTintList(greyTint) - updateTraffic(-1, 0, 0, 0, 0) - testCount += 1 // suppress previous test messages - } - if (ProfilesFragment.instance != null) - ProfilesFragment.instance.profilesAdapter.notifyDataSetChanged() // refresh button enabled state - if (stateListener != null) stateListener(s) - fab.setEnabled(false) - if (state == ServiceState.CONNECTED || state == ServiceState.STOPPED) - handler.postDelayed(() => fab.setEnabled(state == ServiceState.CONNECTED || state == ServiceState.STOPPED), 1000) - } - def updateTraffic(profileId: Int, txRate: Long, rxRate: Long, txTotal: Long, rxTotal: Long) { - txText.setText(TrafficMonitor.formatTraffic(txTotal)) - rxText.setText(TrafficMonitor.formatTraffic(rxTotal)) - txRateText.setText(TrafficMonitor.formatTraffic(txRate) + "/s") - rxRateText.setText(TrafficMonitor.formatTraffic(rxRate) + "/s") - val child = getSupportFragmentManager.findFragmentById(R.id.fragment_holder).asInstanceOf[ToolbarFragment] - if (state != ServiceState.STOPPING && child != null) - child.onTrafficUpdated(profileId, txRate, rxRate, txTotal, rxTotal) - } - - override def onServiceConnected(): Unit = changeState(bgService.getState) - override def onServiceDisconnected(): Unit = changeState(ServiceState.IDLE) - - override def binderDied(): Unit = handler.post(() => { - detachService() - Executable.killAll() - attachService(callback) - }) - - override def onActivityResult(requestCode: Int, resultCode: Int, data: Intent): Unit = resultCode match { - case Activity.RESULT_OK => Utils.startSsService(this) - case _ => - Snackbar.make(findViewById(R.id.snackbar), R.string.vpn_permission_denied, Snackbar.LENGTH_LONG).show() - Log.e(TAG, "Failed to start VpnService: %s".formatLocal(Locale.ENGLISH, data)) - } - - override def onCreate(savedInstanceState: Bundle) { - super.onCreate(savedInstanceState) - setContentView(R.layout.layout_main) - val drawerBuilder = new DrawerBuilder() - .withActivity(this) - .withTranslucentStatusBar(true) - .withHeader(R.layout.layout_header) - .addDrawerItems( - new PrimaryDrawerItem() - .withIdentifier(DRAWER_PROFILES) - .withName(R.string.profiles) - .withIcon(AppCompatResources.getDrawable(this, R.drawable.ic_action_description)) - .withIconTintingEnabled(true), - new PrimaryDrawerItem() - .withIdentifier(DRAWER_CUSTOM_RULES) - .withName(R.string.custom_rules) - .withIcon(AppCompatResources.getDrawable(this, R.drawable.ic_action_assignment)) - .withIconTintingEnabled(true), - new PrimaryDrawerItem() - .withIdentifier(DRAWER_GLOBAL_SETTINGS) - .withName(R.string.settings) - .withIcon(AppCompatResources.getDrawable(this, R.drawable.ic_action_settings)) - .withIconTintingEnabled(true) - ) - .addStickyDrawerItems( - new PrimaryDrawerItem() - .withIdentifier(DRAWER_FAQ) - .withName(R.string.faq) - .withIcon(AppCompatResources.getDrawable(this, R.drawable.ic_action_help_outline)) - .withIconTintingEnabled(true) - .withSelectable(false), - new PrimaryDrawerItem() - .withIdentifier(DRAWER_ABOUT) - .withName(R.string.about) - .withIcon(AppCompatResources.getDrawable(this, R.drawable.ic_action_copyright)) - .withIconTintingEnabled(true) - ) - .withOnDrawerItemClickListener(this) - .withActionBarDrawerToggle(true) - .withSavedInstance(savedInstanceState) - val miniDrawerWidth = getResources.getDimension(R.dimen.material_mini_drawer_item) - if (getResources.getDisplayMetrics.widthPixels >= - getResources.getDimension(R.dimen.profile_item_max_width) + miniDrawerWidth) { - drawer = drawerBuilder.withGenerateMiniDrawer(true).buildView() - crossfader = new Crossfader[CrossFadeSlidingPaneLayout]() - crossfader.withContent(findViewById(android.R.id.content)) - .withFirst(drawer.getSlider, getResources.getDimensionPixelSize(R.dimen.material_drawer_width)) - .withSecond(drawer.getMiniDrawer.build(this), miniDrawerWidth.toInt) - .withSavedInstance(savedInstanceState) - .build() - if (getResources.getConfiguration.getLayoutDirection == View.LAYOUT_DIRECTION_RTL) - crossfader.getCrossFadeSlidingPaneLayout.setShadowDrawableRight( - AppCompatResources.getDrawable(this, R.drawable.material_drawer_shadow_right)) - else crossfader.getCrossFadeSlidingPaneLayout.setShadowDrawableLeft( - AppCompatResources.getDrawable(this, R.drawable.material_drawer_shadow_left)) - drawer.getMiniDrawer.withCrossFader(new ICrossfader { // a wrapper is needed - def isCrossfaded: Boolean = crossfader.isCrossFaded - def crossfade(): Unit = crossfader.crossFade() - }) - } else drawer = drawerBuilder.build() - - val header = drawer.getHeader - val title = header.findViewById[TextView](R.id.drawer_title) - val tf = Typefaces.get(this, "fonts/Iceland.ttf") - if (tf != null) title.setTypeface(tf) - - if (savedInstanceState == null) displayFragment(new ProfilesFragment) - previousPosition = drawer.getCurrentSelectedPosition - statusText = findViewById(R.id.status).asInstanceOf[TextView] - txText = findViewById(R.id.tx).asInstanceOf[TextView] - txRateText = findViewById(R.id.txRate).asInstanceOf[TextView] - rxText = findViewById(R.id.rx).asInstanceOf[TextView] - rxRateText = findViewById(R.id.rxRate).asInstanceOf[TextView] - findViewById[View](R.id.stat).setOnClickListener(_ => if (state == ServiceState.CONNECTED) { - testCount += 1 - statusText.setText(R.string.connection_test_testing) - val id = testCount // it would change by other code - Utils.ThrowableFuture { - // Based on: https://android.googlesource.com/platform/frameworks/base/+/master/services/core/java/com/android/server/connectivity/NetworkMonitor.java#640 - autoDisconnect { - val url = new URL("https", app.currentProfile.get.route match { - case Acl.CHINALIST => "www.qualcomm.cn" - case _ => "www.google.com" - }, "/generate_204") - (if (app.usingVpnMode) url.openConnection() else url.openConnection( - new JavaProxy(JavaProxy.Type.SOCKS, new InetSocketAddress("127.0.0.1", app.dataStore.portProxy)))) - .asInstanceOf[HttpURLConnection] - } { conn => - conn.setConnectTimeout(5 * 1000) - conn.setReadTimeout(5 * 1000) - conn.setInstanceFollowRedirects(false) - conn.setUseCaches(false) - if (testCount == id) { - var result: String = null - var success = true - try { - val start = currentTimeMillis - conn.getInputStream - val elapsed = currentTimeMillis - start - val code = conn.getResponseCode - if (code == 204 || code == 200 && conn.getContentLength == 0) - result = getString(R.string.connection_test_available, elapsed: java.lang.Long) - else throw new Exception(getString(R.string.connection_test_error_status_code, code: Integer)) - } catch { - case e: Exception => - success = false - result = getString(R.string.connection_test_error, e.getMessage) - } - if (testCount == id) handler.post(() => if (success) statusText.setText(result) else { - statusText.setText(R.string.connection_test_fail) - Snackbar.make(findViewById(R.id.snackbar), result, Snackbar.LENGTH_LONG).show() - }) - } - } - } - }) - - fab = findViewById(R.id.fab).asInstanceOf[FloatingActionButton] - fabProgressCircle = findViewById(R.id.fabProgressCircle).asInstanceOf[FABProgressCircle] - fab.setOnClickListener(_ => if (state == ServiceState.CONNECTED) Utils.stopSsService(this) else Utils.ThrowableFuture { - if (app.usingVpnMode) { - val intent = VpnService.prepare(this) - if (intent != null) startActivityForResult(intent, REQUEST_CONNECT) - else handler.post(() => onActivityResult(REQUEST_CONNECT, Activity.RESULT_OK, null)) - } else Utils.startSsService(this) - }) - fab.setOnLongClickListener(_ => { - Utils.positionToast(Toast.makeText(this, if (state == ServiceState.CONNECTED) R.string.stop else R.string.connect, - Toast.LENGTH_SHORT), fab, getWindow, 0, getResources.getDimensionPixelOffset(R.dimen.margin_small)).show() - true - }) - - changeState(ServiceState.IDLE) // reset everything to init state - handler.post(() => attachService(callback)) - app.dataStore.registerChangeListener(this) - - val intent = getIntent - if (intent != null) handleShareIntent(intent) - } - - override def onNewIntent(intent: Intent) { - super.onNewIntent(intent) - handleShareIntent(intent) - } - - def handleShareIntent(intent: Intent) { - val sharedStr = intent.getAction match { - case Intent.ACTION_VIEW => intent.getData.toString - case NfcAdapter.ACTION_NDEF_DISCOVERED => - val rawMsgs = intent.getParcelableArrayExtra(NfcAdapter.EXTRA_NDEF_MESSAGES) - if (rawMsgs != null && rawMsgs.nonEmpty) - new String(rawMsgs(0).asInstanceOf[NdefMessage].getRecords()(0).getPayload) - else null - case _ => null - } - if (TextUtils.isEmpty(sharedStr)) return - val profiles = Parser.findAll(sharedStr).toList - if (profiles.isEmpty) { - Snackbar.make(findViewById(R.id.snackbar), R.string.profile_invalid_input, Snackbar.LENGTH_LONG).show() - return - } - new AlertDialog.Builder(this) - .setTitle(R.string.add_profile_dialog) - .setPositiveButton(R.string.yes, ((_, _) => - profiles.foreach(app.profileManager.createProfile)): DialogInterface.OnClickListener) - .setNegativeButton(R.string.no, null) - .setMessage(profiles.mkString("\n")) - .create() - .show() - } - - def onPreferenceDataStoreChanged(store: PreferenceDataStore, key: String): Unit = key match { - case Key.serviceMode => handler.post(() => { - detachService() - attachService(callback) - }) - case _ => - } - - private def displayFragment(fragment: ToolbarFragment) { - getSupportFragmentManager.beginTransaction().replace(R.id.fragment_holder, fragment).commitAllowingStateLoss() - drawer.closeDrawer() - } - - private var previousPosition: Int = _ - override def onItemClick(view: View, position: Int, drawerItem: IDrawerItem[_, _ <: ViewHolder]): Boolean = { - if (position == previousPosition) drawer.closeDrawer() else drawerItem.getIdentifier match { - case DRAWER_PROFILES => displayFragment(new ProfilesFragment) - case DRAWER_GLOBAL_SETTINGS => displayFragment(new GlobalSettingsFragment) - case DRAWER_ABOUT => - app.track(TAG, "about") - displayFragment(new AboutFragment) - case DRAWER_FAQ => launchUrl(getString(R.string.faq_url)) - case DRAWER_CUSTOM_RULES => displayFragment(new CustomRulesFragment) - case _ => // Ignore - } - previousPosition = position - true // unexpected cases will throw exception - } - - protected override def onResume() { - super.onResume() - app.remoteConfig.fetch() - state match { - case ServiceState.STOPPING | ServiceState.CONNECTING => - case _ => hideCircle() - } - } - - override def onStart() { - super.onStart() - setListeningForBandwidth(true) - } - - override def onBackPressed(): Unit = if (drawer.isDrawerOpen) drawer.closeDrawer() else { - val currentFragment = getSupportFragmentManager.findFragmentById(R.id.fragment_holder).asInstanceOf[ToolbarFragment] - if (!currentFragment.onBackPressed()) - if (currentFragment.isInstanceOf[ProfilesFragment]) super.onBackPressed() - else drawer.setSelection(DRAWER_PROFILES) - } - - override def onStop() { - setListeningForBandwidth(false) - super.onStop() - } - - override def onSaveInstanceState(outState: Bundle) { - super.onSaveInstanceState(outState) - drawer.saveInstanceState(outState) - if (crossfader != null) crossfader.saveInstanceState(outState) - } - - override def onDestroy() { - super.onDestroy() - app.dataStore.unregisterChangeListener(this) - detachService() - new BackupManager(this).dataChanged() - handler.removeCallbacksAndMessages(null) - } -} diff --git a/mobile/src/main/scala/com/github/shadowsocks/ProfileConfigActivity.scala b/mobile/src/main/scala/com/github/shadowsocks/ProfileConfigActivity.scala deleted file mode 100644 index 086b8bae..00000000 --- a/mobile/src/main/scala/com/github/shadowsocks/ProfileConfigActivity.scala +++ /dev/null @@ -1,66 +0,0 @@ -/*******************************************************************************/ -/* */ -/* Copyright (C) 2017 by Max Lv */ -/* Copyright (C) 2017 by Mygod Studio */ -/* */ -/* This program is free software: you can redistribute it and/or modify */ -/* it under the terms of the GNU General Public License as published by */ -/* the Free Software Foundation, either version 3 of the License, or */ -/* (at your option) any later version. */ -/* */ -/* This program is distributed in the hope that it will be useful, */ -/* but WITHOUT ANY WARRANTY; without even the implied warranty of */ -/* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the */ -/* GNU General Public License for more details. */ -/* */ -/* You should have received a copy of the GNU General Public License */ -/* along with this program. If not, see . */ -/* */ -/*******************************************************************************/ - -package com.github.shadowsocks - -import android.app.Activity -import android.content.{DialogInterface, Intent} -import android.os.Bundle -import android.support.v7.app.{AlertDialog, AppCompatActivity} -import android.support.v7.widget.Toolbar -import com.github.shadowsocks.ShadowsocksApplication.app -import com.github.shadowsocks.plugin.PluginContract - -object ProfileConfigActivity { - final val REQUEST_CODE_PLUGIN_HELP = 1 -} - -class ProfileConfigActivity extends AppCompatActivity { - import ProfileConfigActivity._ - - private lazy val child = getSupportFragmentManager.findFragmentById(R.id.content).asInstanceOf[ProfileConfigFragment] - - override def onCreate(savedInstanceState: Bundle) { - super.onCreate(savedInstanceState) - setContentView(R.layout.layout_profile_config) - val toolbar = findViewById(R.id.toolbar).asInstanceOf[Toolbar] - toolbar.setTitle(R.string.profile_config) - toolbar.setNavigationIcon(R.drawable.ic_navigation_close) - toolbar.setNavigationOnClickListener(_ => onBackPressed()) - toolbar.inflateMenu(R.menu.profile_config_menu) - toolbar.setOnMenuItemClickListener(child) - } - - override def onBackPressed(): Unit = if (app.dataStore.dirty) new AlertDialog.Builder(this) - .setTitle(R.string.unsaved_changes_prompt) - .setPositiveButton(R.string.yes, ((_, _) => child.saveAndExit()): DialogInterface.OnClickListener) - .setNegativeButton(R.string.no, ((_, _) => finish()): DialogInterface.OnClickListener) - .setNeutralButton(android.R.string.cancel, null) - .create() - .show() else super.onBackPressed() - - override def onActivityResult(requestCode: Int, resultCode: Int, data: Intent): Unit = requestCode match { - case REQUEST_CODE_PLUGIN_HELP => if (resultCode == Activity.RESULT_OK) new AlertDialog.Builder(this) - .setTitle("?") - .setMessage(data.getCharSequenceExtra(PluginContract.EXTRA_HELP_MESSAGE)) - .show() - case _ => super.onActivityResult(requestCode, resultCode, data) - } -} diff --git a/mobile/src/main/scala/com/github/shadowsocks/ProfileConfigFragment.scala b/mobile/src/main/scala/com/github/shadowsocks/ProfileConfigFragment.scala deleted file mode 100644 index 8acf9f88..00000000 --- a/mobile/src/main/scala/com/github/shadowsocks/ProfileConfigFragment.scala +++ /dev/null @@ -1,193 +0,0 @@ -/*******************************************************************************/ -/* */ -/* Copyright (C) 2017 by Max Lv */ -/* Copyright (C) 2017 by Mygod Studio */ -/* */ -/* This program is free software: you can redistribute it and/or modify */ -/* it under the terms of the GNU General Public License as published by */ -/* the Free Software Foundation, either version 3 of the License, or */ -/* (at your option) any later version. */ -/* */ -/* This program is distributed in the hope that it will be useful, */ -/* but WITHOUT ANY WARRANTY; without even the implied warranty of */ -/* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the */ -/* GNU General Public License for more details. */ -/* */ -/* You should have received a copy of the GNU General Public License */ -/* along with this program. If not, see . */ -/* */ -/*******************************************************************************/ - -package com.github.shadowsocks - -import android.app.Activity -import android.content._ -import android.os.{Build, Bundle, UserManager} -import android.support.design.widget.Snackbar -import android.support.v14.preference.SwitchPreference -import android.support.v7.app.AlertDialog -import android.support.v7.preference.{Preference, PreferenceDataStore} -import android.support.v7.widget.Toolbar.OnMenuItemClickListener -import android.text.TextUtils -import android.view.MenuItem -import com.github.shadowsocks.ShadowsocksApplication.app -import com.github.shadowsocks.database.Profile -import com.github.shadowsocks.plugin._ -import com.github.shadowsocks.preference.{IconListPreference, OnPreferenceDataStoreChangeListener, PluginConfigurationDialogFragment} -import com.github.shadowsocks.utils.{Action, Key} -import com.takisoft.fix.support.v7.preference.{EditTextPreference, PreferenceFragmentCompatDividers} - -object ProfileConfigFragment { - private final val REQUEST_CODE_PLUGIN_CONFIGURE = 1 - private final val FRAGMENT_DIALOG_TAG = "android.support.v7.preference.PreferenceFragment.DIALOG" -} - -class ProfileConfigFragment extends PreferenceFragmentCompatDividers with OnMenuItemClickListener - with OnPreferenceDataStoreChangeListener { - import ProfileConfigFragment._ - - private var profile: Profile = _ - private var isProxyApps: SwitchPreference = _ - private var plugin: IconListPreference = _ - private var pluginConfigure: EditTextPreference = _ - private var pluginConfiguration: PluginConfiguration = _ - - override def onCreatePreferencesFix(bundle: Bundle, key: String) { - getPreferenceManager.setPreferenceDataStore(app.dataStore) - app.profileManager.getProfile(getActivity.getIntent.getIntExtra(Action.EXTRA_PROFILE_ID, -1)) match { - case Some(p) => - profile = p - profile.serialize(app.dataStore) - case None => getActivity.finish() - } - addPreferencesFromResource(R.xml.pref_profile) - if (Build.VERSION.SDK_INT >= 25 && getActivity.getSystemService(classOf[UserManager]).isDemoUser) { - findPreference(Key.host).setSummary("shadowsocks.example.org") - findPreference(Key.remotePort).setSummary("1337") - findPreference(Key.password).setSummary("\u2022" * 32) - } - isProxyApps = findPreference(Key.proxyApps).asInstanceOf[SwitchPreference] - if (Build.VERSION.SDK_INT < 21) isProxyApps.getParent.removePreference(isProxyApps) else { - isProxyApps.setEnabled(app.usingVpnMode) - isProxyApps.setOnPreferenceClickListener(_ => { - startActivity(new Intent(getActivity, classOf[AppManager])) - isProxyApps.setChecked(true) - false - }) - } - plugin = findPreference(Key.plugin).asInstanceOf[IconListPreference] - pluginConfigure = findPreference(Key.pluginConfigure).asInstanceOf[EditTextPreference] - plugin.unknownValueSummary = getString(R.string.plugin_unknown) - plugin.setOnPreferenceChangeListener((_, value) => { - val selected = value.asInstanceOf[String] - pluginConfiguration = new PluginConfiguration(pluginConfiguration.pluginsOptions, selected) - app.dataStore.plugin = pluginConfiguration.toString - app.dataStore.dirty = true - pluginConfigure.setEnabled(!TextUtils.isEmpty(selected)) - pluginConfigure.setText(pluginConfiguration.selectedOptions.toString) - if (!PluginManager.fetchPlugins()(selected).trusted) - Snackbar.make(getView, R.string.plugin_untrusted, Snackbar.LENGTH_LONG).show() - true - }) - pluginConfigure.setOnPreferenceChangeListener(onPluginConfigureChanged) - initPlugins() - app.listenForPackageChanges(initPlugins()) - app.dataStore.registerChangeListener(this) - } - - def initPlugins() { - val plugins = PluginManager.fetchPlugins() - plugin.setEntries(plugins.map(_._2.label).toArray) - plugin.setEntryValues(plugins.map(_._2.id.asInstanceOf[CharSequence]).toArray) - plugin.setEntryIcons(plugins.map(_._2.icon).toArray) - plugin.entryPackageNames = plugins.map(_._2.packageName).toArray - pluginConfiguration = new PluginConfiguration(app.dataStore.plugin) - plugin.setValue(pluginConfiguration.selected) - plugin.init() - plugin.checkSummary() - pluginConfigure.setEnabled(!TextUtils.isEmpty(pluginConfiguration.selected)) - pluginConfigure.setText(pluginConfiguration.selectedOptions.toString) - } - - private def onPluginConfigureChanged(p: Preference, value: Any) = try { - val selected = pluginConfiguration.selected - pluginConfiguration = new PluginConfiguration(pluginConfiguration.pluginsOptions + - (pluginConfiguration.selected -> new PluginOptions(selected, value.asInstanceOf[String])), selected) - app.dataStore.plugin = pluginConfiguration.toString - app.dataStore.dirty = true - true - } catch { - case exc: IllegalArgumentException => - Snackbar.make(getView, exc.getLocalizedMessage, Snackbar.LENGTH_LONG).show() - false - } - - def onPreferenceDataStoreChanged(store: PreferenceDataStore, key: String): Unit = - if (key != Key.proxyApps && findPreference(key) != null) app.dataStore.dirty = true - - override def onDestroy() { - app.dataStore.unregisterChangeListener(this) - super.onDestroy() - } - - override def onResume() { - super.onResume() - isProxyApps.setChecked(app.dataStore.proxyApps) // fetch proxyApps updated by AppManager - } - - private def showPluginEditor() { - val bundle = new Bundle() - bundle.putString("key", Key.pluginConfigure) - bundle.putString(PluginConfigurationDialogFragment.PLUGIN_ID_FRAGMENT_TAG, pluginConfiguration.selected) - val fragment = new PluginConfigurationDialogFragment - fragment.setArguments(bundle) - fragment.setTargetFragment(this, 0) - fragment.show(getFragmentManager, FRAGMENT_DIALOG_TAG) - } - - override def onDisplayPreferenceDialog(preference: Preference): Unit = if (preference.getKey == Key.pluginConfigure) { - val intent = PluginManager.buildIntent(pluginConfiguration.selected, PluginContract.ACTION_CONFIGURE) - if (intent.resolveActivity(getActivity.getPackageManager) != null) - startActivityForResult(intent.putExtra(PluginContract.EXTRA_OPTIONS, - pluginConfiguration.selectedOptions.toString), REQUEST_CODE_PLUGIN_CONFIGURE) else { - showPluginEditor() - } - } else super.onDisplayPreferenceDialog(preference) - - override def onActivityResult(requestCode: Int, resultCode: Int, data: Intent): Unit = requestCode match { - case REQUEST_CODE_PLUGIN_CONFIGURE => resultCode match { - case Activity.RESULT_OK => - val options = data.getStringExtra(PluginContract.EXTRA_OPTIONS) - pluginConfigure.setText(options) - onPluginConfigureChanged(null, options) - case PluginContract.RESULT_FALLBACK => showPluginEditor() - case _ => - } - case _ => super.onActivityResult(requestCode, resultCode, data) - } - - override def onMenuItemClick(item: MenuItem): Boolean = item.getItemId match { - case R.id.action_delete => - new AlertDialog.Builder(getActivity) - .setTitle(R.string.delete_confirm_prompt) - .setPositiveButton(R.string.yes, ((_, _) => { - app.profileManager.delProfile(profile.id) - getActivity.finish() - }): DialogInterface.OnClickListener) - .setNegativeButton(R.string.no, null) - .create() - .show() - true - case R.id.action_apply => - saveAndExit() - true - case _ => false - } - - def saveAndExit() { - profile.deserialize(app.dataStore) - app.profileManager.updateProfile(profile) - if (ProfilesFragment.instance != null) ProfilesFragment.instance.profilesAdapter.deepRefreshId(profile.id) - getActivity.finish() - } -} diff --git a/mobile/src/main/scala/com/github/shadowsocks/ProfilesFragment.scala b/mobile/src/main/scala/com/github/shadowsocks/ProfilesFragment.scala deleted file mode 100644 index dcf59768..00000000 --- a/mobile/src/main/scala/com/github/shadowsocks/ProfilesFragment.scala +++ /dev/null @@ -1,360 +0,0 @@ -/*******************************************************************************/ -/* */ -/* Copyright (C) 2017 by Max Lv */ -/* Copyright (C) 2017 by Mygod Studio */ -/* */ -/* This program is free software: you can redistribute it and/or modify */ -/* it under the terms of the GNU General Public License as published by */ -/* the Free Software Foundation, either version 3 of the License, or */ -/* (at your option) any later version. */ -/* */ -/* This program is distributed in the hope that it will be useful, */ -/* but WITHOUT ANY WARRANTY; without even the implied warranty of */ -/* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the */ -/* GNU General Public License for more details. */ -/* */ -/* You should have received a copy of the GNU General Public License */ -/* along with this program. If not, see . */ -/* */ -/*******************************************************************************/ - -package com.github.shadowsocks - -import android.content._ -import android.os.Bundle -import android.support.design.widget.Snackbar -import android.support.v7.widget.RecyclerView.ViewHolder -import android.support.v7.widget._ -import android.support.v7.widget.helper.ItemTouchHelper -import android.support.v7.widget.helper.ItemTouchHelper.SimpleCallback -import android.view.View.OnLongClickListener -import android.view._ -import android.widget.{LinearLayout, PopupMenu, TextView, Toast} -import com.github.shadowsocks.ShadowsocksApplication.app -import com.github.shadowsocks.bg.{ServiceState, TrafficMonitor} -import com.github.shadowsocks.database.Profile -import com.github.shadowsocks.plugin.PluginConfiguration -import com.github.shadowsocks.utils._ -import com.github.shadowsocks.widget.UndoSnackbarManager -import com.google.android.gms.ads.{AdRequest, AdSize, AdView} - -import scala.collection.mutable.{ArrayBuffer, ListBuffer} - -object ProfilesFragment { - var instance: ProfilesFragment = _ // used for callback from ProfileManager and stateChanged from MainActivity -} - -final class ProfilesFragment extends ToolbarFragment with Toolbar.OnMenuItemClickListener { - import ProfilesFragment._ - - private val cardButtonLongClickListener: OnLongClickListener = view => { - Utils.positionToast(Toast.makeText(getActivity, view.getContentDescription, Toast.LENGTH_SHORT), view, - getActivity.getWindow, 0, getResources.getDimensionPixelOffset(R.dimen.margin_small)).show() - true - } - - /** - * Is ProfilesFragment editable at all. - */ - private def isEnabled = getActivity.asInstanceOf[MainActivity].state match { - case ServiceState.CONNECTED | ServiceState.STOPPED => true - case _ => false - } - private def isProfileEditable(id: => Int) = getActivity.asInstanceOf[MainActivity].state match { - case ServiceState.CONNECTED => id != app.dataStore.profileId - case ServiceState.STOPPED => true - case _ => false - } - - final class ProfileViewHolder(view: View) extends RecyclerView.ViewHolder(view) - with View.OnClickListener with PopupMenu.OnMenuItemClickListener { - - var item: Profile = _ - - private val text1 = itemView.findViewById[TextView](android.R.id.text1) - private val text2 = itemView.findViewById[TextView](android.R.id.text2) - private val traffic = itemView.findViewById[TextView](R.id.traffic) - private val edit = itemView.findViewById[View](R.id.edit) - edit.setOnClickListener(_ => startConfig(item.id)) - edit.setOnLongClickListener(cardButtonLongClickListener) - itemView.setOnClickListener(this) - - private var adView: AdView = _ - - { - val share = itemView.findViewById[View](R.id.share) - share.setOnClickListener(_ => { - val popup = new PopupMenu(getActivity, share) - popup.getMenuInflater.inflate(R.menu.profile_share_popup, popup.getMenu) - popup.setOnMenuItemClickListener(this) - popup.show() - }) - share.setOnLongClickListener(cardButtonLongClickListener) - } - - def bind(item: Profile) { - this.item = item - val editable = isProfileEditable(item.id) - edit.setEnabled(editable) - edit.setAlpha(if (editable) 1 else 0.5F) - var tx = item.tx - var rx = item.rx - if (item.id == bandwidthProfile) { - tx += txTotal - rx += rxTotal - } - text1.setText(item.getName) - val t2 = new ListBuffer[String] - if (!item.nameIsEmpty) t2 += item.formattedAddress - new PluginConfiguration(item.plugin).selected match { - case "" => - case id => t2 += app.getString(R.string.profile_plugin, id) - } - if (t2.isEmpty) text2.setVisibility(View.GONE) else { - text2.setVisibility(View.VISIBLE) - text2.setText(t2.mkString("\n")) - } - if (tx <= 0 && rx <= 0) traffic.setVisibility(View.GONE) else { - traffic.setVisibility(View.VISIBLE) - traffic.setText(getString(R.string.stat_profiles, - TrafficMonitor.formatTraffic(tx), TrafficMonitor.formatTraffic(rx))) - } - - if (item.id == app.dataStore.profileId) { - itemView.setSelected(true) - selectedItem = this - } else { - itemView.setSelected(false) - if (selectedItem eq this) selectedItem = null - } - - if (item.host == "198.199.101.152") { - if (adView == null) { - val params = - new LinearLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT) - params.gravity = Gravity.CENTER_HORIZONTAL - adView = new AdView(getActivity) - adView.setLayoutParams(params) - adView.setAdUnitId("ca-app-pub-9097031975646651/7760346322") - adView.setAdSize(AdSize.FLUID) - - itemView.findViewById[LinearLayout](R.id.content).addView(adView) - - // Load Ad - val adBuilder = new AdRequest.Builder() - adBuilder.addTestDevice("B08FC1764A7B250E91EA9D0D5EBEB208") - adView.loadAd(adBuilder.build()) - } else adView.setVisibility(View.VISIBLE) - } else if (adView != null) adView.setVisibility(View.GONE) - } - - def onClick(v: View): Unit = if (isEnabled) { - val activity = getActivity.asInstanceOf[MainActivity] - val old = app.dataStore.profileId - app.switchProfile(item.id) - profilesAdapter.refreshId(old) - itemView.setSelected(true) - if (activity.state == ServiceState.CONNECTED) Utils.reloadSsService(activity) - } - - override def onMenuItemClick(menu: MenuItem): Boolean = menu.getItemId match { - case R.id.action_qr_code_nfc => - getFragmentManager.beginTransaction().add(new QRCodeDialog(item.toString), "").commitAllowingStateLoss() - true - case R.id.action_export => - clipboard.setPrimaryClip(ClipData.newPlainText(null, item.toString)) - true - case _ => false - } - } - - final class ProfilesAdapter extends RecyclerView.Adapter[ProfileViewHolder] { - var profiles = new ArrayBuffer[Profile] - profiles ++= app.profileManager.getAllProfiles.getOrElse(List.empty[Profile]) - - def getItemCount: Int = profiles.length - - def onBindViewHolder(vh: ProfileViewHolder, i: Int): Unit = vh.bind(profiles(i)) - - def onCreateViewHolder(vg: ViewGroup, i: Int) = - new ProfileViewHolder(LayoutInflater.from(vg.getContext).inflate(R.layout.layout_profile, vg, false)) - - setHasStableIds(true) // Reason: http://stackoverflow.com/a/32488059/2245107 - override def getItemId(position: Int): Long = profiles(position).id - - def add(item: Profile) { - undoManager.flush() - val pos = getItemCount - profiles += item - notifyItemInserted(pos) - } - - def move(from: Int, to: Int) { - undoManager.flush() - val step = if (from < to) 1 else -1 - val first = profiles(from) - var previousOrder = profiles(from).userOrder - for (i <- from until to by step) { - val next = profiles(i + step) - val order = next.userOrder - next.userOrder = previousOrder - previousOrder = order - profiles(i) = next - app.profileManager.updateProfile(next) - } - first.userOrder = previousOrder - profiles(to) = first - app.profileManager.updateProfile(first) - notifyItemMoved(from, to) - } - - def remove(pos: Int) { - profiles.remove(pos) - notifyItemRemoved(pos) - } - def undo(actions: Iterator[(Int, Profile)]): Unit = for ((index, item) <- actions) { - profiles.insert(index, item) - notifyItemInserted(index) - } - def commit(actions: Iterator[(Int, Profile)]): Unit = - for ((_, item) <- actions) app.profileManager.delProfile(item.id) - - def refreshId(id: Int) { - val index = profiles.indexWhere(_.id == id) - if (index >= 0) notifyItemChanged(index) - } - def deepRefreshId(id: Int) { - val index = profiles.indexWhere(_.id == id) - if (index >= 0) { - profiles(index) = app.profileManager.getProfile(id).get - notifyItemChanged(index) - } - } - def removeId(id: Int) { - val index = profiles.indexWhere(_.id == id) - if (index >= 0) { - profiles.remove(index) - notifyItemRemoved(index) - if (id == app.dataStore.profileId) app.dataStore.profileId = 0 // switch to null profile - } - } - } - - private var selectedItem: ProfileViewHolder = _ - - lazy val profilesAdapter = new ProfilesAdapter - private var undoManager: UndoSnackbarManager[Profile] = _ - private var bandwidthProfile: Int = _ - private var txTotal: Long = _ - private var rxTotal: Long = _ - - private lazy val clipboard = getActivity.getSystemService(Context.CLIPBOARD_SERVICE).asInstanceOf[ClipboardManager] - - private def startConfig(id: Int) = startActivity(new Intent(getActivity, classOf[ProfileConfigActivity]) - .putExtra(Action.EXTRA_PROFILE_ID, id)) - - override def onCreateView(inflater: LayoutInflater, container: ViewGroup, savedInstanceState: Bundle): View = - inflater.inflate(R.layout.layout_list, container, false) - - override def onViewCreated(view: View, savedInstanceState: Bundle) { - super.onViewCreated(view, savedInstanceState) - toolbar.setTitle(R.string.profiles) - toolbar.inflateMenu(R.menu.profile_manager_menu) - toolbar.setOnMenuItemClickListener(this) - - if (app.profileManager.getFirstProfile.isEmpty) app.dataStore.profileId = app.profileManager.createProfile().id - val profilesList = view.findViewById[RecyclerView](R.id.list) - val layoutManager = new LinearLayoutManager(getActivity, LinearLayoutManager.VERTICAL, false) - profilesList.setLayoutManager(layoutManager) - profilesList.addItemDecoration(new DividerItemDecoration(getActivity, layoutManager.getOrientation)) - layoutManager.scrollToPosition(profilesAdapter.profiles.zipWithIndex.collectFirst { - case (profile, i) if profile.id == app.dataStore.profileId => i - }.getOrElse(-1)) - val animator = new DefaultItemAnimator() - animator.setSupportsChangeAnimations(false) // prevent fading-in/out when rebinding - profilesList.setItemAnimator(animator) - profilesList.setAdapter(profilesAdapter) - instance = this - undoManager = new UndoSnackbarManager[Profile](getActivity.findViewById(R.id.snackbar), - profilesAdapter.undo, profilesAdapter.commit) - new ItemTouchHelper(new SimpleCallback(ItemTouchHelper.UP | ItemTouchHelper.DOWN, - ItemTouchHelper.START | ItemTouchHelper.END) { - override def getSwipeDirs(recyclerView: RecyclerView, viewHolder: ViewHolder): Int = - if (isProfileEditable(viewHolder.asInstanceOf[ProfileViewHolder].item.id)) - super.getSwipeDirs(recyclerView, viewHolder) else 0 - override def getDragDirs(recyclerView: RecyclerView, viewHolder: ViewHolder): Int = - if (isEnabled) super.getDragDirs(recyclerView, viewHolder) else 0 - - def onSwiped(viewHolder: ViewHolder, direction: Int) { - val index = viewHolder.getAdapterPosition - profilesAdapter.remove(index) - undoManager.remove((index, viewHolder.asInstanceOf[ProfileViewHolder].item)) - } - def onMove(recyclerView: RecyclerView, viewHolder: ViewHolder, target: ViewHolder): Boolean = { - profilesAdapter.move(viewHolder.getAdapterPosition, target.getAdapterPosition) - true - } - }).attachToRecyclerView(profilesList) - } - - override def onTrafficUpdated(profileId: Int, txRate: Long, rxRate: Long, txTotal: Long, rxTotal: Long): Unit = - if (profileId != -1) { // ignore resets from MainActivity - if (bandwidthProfile != profileId) { - onTrafficPersisted(bandwidthProfile) - bandwidthProfile = profileId - } - this.txTotal = txTotal - this.rxTotal = rxTotal - profilesAdapter.refreshId(profileId) - } - def onTrafficPersisted(profileId: Int) { - txTotal = 0 - rxTotal = 0 - if (bandwidthProfile != profileId) { - onTrafficPersisted(bandwidthProfile) - bandwidthProfile = profileId - } - profilesAdapter.deepRefreshId(profileId) - } - - override def onDetach() { - undoManager.flush() - super.onDetach() - } - - override def onDestroy() { - instance = null - super.onDestroy() - } - - def onMenuItemClick(item: MenuItem): Boolean = item.getItemId match { - case R.id.action_scan_qr_code => - startActivity(new Intent(getActivity, classOf[ScannerActivity])) - true - case R.id.action_import => - try { - val profiles = Parser.findAll(clipboard.getPrimaryClip.getItemAt(0).getText) - if (profiles.nonEmpty) { - profiles.foreach(app.profileManager.createProfile) - Toast.makeText(getActivity, R.string.action_import_msg, Toast.LENGTH_SHORT).show() - return true - } - } catch { - case _: Exception => - } - Snackbar.make(getActivity.findViewById(R.id.snackbar), R.string.action_import_err, Snackbar.LENGTH_LONG).show() - true - case R.id.action_manual_settings => - startConfig(app.profileManager.createProfile().id) - true - case R.id.action_export => - app.profileManager.getAllProfiles match { - case Some(profiles) => - clipboard.setPrimaryClip(ClipData.newPlainText(null, profiles.mkString("\n"))) - Toast.makeText(getActivity, R.string.action_export_msg, Toast.LENGTH_SHORT).show() - case _ => Toast.makeText(getActivity, R.string.action_export_err, Toast.LENGTH_SHORT).show() - } - true - case _ => false - } -} diff --git a/mobile/src/main/scala/com/github/shadowsocks/QRCodeDialog.scala b/mobile/src/main/scala/com/github/shadowsocks/QRCodeDialog.scala deleted file mode 100644 index 8cc69de4..00000000 --- a/mobile/src/main/scala/com/github/shadowsocks/QRCodeDialog.scala +++ /dev/null @@ -1,77 +0,0 @@ -/*******************************************************************************/ -/* */ -/* Copyright (C) 2017 by Max Lv */ -/* Copyright (C) 2017 by Mygod Studio */ -/* */ -/* This program is free software: you can redistribute it and/or modify */ -/* it under the terms of the GNU General Public License as published by */ -/* the Free Software Foundation, either version 3 of the License, or */ -/* (at your option) any later version. */ -/* */ -/* This program is distributed in the hope that it will be useful, */ -/* but WITHOUT ANY WARRANTY; without even the implied warranty of */ -/* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the */ -/* GNU General Public License for more details. */ -/* */ -/* You should have received a copy of the GNU General Public License */ -/* along with this program. If not, see . */ -/* */ -/*******************************************************************************/ - -package com.github.shadowsocks - -import java.nio.charset.Charset - -import android.app.Activity -import android.content.Context -import android.nfc.{NdefMessage, NdefRecord, NfcAdapter} -import android.os.Bundle -import android.support.v4.app.DialogFragment -import android.view.{LayoutInflater, View, ViewGroup} -import android.widget.{ImageView, LinearLayout} -import net.glxn.qrgen.android.QRCode - -object QRCodeDialog { - private final val KEY_URL = "com.github.shadowsocks.QRCodeDialog.KEY_URL" -} - -final class QRCodeDialog extends DialogFragment { - import QRCodeDialog._ - def this(url: String) { - this() - val bundle = new Bundle() - bundle.putString(KEY_URL, url) - setArguments(bundle) - } - private def url = getArguments.getString(KEY_URL) - - private lazy val nfcShareItem = url.getBytes(Charset.forName("UTF-8")) - private var adapter: NfcAdapter = _ - - override def onCreateView(inflater: LayoutInflater, container: ViewGroup, savedInstanceState: Bundle): View = { - val image = new ImageView(getActivity) - image.setLayoutParams(new LinearLayout.LayoutParams(-1, -1)) - val size = getResources.getDimensionPixelSize(R.dimen.qr_code_size) - val qrcode = QRCode.from(url) - .withSize(size, size) - .asInstanceOf[QRCode].bitmap() - image.setImageBitmap(qrcode) - image - } - - override def onAttach(context: Context) { - super.onAttach(context) - adapter = NfcAdapter.getDefaultAdapter(getActivity) - if (adapter != null) adapter.setNdefPushMessage(new NdefMessage(Array( - new NdefRecord(NdefRecord.TNF_ABSOLUTE_URI, nfcShareItem, Array[Byte](), nfcShareItem))), - context.asInstanceOf[Activity]) - } - - override def onDetach() { - if (adapter != null) { - adapter.setNdefPushMessage(null, getActivity) - adapter = null - } - super.onDetach() - } -} diff --git a/mobile/src/main/scala/com/github/shadowsocks/QuickToggleShortcut.scala b/mobile/src/main/scala/com/github/shadowsocks/QuickToggleShortcut.scala deleted file mode 100644 index e631fe9e..00000000 --- a/mobile/src/main/scala/com/github/shadowsocks/QuickToggleShortcut.scala +++ /dev/null @@ -1,67 +0,0 @@ -/*******************************************************************************/ -/* */ -/* Copyright (C) 2017 by Max Lv */ -/* Copyright (C) 2017 by Mygod Studio */ -/* */ -/* This program is free software: you can redistribute it and/or modify */ -/* it under the terms of the GNU General Public License as published by */ -/* the Free Software Foundation, either version 3 of the License, or */ -/* (at your option) any later version. */ -/* */ -/* This program is distributed in the hope that it will be useful, */ -/* but WITHOUT ANY WARRANTY; without even the implied warranty of */ -/* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the */ -/* GNU General Public License for more details. */ -/* */ -/* You should have received a copy of the GNU General Public License */ -/* along with this program. If not, see . */ -/* */ -/*******************************************************************************/ - -package com.github.shadowsocks - -import android.app.Activity -import android.content.Intent -import android.content.pm.ShortcutManager -import android.os.{Build, Bundle} -import android.support.v4.content.pm.{ShortcutInfoCompat, ShortcutManagerCompat} -import android.support.v4.graphics.drawable.IconCompat -import android.support.v7.app.AppCompatActivity -import com.github.shadowsocks.bg.ServiceState -import com.github.shadowsocks.utils.Utils - -/** - * @author Mygod - */ -class QuickToggleShortcut extends AppCompatActivity with ServiceBoundContext { - override def onCreate(savedInstanceState: Bundle) { - super.onCreate(savedInstanceState) - getIntent.getAction match { - case Intent.ACTION_CREATE_SHORTCUT => - setResult(Activity.RESULT_OK, ShortcutManagerCompat.createShortcutResultIntent(this, - new ShortcutInfoCompat.Builder(this, "toggle") - .setIntent(new Intent(this, classOf[QuickToggleShortcut]).setAction(Intent.ACTION_MAIN)) - .setIcon(IconCompat.createWithResource(this, R.drawable.ic_qu_shadowsocks_launcher)) - .setShortLabel(getString(R.string.quick_toggle)) - .build())) - finish() - case _ => - attachService() - if (Build.VERSION.SDK_INT >= 25) getSystemService(classOf[ShortcutManager]).reportShortcutUsed("toggle") - } - } - - override def onDestroy() { - detachService() - super.onDestroy() - } - - override def onServiceConnected() { - bgService.getState match { - case ServiceState.STOPPED => Utils.startSsService(this) - case ServiceState.CONNECTED => Utils.stopSsService(this) - case _ => // ignore - } - finish() - } -} diff --git a/mobile/src/main/scala/com/github/shadowsocks/ScannerActivity.scala b/mobile/src/main/scala/com/github/shadowsocks/ScannerActivity.scala deleted file mode 100644 index 3cfb3cad..00000000 --- a/mobile/src/main/scala/com/github/shadowsocks/ScannerActivity.scala +++ /dev/null @@ -1,104 +0,0 @@ -/*******************************************************************************/ -/* */ -/* Copyright (C) 2016 by Max Lv */ -/* Copyright (C) 2016 by Mygod Studio */ -/* */ -/* This program is free software: you can redistribute it and/or modify */ -/* it under the terms of the GNU General Public License as published by */ -/* the Free Software Foundation, either version 3 of the License, or */ -/* (at your option) any later version. */ -/* */ -/* This program is distributed in the hope that it will be useful, */ -/* but WITHOUT ANY WARRANTY; without even the implied warranty of */ -/* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the */ -/* GNU General Public License for more details. */ -/* */ -/* You should have received a copy of the GNU General Public License */ -/* along with this program. If not, see . */ -/* */ -/*******************************************************************************/ - -package com.github.shadowsocks - -import android.app.TaskStackBuilder -import android.content.pm.{PackageManager, ShortcutManager} -import android.os.{Build, Bundle} -import android.support.v4.app.ActivityCompat -import android.support.v4.content.ContextCompat -import android.support.v7.app.AppCompatActivity -import android.support.v7.widget.Toolbar -import android.text.TextUtils -import android.widget.Toast -import com.github.shadowsocks.ShadowsocksApplication.app -import com.github.shadowsocks.utils.Parser -import com.google.zxing.Result -import me.dm7.barcodescanner.zxing.ZXingScannerView - -object ScannerActivity { - private final val MY_PERMISSIONS_REQUEST_CAMERA = 1 -} - -class ScannerActivity extends AppCompatActivity with ZXingScannerView.ResultHandler { - import ScannerActivity._ - - private var scannerView: ZXingScannerView = _ - - override def onRequestPermissionsResult(requestCode: Int, permissions: Array[String], - grantResults: Array[Int]) { - if (requestCode == MY_PERMISSIONS_REQUEST_CAMERA) { - // If request is cancelled, the result arrays are empty. - if (grantResults.length > 0 - && grantResults(0) == PackageManager.PERMISSION_GRANTED) { - scannerView.setResultHandler(this) - scannerView.startCamera() - } else { - Toast.makeText(this, R.string.add_profile_scanner_permission_required, Toast.LENGTH_SHORT).show() - finish() - } - } - } - - def navigateUp() { - val intent = getParentActivityIntent - if (shouldUpRecreateTask(intent) || isTaskRoot) - TaskStackBuilder.create(this).addNextIntentWithParentStack(intent).startActivities() - else finish() - } - - override def onCreate(state: Bundle) { - super.onCreate(state) - setContentView(R.layout.layout_scanner) - val toolbar = findViewById(R.id.toolbar).asInstanceOf[Toolbar] - toolbar.setTitle(getTitle) - toolbar.setNavigationIcon(R.drawable.abc_ic_ab_back_material) - toolbar.setNavigationOnClickListener(_ => navigateUp()) - scannerView = findViewById(R.id.scanner).asInstanceOf[ZXingScannerView] - if (Build.VERSION.SDK_INT >= 25) getSystemService(classOf[ShortcutManager]).reportShortcutUsed("scan") - } - - override def onResume() { - super.onResume() - val permissionCheck = ContextCompat.checkSelfPermission(this, - android.Manifest.permission.CAMERA) - if (permissionCheck == PackageManager.PERMISSION_GRANTED) { - scannerView.setResultHandler(this) // Register ourselves as a handler for scan results. - scannerView.startCamera() // Start camera on resume - } else { - ActivityCompat.requestPermissions(this, - Array(android.Manifest.permission.CAMERA), MY_PERMISSIONS_REQUEST_CAMERA) - } - } - - override def onPause() { - super.onPause() - scannerView.stopCamera() // Stop camera on pause - } - - override def handleResult(rawResult: Result) = { - val uri = rawResult.getText - if (!TextUtils.isEmpty(uri)) - Parser.findAll(uri).foreach(app.profileManager.createProfile) - navigateUp() - } -} - diff --git a/mobile/src/main/scala/com/github/shadowsocks/ServiceBoundContext.scala b/mobile/src/main/scala/com/github/shadowsocks/ServiceBoundContext.scala deleted file mode 100644 index 4a7fe87f..00000000 --- a/mobile/src/main/scala/com/github/shadowsocks/ServiceBoundContext.scala +++ /dev/null @@ -1,112 +0,0 @@ -/*******************************************************************************/ -/* */ -/* Copyright (C) 2017 by Max Lv */ -/* Copyright (C) 2017 by Mygod Studio */ -/* */ -/* This program is free software: you can redistribute it and/or modify */ -/* it under the terms of the GNU General Public License as published by */ -/* the Free Software Foundation, either version 3 of the License, or */ -/* (at your option) any later version. */ -/* */ -/* This program is distributed in the hope that it will be useful, */ -/* but WITHOUT ANY WARRANTY; without even the implied warranty of */ -/* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the */ -/* GNU General Public License for more details. */ -/* */ -/* You should have received a copy of the GNU General Public License */ -/* along with this program. If not, see . */ -/* */ -/*******************************************************************************/ - -package com.github.shadowsocks - -import android.content.{ComponentName, Context, Intent, ServiceConnection} -import android.os.{IBinder, RemoteException} -import com.github.shadowsocks.aidl.{IShadowsocksService, IShadowsocksServiceCallback} -import com.github.shadowsocks.utils.Action -import com.github.shadowsocks.ShadowsocksApplication.app -import com.github.shadowsocks.bg.{TransproxyService, VpnService} - -/** - * @author Mygod - */ -trait ServiceBoundContext extends Context with IBinder.DeathRecipient { - private class ShadowsocksServiceConnection extends ServiceConnection { - override def onServiceConnected(name: ComponentName, service: IBinder) { - binder = service - service.linkToDeath(ServiceBoundContext.this, 0) - bgService = IShadowsocksService.Stub.asInterface(service) - if (callback != null && !callbackRegistered) try { - bgService.registerCallback(callback) - callbackRegistered = true - if (listeningForBandwidth) bgService.startListeningForBandwidth(callback) - } catch { - case _: RemoteException => // Nothing - } - ServiceBoundContext.this.onServiceConnected() - } - override def onServiceDisconnected(name: ComponentName) { - unregisterCallback() - ServiceBoundContext.this.onServiceDisconnected() - bgService = null - binder = null - } - } - - protected def setListeningForBandwidth(value: Boolean) { - if (listeningForBandwidth != value && bgService != null && callback != null) - if (value) bgService.startListeningForBandwidth(callback) else bgService.stopListeningForBandwidth(callback) - listeningForBandwidth = value - } - - private def unregisterCallback() { - if (bgService != null && callback != null && callbackRegistered) try bgService.unregisterCallback(callback) catch { - case _: RemoteException => - } - callbackRegistered = false - } - - protected def onServiceConnected(): Unit = () - /** - * Different from Android framework, this method will be called even when you call `detachService`. - */ - protected def onServiceDisconnected(): Unit = () - override def binderDied(): Unit = () - - private var callback: IShadowsocksServiceCallback.Stub = _ - private var connection: ShadowsocksServiceConnection = _ - private var callbackRegistered: Boolean = _ - private var listeningForBandwidth: Boolean = _ - - // Variables - private var binder: IBinder = _ - var bgService: IShadowsocksService = _ - - protected def attachService(callback: IShadowsocksServiceCallback.Stub = null) { - this.callback = callback - if (bgService == null) { - val intent = new Intent(this, app.serviceClass) - intent.setAction(Action.SERVICE) - - connection = new ShadowsocksServiceConnection() - bindService(intent, connection, Context.BIND_AUTO_CREATE) - } - } - - protected def detachService() { - unregisterCallback() - onServiceDisconnected() - callback = null - if (connection != null) { - try unbindService(connection) catch { - case _: IllegalArgumentException => // ignore - } - connection = null - } - if (binder != null) { - binder.unlinkToDeath(this, 0) - binder = null - } - bgService = null - } -} diff --git a/mobile/src/main/scala/com/github/shadowsocks/ShadowsocksApplication.scala b/mobile/src/main/scala/com/github/shadowsocks/ShadowsocksApplication.scala deleted file mode 100644 index a694de81..00000000 --- a/mobile/src/main/scala/com/github/shadowsocks/ShadowsocksApplication.scala +++ /dev/null @@ -1,208 +0,0 @@ -/*******************************************************************************/ -/* */ -/* Copyright (C) 2017 by Max Lv */ -/* Copyright (C) 2017 by Mygod Studio */ -/* */ -/* This program is free software: you can redistribute it and/or modify */ -/* it under the terms of the GNU General Public License as published by */ -/* the Free Software Foundation, either version 3 of the License, or */ -/* (at your option) any later version. */ -/* */ -/* This program is distributed in the hope that it will be useful, */ -/* but WITHOUT ANY WARRANTY; without even the implied warranty of */ -/* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the */ -/* GNU General Public License for more details. */ -/* */ -/* You should have received a copy of the GNU General Public License */ -/* along with this program. If not, see . */ -/* */ -/*******************************************************************************/ - -package com.github.shadowsocks - -import java.io.{File, FileOutputStream, IOException} -import java.util.Locale - -import android.annotation.SuppressLint -import android.app.{Application, NotificationChannel, NotificationManager} -import android.content._ -import android.content.pm.{PackageInfo, PackageManager} -import android.content.res.Configuration -import android.os.{Build, LocaleList} -import android.support.v7.app.AppCompatDelegate -import android.util.Log -import com.evernote.android.job.JobManager -import com.github.shadowsocks.acl.DonaldTrump -import com.github.shadowsocks.bg.{ProxyService, TransproxyService, VpnService} -import com.github.shadowsocks.database.{DBHelper, Profile, ProfileManager} -import com.github.shadowsocks.preference.{BottomSheetPreferenceDialogFragment, IconListPreference, OrmLitePreferenceDataStore} -import com.github.shadowsocks.utils.CloseUtils._ -import com.github.shadowsocks.utils._ -import com.google.android.gms.analytics.{GoogleAnalytics, HitBuilders, StandardExceptionParser, Tracker} -import com.google.firebase.FirebaseApp -import com.google.firebase.remoteconfig.FirebaseRemoteConfig -import com.j256.ormlite.logger.LocalLog -import com.takisoft.fix.support.v7.preference.PreferenceFragmentCompat - -import scala.collection.JavaConversions._ - -object ShadowsocksApplication { - var app: ShadowsocksApplication = _ - - private final val TAG = "ShadowsocksApplication" - - // The ones in Locale doesn't have script included - private final lazy val SIMPLIFIED_CHINESE = - if (Build.VERSION.SDK_INT >= 21) Locale.forLanguageTag("zh-Hans-CN") else Locale.SIMPLIFIED_CHINESE - private final lazy val TRADITIONAL_CHINESE = - if (Build.VERSION.SDK_INT >= 21) Locale.forLanguageTag("zh-Hant-TW") else Locale.TRADITIONAL_CHINESE -} - -class ShadowsocksApplication extends Application { - import ShadowsocksApplication._ - - lazy val remoteConfig: FirebaseRemoteConfig = FirebaseRemoteConfig.getInstance() - lazy val tracker: Tracker = GoogleAnalytics.getInstance(this).newTracker(R.xml.tracker) - private lazy val dbHelper = new DBHelper(this) - lazy val profileManager = new ProfileManager(dbHelper) - lazy val dataStore = new OrmLitePreferenceDataStore(dbHelper) - - def usingVpnMode: Boolean = dataStore.serviceMode == Key.modeVpn - def serviceClass: Class[_] = app.dataStore.serviceMode match { - case Key.modeProxy => classOf[ProxyService] - case Key.modeVpn => classOf[VpnService] - case Key.modeTransproxy => classOf[TransproxyService] - } - - // send event - def track(category: String, action: String): Unit = tracker.send(new HitBuilders.EventBuilder() - .setAction(action) - .setLabel(BuildConfig.VERSION_NAME) - .build()) - def track(t: Throwable): Unit = tracker.send(new HitBuilders.ExceptionBuilder() - .setDescription(new StandardExceptionParser(this, null).getDescription(Thread.currentThread.getName, t)) - .setFatal(false) - .build()) - - def currentProfile: Option[Profile] = profileManager.getProfile(dataStore.profileId) - - def switchProfile(id: Int): Profile = { - val result = profileManager.getProfile(id) getOrElse profileManager.createProfile() - dataStore.profileId = result.id - result - } - - private def checkChineseLocale(locale: Locale): Locale = if (locale.getLanguage == "zh") locale.getCountry match { - case "CN" | "TW" => null - case _ => (if (Build.VERSION.SDK_INT >= 21) locale.getScript else null) match { - case "Hans" => SIMPLIFIED_CHINESE - case "Hant" => TRADITIONAL_CHINESE - case script => - Log.w(TAG, "Unknown zh locale script: %s. Falling back to trying countries...".format(script)) - locale.getCountry match { - case "SG" => SIMPLIFIED_CHINESE - case "HK" | "MO" => TRADITIONAL_CHINESE - case _ => - Log.w(TAG, "Unknown zh locale: %s. Falling back to zh-Hans-CN..." - .formatLocal(Locale.ENGLISH, if (Build.VERSION.SDK_INT >= 21) locale.toLanguageTag else locale)) - SIMPLIFIED_CHINESE - } - } - } else null - - @SuppressLint(Array("NewApi")) - private def checkChineseLocale(config: Configuration): Unit = if (Build.VERSION.SDK_INT >= 24) { - val localeList = config.getLocales - val newList = new Array[Locale](localeList.size()) - var changed = false - for (i <- 0 until localeList.size()) { - val locale = localeList.get(i) - val newLocale = checkChineseLocale(locale) - if (newLocale == null) newList(i) = locale else { - newList(i) = newLocale - changed = true - } - } - if (changed) { - val newConfig = new Configuration(config) - newConfig.setLocales(new LocaleList(newList.distinct: _*)) - val res = getResources - res.updateConfiguration(newConfig, res.getDisplayMetrics) - } - } else { - //noinspection ScalaDeprecation - val newLocale = checkChineseLocale(config.locale) - if (newLocale != null) { - val newConfig = new Configuration(config) - //noinspection ScalaDeprecation - newConfig.locale = newLocale - val res = getResources - res.updateConfiguration(newConfig, res.getDisplayMetrics) - } - } - - override def onConfigurationChanged(newConfig: Configuration) { - super.onConfigurationChanged(newConfig) - checkChineseLocale(newConfig) - } - - override def onCreate() { - app = this - if (!BuildConfig.DEBUG) java.lang.System.setProperty(LocalLog.LOCAL_LOG_LEVEL_PROPERTY, "ERROR") - AppCompatDelegate.setCompatVectorFromResourcesEnabled(true) - checkChineseLocale(getResources.getConfiguration) - - FirebaseApp.initializeApp(this) - remoteConfig.setDefaults(R.xml.default_configs) - remoteConfig.fetch().addOnCompleteListener(task => - if (task.isSuccessful) remoteConfig.activateFetched() else Log.e(TAG, "Failed to fetch config")) - - JobManager.create(this).addJobCreator(DonaldTrump) - PreferenceFragmentCompat.registerPreferenceFragment(classOf[IconListPreference], - classOf[BottomSheetPreferenceDialogFragment]) - - TcpFastOpen.enabled(dataStore.getBoolean(Key.tfo, TcpFastOpen.sendEnabled)) - - if (dataStore.getLong(Key.assetUpdateTime, -1) != info.lastUpdateTime) copyAssets() - - if (Build.VERSION.SDK_INT >= 26) { - val nm = getSystemService(classOf[NotificationManager]) - nm.createNotificationChannels(List( - new NotificationChannel("service-vpn", getText(R.string.service_vpn), NotificationManager.IMPORTANCE_LOW), - new NotificationChannel("service-proxy", getText(R.string.service_proxy), NotificationManager.IMPORTANCE_LOW), - new NotificationChannel("service-transproxy", getText(R.string.service_transproxy), - NotificationManager.IMPORTANCE_LOW) - )) - nm.deleteNotificationChannel("service-nat") // NAT mode is gone for good - } - } - - lazy val info: PackageInfo = getPackageManager.getPackageInfo(getPackageName, PackageManager.GET_SIGNATURES) - - def copyAssets() { - val assetManager = getAssets - for (dir <- Array("acl", "overture")) { - var files: Array[String] = null - try files = assetManager.list(dir) catch { - case e: IOException => - Log.e(TAG, e.getMessage) - app.track(e) - } - if (files != null) for (file <- files) autoClose(assetManager.open(dir + "/" + file))(in => - autoClose(new FileOutputStream(new File(getFilesDir, file)))(out => - IOUtils.copy(in, out))) - } - dataStore.putLong(Key.assetUpdateTime, info.lastUpdateTime) - } - - def listenForPackageChanges(callback: => Unit): BroadcastReceiver = { - val filter = new IntentFilter(Intent.ACTION_PACKAGE_ADDED) - filter.addAction(Intent.ACTION_PACKAGE_REMOVED) - filter.addDataScheme("package") - val result: BroadcastReceiver = (_: Context, intent: Intent) => - if (intent.getAction != Intent.ACTION_PACKAGE_REMOVED || !intent.getBooleanExtra(Intent.EXTRA_REPLACING, false)) - callback - app.registerReceiver(result, filter) - result - } -} diff --git a/mobile/src/main/scala/com/github/shadowsocks/ShadowsocksBackupAgent.scala b/mobile/src/main/scala/com/github/shadowsocks/ShadowsocksBackupAgent.scala deleted file mode 100644 index 64441b6e..00000000 --- a/mobile/src/main/scala/com/github/shadowsocks/ShadowsocksBackupAgent.scala +++ /dev/null @@ -1,30 +0,0 @@ -/*******************************************************************************/ -/* */ -/* Copyright (C) 2017 by Max Lv */ -/* Copyright (C) 2017 by Mygod Studio */ -/* */ -/* This program is free software: you can redistribute it and/or modify */ -/* it under the terms of the GNU General Public License as published by */ -/* the Free Software Foundation, either version 3 of the License, or */ -/* (at your option) any later version. */ -/* */ -/* This program is distributed in the hope that it will be useful, */ -/* but WITHOUT ANY WARRANTY; without even the implied warranty of */ -/* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the */ -/* GNU General Public License for more details. */ -/* */ -/* You should have received a copy of the GNU General Public License */ -/* along with this program. If not, see . */ -/* */ -/*******************************************************************************/ - -package com.github.shadowsocks - -import android.app.backup.{BackupAgentHelper, FileBackupHelper} -import com.github.shadowsocks.acl.Acl -import com.github.shadowsocks.database.DBHelper - -class ShadowsocksBackupAgent extends BackupAgentHelper { - override def onCreate(): Unit = addHelper("com.github.shadowsocks.database.profile", - new FileBackupHelper(this, "../databases/" + DBHelper.PROFILE, Acl.CUSTOM_RULES + ".acl")) -} diff --git a/mobile/src/main/scala/com/github/shadowsocks/ShadowsocksRunnerActivity.scala b/mobile/src/main/scala/com/github/shadowsocks/ShadowsocksRunnerActivity.scala deleted file mode 100644 index d37d4d9b..00000000 --- a/mobile/src/main/scala/com/github/shadowsocks/ShadowsocksRunnerActivity.scala +++ /dev/null @@ -1,92 +0,0 @@ -/*******************************************************************************/ -/* */ -/* Copyright (C) 2017 by Max Lv */ -/* Copyright (C) 2017 by Mygod Studio */ -/* */ -/* This program is free software: you can redistribute it and/or modify */ -/* it under the terms of the GNU General Public License as published by */ -/* the Free Software Foundation, either version 3 of the License, or */ -/* (at your option) any later version. */ -/* */ -/* This program is distributed in the hope that it will be useful, */ -/* but WITHOUT ANY WARRANTY; without even the implied warranty of */ -/* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the */ -/* GNU General Public License for more details. */ -/* */ -/* You should have received a copy of the GNU General Public License */ -/* along with this program. If not, see . */ -/* */ -/*******************************************************************************/ - -package com.github.shadowsocks - -import android.app.{Activity, KeyguardManager} -import android.content.{BroadcastReceiver, Context, Intent, IntentFilter} -import android.net.VpnService -import android.os.{Bundle, Handler} -import android.util.Log -import com.github.shadowsocks.ShadowsocksApplication.app -import com.github.shadowsocks.utils.Utils - -object ShadowsocksRunnerActivity { - private final val TAG = "ShadowsocksRunnerActivity" - private final val REQUEST_CONNECT = 1 -} - -class ShadowsocksRunnerActivity extends Activity with ServiceBoundContext { - import ShadowsocksRunnerActivity._ - - val handler = new Handler() - - // Variables - var receiver: BroadcastReceiver = _ - - override def onServiceConnected() { - handler.postDelayed(() => if (bgService != null) startBackgroundService(), 1000) - } - - def startBackgroundService() { - if (app.usingVpnMode) VpnService.prepare(ShadowsocksRunnerActivity.this) match { - case null => onActivityResult(REQUEST_CONNECT, Activity.RESULT_OK, null) - case intent => startActivityForResult(intent, REQUEST_CONNECT) - } else { - Utils.startSsService(this) - finish() - } - } - - override def onCreate(savedInstanceState: Bundle) { - super.onCreate(savedInstanceState) - val km = getSystemService(Context.KEYGUARD_SERVICE).asInstanceOf[KeyguardManager] - val locked = km.inKeyguardRestrictedInputMode - if (locked) { - val filter = new IntentFilter(Intent.ACTION_USER_PRESENT) - receiver = (_: Context, intent: Intent) => { - if (intent.getAction == Intent.ACTION_USER_PRESENT) { - attachService() - } - } - registerReceiver(receiver, filter) - } else { - attachService() - } - } - - override def onDestroy() { - super.onDestroy() - detachService() - if (receiver != null) { - unregisterReceiver(receiver) - receiver = null - } - } - - override def onActivityResult(requestCode: Int, resultCode: Int, data: Intent) { - resultCode match { - case Activity.RESULT_OK => Utils.startSsService(this) - case _ => - Log.e(TAG, "Failed to start VpnService") - } - finish() - } -} diff --git a/mobile/src/main/scala/com/github/shadowsocks/TaskerActivity.scala b/mobile/src/main/scala/com/github/shadowsocks/TaskerActivity.scala deleted file mode 100644 index 733ae3e7..00000000 --- a/mobile/src/main/scala/com/github/shadowsocks/TaskerActivity.scala +++ /dev/null @@ -1,109 +0,0 @@ -/*******************************************************************************/ -/* */ -/* Copyright (C) 2017 by Max Lv */ -/* Copyright (C) 2017 by Mygod Studio */ -/* */ -/* This program is free software: you can redistribute it and/or modify */ -/* it under the terms of the GNU General Public License as published by */ -/* the Free Software Foundation, either version 3 of the License, or */ -/* (at your option) any later version. */ -/* */ -/* This program is distributed in the hope that it will be useful, */ -/* but WITHOUT ANY WARRANTY; without even the implied warranty of */ -/* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the */ -/* GNU General Public License for more details. */ -/* */ -/* You should have received a copy of the GNU General Public License */ -/* along with this program. If not, see . */ -/* */ -/*******************************************************************************/ - -package com.github.shadowsocks - -import android.app.Activity -import android.content.res.Resources -import android.os.{Build, Bundle} -import android.support.v7.app.AppCompatActivity -import android.support.v7.widget.{DefaultItemAnimator, LinearLayoutManager, RecyclerView, Toolbar} -import android.view.{LayoutInflater, View, ViewGroup} -import android.widget.{CheckedTextView, Switch} -import com.github.shadowsocks.database.Profile -import com.github.shadowsocks.utils.TaskerSettings -import com.github.shadowsocks.ShadowsocksApplication.app - -/** - * @author CzBiX - */ -class TaskerActivity extends AppCompatActivity { - private class ProfileViewHolder(view: View) extends RecyclerView.ViewHolder(view) with View.OnClickListener { - { - val typedArray = obtainStyledAttributes(Array(android.R.attr.selectableItemBackground)) - view.setBackgroundResource(typedArray.getResourceId(0, 0)) - typedArray.recycle() - } - private var item: Profile = _ - private val text = itemView.findViewById[CheckedTextView](android.R.id.text1) - itemView.setOnClickListener(this) - - def bindDefault() { - item = null - text.setText(R.string.profile_default) - text.setChecked(taskerOption.profileId < 0) - } - def bind(item: Profile) { - this.item = item - text.setText(item.getName) - text.setChecked(taskerOption.profileId == item.id) - } - - def onClick(v: View) { - taskerOption.switchOn = switch.isChecked - taskerOption.profileId = if (item == null) -1 else item.id - setResult(Activity.RESULT_OK, taskerOption.toIntent(TaskerActivity.this)) - finish() - } - } - - private class ProfilesAdapter extends RecyclerView.Adapter[ProfileViewHolder] { - val profiles: List[Profile] = app.profileManager.getAllProfiles.getOrElse(List.empty[Profile]) - def getItemCount: Int = 1 + profiles.length - def onBindViewHolder(vh: ProfileViewHolder, i: Int): Unit = i match { - case 0 => vh.bindDefault() - case _ => vh.bind(profiles(i - 1)) - } - private val name = "select_dialog_singlechoice_" + (if (Build.VERSION.SDK_INT >= 21) "material" else "holo") - def onCreateViewHolder(vg: ViewGroup, i: Int) = new ProfileViewHolder(LayoutInflater.from(vg.getContext) - .inflate(Resources.getSystem.getIdentifier(name, "layout", "android"), vg, false)) - } - - private var taskerOption: TaskerSettings = _ - private var switch: Switch = _ - private val profilesAdapter = new ProfilesAdapter - - override def onCreate(savedInstanceState: Bundle) { - super.onCreate(savedInstanceState) - try taskerOption = TaskerSettings.fromIntent(getIntent) catch { - case _: Exception => - finish() - return - } - setContentView(R.layout.layout_tasker) - - val toolbar = findViewById(R.id.toolbar).asInstanceOf[Toolbar] - toolbar.setTitle(R.string.app_name) - toolbar.setNavigationIcon(R.drawable.ic_navigation_close) - toolbar.setNavigationOnClickListener(_ => finish()) - - switch = findViewById(R.id.serviceSwitch).asInstanceOf[Switch] - switch.setChecked(taskerOption.switchOn) - val profilesList = findViewById(R.id.list).asInstanceOf[RecyclerView] - val lm = new LinearLayoutManager(this, LinearLayoutManager.VERTICAL, false) - profilesList.setLayoutManager(lm) - profilesList.setItemAnimator(new DefaultItemAnimator) - profilesList.setAdapter(profilesAdapter) - if (taskerOption.profileId >= 0) lm.scrollToPosition(profilesAdapter.profiles.zipWithIndex.collectFirst { - case (profile, i) if profile.id == taskerOption.profileId => i + 1 - }.getOrElse(0)) - } -} - diff --git a/mobile/src/main/scala/com/github/shadowsocks/TaskerReceiver.scala b/mobile/src/main/scala/com/github/shadowsocks/TaskerReceiver.scala deleted file mode 100644 index fa9017b8..00000000 --- a/mobile/src/main/scala/com/github/shadowsocks/TaskerReceiver.scala +++ /dev/null @@ -1,46 +0,0 @@ -/*******************************************************************************/ -/* */ -/* Copyright (C) 2017 by Max Lv */ -/* Copyright (C) 2017 by Mygod Studio */ -/* */ -/* This program is free software: you can redistribute it and/or modify */ -/* it under the terms of the GNU General Public License as published by */ -/* the Free Software Foundation, either version 3 of the License, or */ -/* (at your option) any later version. */ -/* */ -/* This program is distributed in the hope that it will be useful, */ -/* but WITHOUT ANY WARRANTY; without even the implied warranty of */ -/* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the */ -/* GNU General Public License for more details. */ -/* */ -/* You should have received a copy of the GNU General Public License */ -/* along with this program. If not, see . */ -/* */ -/*******************************************************************************/ - -package com.github.shadowsocks - -import android.content.{BroadcastReceiver, Context, Intent} -import com.github.shadowsocks.utils.{TaskerSettings, Utils} -import com.github.shadowsocks.ShadowsocksApplication.app - -/** - * @author CzBiX - */ -class TaskerReceiver extends BroadcastReceiver { - override def onReceive(context: Context, intent: Intent) { - val settings = TaskerSettings.fromIntent(intent) - var changed = false - app.profileManager.getProfile(settings.profileId) match { - case Some(_) => if (app.dataStore.profileId != settings.profileId) { - app.switchProfile(settings.profileId) - changed = true - } - case _ => - } - if (settings.switchOn) { - Utils.startSsService(context) - if (changed) Utils.reloadSsService(context) - } else Utils.stopSsService(context) - } -} diff --git a/mobile/src/main/scala/com/github/shadowsocks/ToolbarFragment.scala b/mobile/src/main/scala/com/github/shadowsocks/ToolbarFragment.scala deleted file mode 100644 index 0d397150..00000000 --- a/mobile/src/main/scala/com/github/shadowsocks/ToolbarFragment.scala +++ /dev/null @@ -1,41 +0,0 @@ -/*******************************************************************************/ -/* */ -/* Copyright (C) 2017 by Max Lv */ -/* Copyright (C) 2017 by Mygod Studio */ -/* */ -/* This program is free software: you can redistribute it and/or modify */ -/* it under the terms of the GNU General Public License as published by */ -/* the Free Software Foundation, either version 3 of the License, or */ -/* (at your option) any later version. */ -/* */ -/* This program is distributed in the hope that it will be useful, */ -/* but WITHOUT ANY WARRANTY; without even the implied warranty of */ -/* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the */ -/* GNU General Public License for more details. */ -/* */ -/* You should have received a copy of the GNU General Public License */ -/* along with this program. If not, see . */ -/* */ -/*******************************************************************************/ - -package com.github.shadowsocks - -import android.os.Bundle -import android.support.v4.app.Fragment -import android.support.v7.widget.Toolbar -import android.view.View - -class ToolbarFragment extends Fragment { - var toolbar: Toolbar = _ - - override def onViewCreated(view: View, savedInstanceState: Bundle) { - super.onViewCreated(view, savedInstanceState) - toolbar = view.findViewById[Toolbar](R.id.toolbar) - val activity = getActivity.asInstanceOf[MainActivity] - if (activity.crossfader == null) activity.drawer.setToolbar(activity, toolbar, true) - } - - def onTrafficUpdated(profileId: Int, txRate: Long, rxRate: Long, txTotal: Long, rxTotal: Long): Unit = () - - def onBackPressed(): Boolean = false -} diff --git a/mobile/src/main/scala/com/github/shadowsocks/acl/Acl.scala b/mobile/src/main/scala/com/github/shadowsocks/acl/Acl.scala deleted file mode 100644 index 293737a7..00000000 --- a/mobile/src/main/scala/com/github/shadowsocks/acl/Acl.scala +++ /dev/null @@ -1,182 +0,0 @@ -package com.github.shadowsocks.acl - -import java.io.{File, FileNotFoundException} -import java.net.URL - -import android.util.Log -import com.github.shadowsocks.ShadowsocksApplication.app -import com.github.shadowsocks.utils.IOUtils -import com.j256.ormlite.field.DatabaseField - -import scala.collection.mutable -import scala.io.Source - -/** - * ACL handler compliant with: src/main/jni/shadowsocks-libev/src/acl.c - * - * OrmLite integration is unused for now. - * - * @author Mygod - */ -class Acl { - import Acl._ - - @DatabaseField(generatedId = true) - var id: Int = _ - val bypassHostnames = new mutable.SortedList[String]() - val proxyHostnames = new mutable.SortedList[String]() - val subnets = new mutable.SortedList[Subnet]() - val urls = new mutable.SortedList[URL]()(urlOrdering) - - @DatabaseField - var bypass: Boolean = _ - - def getBypassHostnamesString: String = bypassHostnames.mkString("\n") - def getProxyHostnamesString: String = proxyHostnames.mkString("\n") - def getSubnetsString: String = subnets.mkString("\n") - def getUrlsString: String = urls.mkString("\n") - def setBypassHostnamesString(value: String) { - bypassHostnames.clear() - bypassHostnames ++= value.split("\n") - } - def setProxyHostnamesString(value: String) { - proxyHostnames.clear() - proxyHostnames ++= value.split("\n") - } - def setSubnetsString(value: String) { - subnets.clear() - subnets ++= value.split("\n").map(Subnet.fromString) - } - def setUrlsString(value: String) { - urls.clear() - urls ++= value.split("\n").map(new URL(_)) - } - - def fromAcl(other: Acl): Acl = { - bypassHostnames.clear() - bypassHostnames ++= other.bypassHostnames - proxyHostnames.clear() - proxyHostnames ++= other.proxyHostnames - subnets.clear() - subnets ++= other.subnets - urls.clear() - urls ++= other.urls - bypass = other.bypass - this - } - def fromSource(value: Source, defaultBypass: Boolean = false): Acl = { - bypassHostnames.clear() - proxyHostnames.clear() - this.subnets.clear() - urls.clear() - bypass = defaultBypass - lazy val bypassSubnets = new mutable.SortedList[Subnet]() - lazy val proxySubnets = new mutable.SortedList[Subnet]() - var hostnames: mutable.SortedList[String] = if (defaultBypass) proxyHostnames else bypassHostnames - var subnets: mutable.SortedList[Subnet] = if (defaultBypass) proxySubnets else bypassSubnets - for (line <- value.getLines()) (line.indexOf('#') match { - case -1 => line - case index => - line.substring(index + 1) match { - case networkAclParser(url) => urls.add(new URL(url)) - case _ => // ignore - } - line.substring(0, index) // trim comments - }).trim match { - case "[outbound_block_list]" => - hostnames = null - subnets = null - case "[black_list]" | "[bypass_list]" => - hostnames = bypassHostnames - subnets = bypassSubnets - case "[white_list]" | "[proxy_list]" => - hostnames = proxyHostnames - subnets = proxySubnets - case "[reject_all]" | "[bypass_all]" => bypass = true - case "[accept_all]" | "[proxy_all]" => bypass = false - case input if subnets != null && input.nonEmpty => try subnets += Subnet.fromString(input) catch { - case _: IllegalArgumentException => hostnames += input - } - case _ => - } - this.subnets ++= (if (bypass) proxySubnets else bypassSubnets) - this - } - final def fromId(id: String): Acl = fromSource(Source.fromFile(Acl.getFile(id))) - - def flatten(depth: Int): Acl = { - if (depth > 0) for (url <- urls) { - val child = new Acl().fromSource(Source.fromURL(url), bypass).flatten(depth - 1) - if (bypass != child.bypass) { - Log.w(TAG, "Imported network ACL has a conflicting mode set. This will probably not work as intended. URL: %s" - .format(url)) - child.subnets.clear() // subnets for the different mode are discarded - child.bypass = bypass - } - bypassHostnames ++= child.bypassHostnames - proxyHostnames ++= child.proxyHostnames - subnets ++= child.subnets - } - urls.clear() - this - } - - override def toString: String = { - val result = new StringBuilder() - result.append(if (bypass) "[bypass_all]\n" else "[proxy_all]\n") - val (bypassList, proxyList) = - if (bypass) (bypassHostnames.toStream, subnets.toStream.map(_.toString) #::: proxyHostnames.toStream) - else (subnets.toStream.map(_.toString) #::: bypassHostnames.toStream, proxyHostnames.toStream) - if (bypassList.nonEmpty) { - result.append("[bypass_list]\n") - result.append(bypassList.mkString("\n")) - result.append('\n') - } - if (proxyList.nonEmpty) { - result.append("[proxy_list]\n") - result.append(proxyList.mkString("\n")) - result.append('\n') - } - result.append(urls.map("#IMPORT_URL <%s>\n".format(_)).mkString) - result.toString - } - - // Don't change: dummy fields for OrmLite interaction - - // noinspection ScalaUnusedSymbol - @DatabaseField(useGetSet = true) - private val bypassHostnamesString: String = null - // noinspection ScalaUnusedSymbol - @DatabaseField(useGetSet = true) - private val proxyHostnamesString: String = null - // noinspection ScalaUnusedSymbol - @DatabaseField(useGetSet = true) - private val subnetsString: String = null -} - -object Acl { - final val TAG = "Acl" - final val ALL = "all" - final val BYPASS_LAN = "bypass-lan" - final val BYPASS_CHN = "bypass-china" - final val BYPASS_LAN_CHN = "bypass-lan-china" - final val GFWLIST = "gfwlist" - final val CHINALIST = "china-list" - final val CUSTOM_RULES = "custom-rules" - final val CUSTOM_RULES_FLATTENED = "custom-rules-flattened" - private val networkAclParser = "^IMPORT_URL\\s*<(.+)>\\s*$".r - - private val urlOrdering: Ordering[URL] = Ordering.by(url => (url.getHost, url.getPort, url.getFile, url.getProtocol)) - - def getFile(id: String) = new File(app.getFilesDir, id + ".acl") - def customRules: Acl = { - val acl = new Acl() - try acl.fromId(CUSTOM_RULES) catch { - case _: FileNotFoundException => - } - acl.bypass = true - acl.bypassHostnames.clear() // everything is bypassed - acl - } - def save(id: String, acl: Acl): Unit = IOUtils.writeString(getFile(id), acl.toString) -} diff --git a/mobile/src/main/scala/com/github/shadowsocks/acl/AclSyncJob.scala b/mobile/src/main/scala/com/github/shadowsocks/acl/AclSyncJob.scala deleted file mode 100644 index c532039c..00000000 --- a/mobile/src/main/scala/com/github/shadowsocks/acl/AclSyncJob.scala +++ /dev/null @@ -1,64 +0,0 @@ -/*******************************************************************************/ -/* */ -/* Copyright (C) 2017 by Max Lv */ -/* Copyright (C) 2017 by Mygod Studio */ -/* */ -/* This program is free software: you can redistribute it and/or modify */ -/* it under the terms of the GNU General Public License as published by */ -/* the Free Software Foundation, either version 3 of the License, or */ -/* (at your option) any later version. */ -/* */ -/* This program is distributed in the hope that it will be useful, */ -/* but WITHOUT ANY WARRANTY; without even the implied warranty of */ -/* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the */ -/* GNU General Public License for more details. */ -/* */ -/* You should have received a copy of the GNU General Public License */ -/* along with this program. If not, see . */ -/* */ -/*******************************************************************************/ - -package com.github.shadowsocks.acl - -import java.io.IOException -import java.util.concurrent.TimeUnit - -import com.evernote.android.job.Job.{Params, Result} -import com.evernote.android.job.{Job, JobRequest} -import com.github.shadowsocks.utils.IOUtils - -import scala.io.Source - -/** - * @author Mygod - */ -object AclSyncJob { - final val TAG = "AclSyncJob" - final val MAX_RESCHEDULE = 3 - - def schedule(route: String): Int = new JobRequest.Builder(AclSyncJob.TAG + ':' + route) - .setExecutionWindow(TimeUnit.SECONDS.toMillis(10), TimeUnit.DAYS.toMillis(28)) - .setRequirementsEnforced(true) - .setRequiredNetworkType(JobRequest.NetworkType.UNMETERED) - .setRequiresCharging(true) - .setUpdateCurrent(true) - .build().schedule() -} - -class AclSyncJob(route: String) extends Job { - override def onRunJob(params: Params): Result = { - try { - //noinspection JavaAccessorMethodCalledAsEmptyParen - IOUtils.writeString(Acl.getFile(route), - Source.fromURL("https://shadowsocks.org/acl/android/v1/" + route + ".acl").mkString) - Result.SUCCESS - } catch { - case e: IOException => - e.printStackTrace() - if (params.getFailureCount < AclSyncJob.MAX_RESCHEDULE) Result.RESCHEDULE else Result.FAILURE - case e: Exception => // unknown failures, probably shouldn't retry - e.printStackTrace() - Result.FAILURE - } - } -} diff --git a/mobile/src/main/scala/com/github/shadowsocks/acl/CustomRulesFragment.scala b/mobile/src/main/scala/com/github/shadowsocks/acl/CustomRulesFragment.scala deleted file mode 100644 index 85399e74..00000000 --- a/mobile/src/main/scala/com/github/shadowsocks/acl/CustomRulesFragment.scala +++ /dev/null @@ -1,379 +0,0 @@ -package com.github.shadowsocks.acl - -import java.net.{IDN, URL} -import java.util.Locale - -import android.content.{ClipData, ClipboardManager, Context, DialogInterface} -import android.os.Bundle -import android.support.design.widget.Snackbar -import android.support.v7.app.AlertDialog -import android.support.v7.widget.RecyclerView.ViewHolder -import android.support.v7.widget.helper.ItemTouchHelper -import android.support.v7.widget.helper.ItemTouchHelper.SimpleCallback -import android.support.v7.widget.{DefaultItemAnimator, LinearLayoutManager, RecyclerView, Toolbar} -import android.view._ -import android.widget.{EditText, Spinner, TextView, Toast} -import com.futuremind.recyclerviewfastscroll.{FastScroller, SectionTitleProvider} -import com.github.shadowsocks.ShadowsocksApplication.app -import com.github.shadowsocks.bg.ServiceState -import com.github.shadowsocks.widget.UndoSnackbarManager -import com.github.shadowsocks.{MainActivity, R, ToolbarFragment} - -import scala.collection.mutable -import scala.io.Source - -/** - * @author Mygod - */ -object CustomRulesFragment { - private final val TEMPLATE_GENERIC = 0 - private final val TEMPLATE_DOMAIN = 1 - private final val TEMPLATE_URL = 2 - private val PATTERN_DOMAIN = """(?<=^\(\^\|\\\.\)).*(?=\$$)""".r - private final val TEMPLATE_REGEX_DOMAIN = "(^|\\.)%s$" - - private final val SELECTED_SUBNETS = "com.github.shadowsocks.acl.CustomRulesFragment.SELECTED_SUBNETS" - private final val SELECTED_HOSTNAMES = "com.github.shadowsocks.acl.CustomRulesFragment.SELECTED_HOSTNAMES" - private final val SELECTED_URLS = "com.github.shadowsocks.acl.CustomRulesFragment.SELECTED_URLS" -} - -class CustomRulesFragment extends ToolbarFragment with Toolbar.OnMenuItemClickListener { - import CustomRulesFragment._ - - private def isEnabled = getActivity.asInstanceOf[MainActivity].state match { - case ServiceState.CONNECTED => app.currentProfile.get.route != Acl.CUSTOM_RULES - case ServiceState.STOPPED => true - case _ => false - } - - private def createAclRuleDialog(item: AnyRef = "") = { - val view = getActivity.getLayoutInflater.inflate(R.layout.dialog_acl_rule, null) - val templateSelector = view.findViewById[Spinner](R.id.template_selector) - val editText = view.findViewById[EditText](R.id.content) - item match { - case hostname: String => PATTERN_DOMAIN.findFirstMatchIn(hostname) match { - case Some(m) => - templateSelector.setSelection(TEMPLATE_DOMAIN) - editText.setText(IDN.toUnicode(m.matched.replaceAll("\\\\.", "."), - IDN.ALLOW_UNASSIGNED | IDN.USE_STD3_ASCII_RULES)) - case None => - templateSelector.setSelection(TEMPLATE_GENERIC) - editText.setText(hostname) - } - case url: URL => - templateSelector.setSelection(TEMPLATE_URL) - editText.setText(url.toString) - case i => - templateSelector.setSelection(TEMPLATE_GENERIC) - editText.setText(i.toString) - } - (templateSelector, editText, new AlertDialog.Builder(getActivity) - .setTitle(R.string.edit_rule) - .setNegativeButton(android.R.string.cancel, null) - .setView(view)) - } - - private val selectedItems = new mutable.HashSet[AnyRef] - private def onSelectedItemsUpdated(): Unit = - if (selectionItem != null) selectionItem.setVisible(selectedItems.nonEmpty) - - private final class AclRuleViewHolder(view: View) extends RecyclerView.ViewHolder(view) - with View.OnClickListener with View.OnLongClickListener { - var item: AnyRef = _ - private val text = itemView.findViewById[TextView](android.R.id.text1) - itemView.setOnClickListener(this) - itemView.setOnLongClickListener(this) - itemView.setBackgroundResource(R.drawable.background_selectable) - - def bind(hostname: String) { - item = hostname - text.setText(hostname) - itemView.setSelected(selectedItems.contains(hostname)) - } - def bind(subnet: Subnet) { - item = subnet - text.setText(subnet.toString) - itemView.setSelected(selectedItems.contains(subnet)) - } - def bind(url: URL) { - item = url - text.setText(url.toString) - itemView.setSelected(selectedItems.contains(url)) - } - - def onClick(v: View): Unit = if (selectedItems.nonEmpty) onLongClick(v) else { - val (templateSelector, editText, dialog) = createAclRuleDialog(item) - dialog - .setNeutralButton(R.string.delete, ((_, _) => { - adapter.remove(item) - undoManager.remove((-1, item)) - }): DialogInterface.OnClickListener) - .setPositiveButton(android.R.string.ok, ((_, _) => - adapter.addFromTemplate(templateSelector.getSelectedItemPosition, editText.getText) match { - case -1 => - case index => - val item = this.item - list.post(() => { - list.scrollToPosition(index) - adapter.remove(item) - }) - }) - : DialogInterface.OnClickListener) - .create().show() - } - def onLongClick(v: View): Boolean = { - if (!selectedItems.add(item)) selectedItems.remove(item) // toggle - onSelectedItemsUpdated() - itemView.setSelected(!itemView.isSelected) - true - } - } - - private final class AclRulesAdapter extends RecyclerView.Adapter[AclRuleViewHolder] with SectionTitleProvider { - private val acl = Acl.customRules - private var savePending: Boolean = _ - private def apply() = if (!savePending) { - savePending = true - list.post(() => { - Acl.save(Acl.CUSTOM_RULES, acl) - savePending = false - }) - } - - def getItemCount: Int = acl.subnets.size + acl.proxyHostnames.size + acl.urls.size - def onBindViewHolder(vh: AclRuleViewHolder, i: Int): Unit = { - val j = i - acl.subnets.size - if (j < 0) vh.bind(acl.subnets(i)) else { - val k = j - acl.proxyHostnames.size - if (k < 0) vh.bind(acl.proxyHostnames(j)) else vh.bind(acl.urls(k)) - } - } - def onCreateViewHolder(vg: ViewGroup, i: Int) = new AclRuleViewHolder(LayoutInflater.from(vg.getContext) - .inflate(android.R.layout.simple_list_item_1, vg, false)) - - def addSubnet(subnet: Subnet): Int = if (acl.subnets.add(subnet)) { - val index = acl.subnets.indexOf(subnet) - notifyItemInserted(index) - apply() - index - } else -1 - def addHostname(hostname: String): Int = if (acl.proxyHostnames.add(hostname)) { - val index = acl.proxyHostnames.indexOf(hostname) + acl.subnets.size - notifyItemInserted(index) - apply() - index - } else -1 - def addURL(url: URL): Int = if (acl.urls.add(url)) { - val index = acl.urls.indexOf(url) + acl.subnets.size + acl.proxyHostnames.size - notifyItemInserted(index) - apply() - index - } else -1 - def addToProxy(input: String): Int = { - val acl = new Acl().fromSource(Source.fromString(input), defaultBypass = true) - var result = -1 - if (acl.bypass) for (subnet <- acl.subnets) result = addSubnet(subnet) - for (hostname <- acl.proxyHostnames) result = addHostname(hostname) - for (url <- acl.urls) result = addURL(url) - result - } - def addFromTemplate(template: Int, text: CharSequence): Int = template match { - case TEMPLATE_GENERIC => addToProxy(text.toString) - case TEMPLATE_DOMAIN => try addHostname(TEMPLATE_REGEX_DOMAIN.formatLocal(Locale.ENGLISH, - IDN.toASCII(text.toString, IDN.ALLOW_UNASSIGNED | IDN.USE_STD3_ASCII_RULES).replaceAll("\\.", "\\\\."))) catch { - case exc: IllegalArgumentException => - Toast.makeText(getActivity, exc.getMessage, Toast.LENGTH_SHORT).show() - -1 - } - case TEMPLATE_URL => addURL(new URL(text.toString)) - case _ => -1 - } - - def remove(i: Int) { - val j = i - acl.subnets.size - if (j < 0) { - undoManager.remove((i, acl.subnets(i))) - acl.subnets.remove(i) - } else { - val k = j - acl.proxyHostnames.size - if (k < 0) { - undoManager.remove((j, acl.proxyHostnames(j))) - acl.proxyHostnames.remove(j) - } else { - undoManager.remove((k, acl.urls(k))) - acl.urls.remove(k) - } - } - notifyItemRemoved(i) - apply() - } - def remove(item: AnyRef): Unit = item match { - case subnet: Subnet => - notifyItemRemoved(acl.subnets.indexOf(subnet)) - acl.subnets.remove(subnet) - apply() - case hostname: String => - notifyItemRemoved(acl.subnets.size + acl.proxyHostnames.indexOf(hostname)) - acl.proxyHostnames.remove(hostname) - apply() - case url: URL => - notifyItemRemoved(acl.subnets.size + acl.proxyHostnames.size + acl.urls.indexOf(url)) - acl.urls.remove(url) - apply() - } - def removeSelected() { - undoManager.remove(selectedItems.map((-1, _)).toSeq: _*) - selectedItems.foreach(remove) - selectedItems.clear() - onSelectedItemsUpdated() - } - def undo(actions: Iterator[(Int, AnyRef)]): Unit = for ((_, item) <- actions) item match { - case subnet: Subnet => if (acl.subnets.insert(subnet)) { - notifyItemInserted(acl.subnets.indexOf(subnet)) - apply() - } - case hostname: String => if (acl.proxyHostnames.insert(hostname)) { - notifyItemInserted(acl.subnets.size + acl.proxyHostnames.indexOf(hostname)) - apply() - } - case url: URL => if (acl.urls.insert(url)) { - notifyItemInserted(acl.subnets.size + acl.proxyHostnames.size + acl.urls.indexOf(url)) - apply() - } - } - - def selectAll() { - selectedItems.clear() - selectedItems ++= acl.subnets - selectedItems ++= acl.proxyHostnames - selectedItems ++= acl.urls - onSelectedItemsUpdated() - notifyDataSetChanged() - } - - def getSectionTitle(i: Int): String = { - val j = i - acl.subnets.size - try { - (if (j < 0) acl.subnets(i).address.getHostAddress.substring(0, 1) else { - val k = j - acl.proxyHostnames.size - if (k < 0) { - val hostname = acl.proxyHostnames(j) - PATTERN_DOMAIN.findFirstMatchIn(hostname) match { - case Some(m) => m.matched.replaceAll("\\\\.", ".") // don't convert IDN yet - case None => hostname - } - } else acl.urls(k).getHost - }).substring(0, 1) - } catch { - case _: IndexOutOfBoundsException => " " - } - } - } - - private lazy val adapter = new AclRulesAdapter() - private var list: RecyclerView = _ - private var selectionItem: MenuItem = _ - private var undoManager: UndoSnackbarManager[AnyRef] = _ - private lazy val clipboard = getActivity.getSystemService(Context.CLIPBOARD_SERVICE).asInstanceOf[ClipboardManager] - - override def onCreateView(inflater: LayoutInflater, container: ViewGroup, savedInstanceState: Bundle): View = - inflater.inflate(R.layout.layout_custom_rules, container, false) - override def onViewCreated(view: View, savedInstanceState: Bundle) { - super.onViewCreated(view, savedInstanceState) - if (savedInstanceState != null) { - savedInstanceState.getStringArray(SELECTED_SUBNETS) match { - case null => - case arr => selectedItems ++= arr.map(Subnet.fromString) - } - savedInstanceState.getStringArray(SELECTED_HOSTNAMES) match { - case null => - case arr => selectedItems ++= arr - } - savedInstanceState.getStringArray(SELECTED_URLS) match { - case null => - case arr => selectedItems ++= arr.map(new URL(_)) - } - onSelectedItemsUpdated() - } - toolbar.setTitle(R.string.custom_rules) - toolbar.inflateMenu(R.menu.custom_rules_menu) - toolbar.setOnMenuItemClickListener(this) - selectionItem = toolbar.getMenu.findItem(R.id.selection) - selectionItem.setVisible(selectedItems.nonEmpty) - list = view.findViewById(R.id.list) - list.setLayoutManager(new LinearLayoutManager(getActivity, LinearLayoutManager.VERTICAL, false)) - list.setItemAnimator(new DefaultItemAnimator) - list.setAdapter(adapter) - view.findViewById[FastScroller](R.id.fastscroller).setRecyclerView(list) - undoManager = new UndoSnackbarManager[AnyRef](getActivity.findViewById(R.id.snackbar), adapter.undo) - new ItemTouchHelper(new SimpleCallback(0, ItemTouchHelper.START | ItemTouchHelper.END) { - override def getSwipeDirs(recyclerView: RecyclerView, viewHolder: ViewHolder): Int = - if (isEnabled) super.getSwipeDirs(recyclerView, viewHolder) else 0 - def onSwiped(viewHolder: ViewHolder, direction: Int): Unit = adapter.remove(viewHolder.getAdapterPosition) - def onMove(recyclerView: RecyclerView, viewHolder: ViewHolder, target: ViewHolder): Boolean = false - }).attachToRecyclerView(list) - } - - override def onBackPressed(): Boolean = if (selectedItems.nonEmpty) { - selectedItems.clear() - onSelectedItemsUpdated() - adapter.notifyDataSetChanged() - true - } else super.onBackPressed() - - override def onSaveInstanceState(outState: Bundle): Unit = { - super.onSaveInstanceState(outState) - outState.putStringArray(SELECTED_SUBNETS, selectedItems.filter(_.isInstanceOf[Subnet]).map(_.toString).toArray) - outState.putStringArray(SELECTED_HOSTNAMES, selectedItems.filter(_.isInstanceOf[String]).map(_.toString).toArray) - outState.putStringArray(SELECTED_URLS, selectedItems.filter(_.isInstanceOf[URL]).map(_.toString).toArray) - } - - def copySelected() { - val acl = new Acl() - acl.bypass = true - selectedItems.foreach { - case subnet: Subnet => acl.subnets.add(subnet) - case hostname: String => acl.proxyHostnames.add(hostname) - case url: URL => acl.urls.add(url) - } - clipboard.setPrimaryClip(ClipData.newPlainText(null, acl.toString)) - } - override def onMenuItemClick(menuItem: MenuItem): Boolean = menuItem.getItemId match { - case R.id.action_select_all => - adapter.selectAll() - true - case R.id.action_cut => - copySelected() - adapter.removeSelected() - true - case R.id.action_copy => - copySelected() - true - case R.id.action_delete => - adapter.removeSelected() - true - - case R.id.action_manual_settings => - val (templateSelector, editText, dialog) = createAclRuleDialog() - dialog - .setPositiveButton(android.R.string.ok, ((_, _) => adapter.addFromTemplate( - templateSelector.getSelectedItemPosition, editText.getText)): DialogInterface.OnClickListener) - .create().show() - true - case R.id.action_import => - try adapter.addToProxy(clipboard.getPrimaryClip.getItemAt(0).getText.toString) != -1 catch { - case exc: Exception => - Snackbar.make(getActivity.findViewById(R.id.snackbar), R.string.action_import_err, Snackbar.LENGTH_LONG) - .show() - exc.printStackTrace() - } - true - case R.id.action_import_gfwlist => - val acl = new Acl().fromId(Acl.GFWLIST) - acl.subnets.foreach(adapter.addSubnet) - acl.proxyHostnames.foreach(adapter.addHostname) - acl.urls.foreach(adapter.addURL) - true - case _ => false - } -} diff --git a/mobile/src/main/scala/com/github/shadowsocks/acl/DonaldTrump.scala b/mobile/src/main/scala/com/github/shadowsocks/acl/DonaldTrump.scala deleted file mode 100644 index 6ea1a9a6..00000000 --- a/mobile/src/main/scala/com/github/shadowsocks/acl/DonaldTrump.scala +++ /dev/null @@ -1,44 +0,0 @@ -/*******************************************************************************/ -/* */ -/* Copyright (C) 2017 by Max Lv */ -/* Copyright (C) 2017 by Mygod Studio */ -/* */ -/* This program is free software: you can redistribute it and/or modify */ -/* it under the terms of the GNU General Public License as published by */ -/* the Free Software Foundation, either version 3 of the License, or */ -/* (at your option) any later version. */ -/* */ -/* This program is distributed in the hope that it will be useful, */ -/* but WITHOUT ANY WARRANTY; without even the implied warranty of */ -/* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the */ -/* GNU General Public License for more details. */ -/* */ -/* You should have received a copy of the GNU General Public License */ -/* along with this program. If not, see . */ -/* */ -/*******************************************************************************/ - -package com.github.shadowsocks.acl - -import android.util.Log -import com.evernote.android.job.JobCreator - -/** - * “I create jobs all day long. - * - Donald Trump, 2015 - * - * Source: http://www.cnn.com/2015/09/24/politics/donald-trump-marco-rubio-foreign-policy/ - * - * @author !Mygod - */ -object DonaldTrump extends JobCreator { - def create(tag: String): AclSyncJob = { - val parts = tag.split(":") - parts(0) match { - case AclSyncJob.TAG => new AclSyncJob(parts(1)) - case _ => - Log.w("DonaldTrump", "Unknown job tag: " + tag) - null - } - } -} diff --git a/mobile/src/main/scala/com/github/shadowsocks/acl/Subnet.scala b/mobile/src/main/scala/com/github/shadowsocks/acl/Subnet.scala deleted file mode 100644 index 71e6d67f..00000000 --- a/mobile/src/main/scala/com/github/shadowsocks/acl/Subnet.scala +++ /dev/null @@ -1,49 +0,0 @@ -package com.github.shadowsocks.acl - -import java.net.InetAddress -import java.util.Objects - -import com.github.shadowsocks.utils.Utils - -/** - * @author Mygod - */ -@throws[IllegalArgumentException] -class Subnet(val address: InetAddress, val prefixSize: Int) extends Comparable[Subnet] { - private def addressLength = address.getAddress.length << 3 - if (prefixSize < 0 || prefixSize > addressLength) throw new IllegalArgumentException - - override def toString: String = - if (prefixSize == addressLength) address.getHostAddress else address.getHostAddress + '/' + prefixSize - - override def compareTo(that: Subnet): Int = { - val addrThis = address.getAddress - val addrThat = that.address.getAddress - var result = addrThis lengthCompare addrThat.length // IPv4 address goes first - if (result != 0) return result - for ((x, y) <- addrThis zip addrThat) { - result = (x & 0xFF) compare (y & 0xFF) // undo sign extension of signed byte - if (result != 0) return result - } - prefixSize compare that.prefixSize - } - - override def equals(other: Any): Boolean = other match { - case that: Subnet => address == that.address && prefixSize == that.prefixSize - case _ => false - } - override def hashCode: Int = Objects.hash(address, prefixSize: Integer) -} - -object Subnet { - @throws[IllegalArgumentException] - def fromString(value: String): Subnet = { - val parts = value.split("/") - val addr = Utils.parseNumericAddress(parts(0)) - parts.length match { - case 1 => new Subnet(addr, addr.getAddress.length << 3) - case 2 => new Subnet(addr, parts(1).toInt) - case _ => throw new IllegalArgumentException() - } - } -} diff --git a/mobile/src/main/scala/com/github/shadowsocks/bg/BaseService.scala b/mobile/src/main/scala/com/github/shadowsocks/bg/BaseService.scala deleted file mode 100644 index 11484b6d..00000000 --- a/mobile/src/main/scala/com/github/shadowsocks/bg/BaseService.scala +++ /dev/null @@ -1,383 +0,0 @@ -/*******************************************************************************/ -/* */ -/* Copyright (C) 2017 by Max Lv */ -/* Copyright (C) 2017 by Mygod Studio */ -/* */ -/* This program is free software: you can redistribute it and/or modify */ -/* it under the terms of the GNU General Public License as published by */ -/* the Free Software Foundation, either version 3 of the License, or */ -/* (at your option) any later version. */ -/* */ -/* This program is distributed in the hope that it will be useful, */ -/* but WITHOUT ANY WARRANTY; without even the implied warranty of */ -/* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the */ -/* GNU General Public License for more details. */ -/* */ -/* You should have received a copy of the GNU General Public License */ -/* along with this program. If not, see . */ -/* */ -/*******************************************************************************/ - -package com.github.shadowsocks.bg - -import java.io.{File, IOException} -import java.net.InetAddress -import java.util -import java.util.concurrent.TimeUnit -import java.util.{Timer, TimerTask} - -import android.app.Service -import android.content._ -import android.os.{Build, Handler, IBinder, RemoteCallbackList} -import android.text.TextUtils -import android.util.Log -import android.widget.Toast -import com.github.shadowsocks.ShadowsocksApplication.app -import com.github.shadowsocks.acl.{Acl, AclSyncJob} -import com.github.shadowsocks.aidl.{IShadowsocksService, IShadowsocksServiceCallback} -import com.github.shadowsocks.database.Profile -import com.github.shadowsocks.plugin.{PluginConfiguration, PluginManager, PluginOptions} -import com.github.shadowsocks.utils._ -import com.github.shadowsocks.{GuardedProcess, R} -import okhttp3.{Dns, FormBody, OkHttpClient, Request} -import org.json.JSONObject - -import scala.collection.mutable -import scala.collection.mutable.ArrayBuffer -import scala.util.Random - -trait BaseService extends Service { - - protected val TAG: String - @volatile private var state = ServiceState.STOPPED - @volatile protected var profile: Profile = _ - @volatile private var plugin: PluginOptions = _ - @volatile protected var pluginPath: String = _ - var sslocalProcess: GuardedProcess = _ - - case class NameNotResolvedException() extends IOException - case class NullConnectionException() extends NullPointerException - - var timer: Timer = _ - var trafficMonitorThread: TrafficMonitorThread = _ - - final val callbacks = new RemoteCallbackList[IShadowsocksServiceCallback] - private final val bandwidthListeners = new mutable.HashSet[IBinder]() // the binder is the real identifier - lazy val handler = new Handler(getMainLooper) - lazy val restartHanlder = new Handler(getMainLooper) - - private var notification: ServiceNotification = _ - private val closeReceiver: BroadcastReceiver = (context: Context, intent: Intent) => intent.getAction match { - case Action.RELOAD => forceLoad() - case _ => - Toast.makeText(context, R.string.stopping, Toast.LENGTH_SHORT).show() - stopRunner(stopService = true) - } - var closeReceiverRegistered: Boolean = _ - - val binder = new IShadowsocksService.Stub { - override def getState: Int = { - state - } - - override def getProfileName: String = if (profile == null) null else profile.getName - - override def registerCallback(cb: IShadowsocksServiceCallback): Unit = callbacks.register(cb) - - override def startListeningForBandwidth(cb: IShadowsocksServiceCallback): Unit = - if (bandwidthListeners.add(cb.asBinder)){ - if (timer == null) { - timer = new Timer(true) - timer.schedule(new TimerTask { - def run(): Unit = if (state == ServiceState.CONNECTED && TrafficMonitor.updateRate()) updateTrafficRate() - }, 1000, 1000) - } - TrafficMonitor.updateRate() - if (state == ServiceState.CONNECTED) cb.trafficUpdated(profile.id, - TrafficMonitor.txRate, TrafficMonitor.rxRate, TrafficMonitor.txTotal, TrafficMonitor.rxTotal) - } - - override def stopListeningForBandwidth(cb: IShadowsocksServiceCallback): Unit = - if (bandwidthListeners.remove(cb.asBinder) && bandwidthListeners.isEmpty) { - timer.cancel() - timer = null - } - - override def unregisterCallback(cb: IShadowsocksServiceCallback) { - stopListeningForBandwidth(cb) // saves an RPC, and safer - callbacks.unregister(cb) - } - } - - def onBind(intent: Intent): IBinder = intent.getAction match { - case Action.SERVICE => binder - case _ => null - } - - def checkProfile(profile: Profile): Boolean = if (TextUtils.isEmpty(profile.host) || TextUtils.isEmpty(profile.password)) { - stopRunner(stopService = true, getString(R.string.proxy_empty)) - false - } else true - - def forceLoad(): Unit = app.currentProfile.orNull match { - case null => stopRunner(stopService = true, getString(R.string.profile_empty)) - case p => if (checkProfile(p)) state match { - case ServiceState.STOPPED => startRunner() - case ServiceState.CONNECTED => - stopRunner(stopService = false) - startRunner() - case s => Log.w(BaseService.this.getClass.getSimpleName, "Illegal state when invoking use: " + s) - } - } - - protected def buildAdditionalArguments(cmd: ArrayBuffer[String]): ArrayBuffer[String] = cmd - - /** - * BaseService will only start ss-local. Child class override this class to start other native processes. - */ - def startNativeProcesses() { - buildShadowsocksConfig() - val cmd = buildAdditionalArguments(ArrayBuffer[String]( - new File(getApplicationInfo.nativeLibraryDir, Executable.SS_LOCAL).getAbsolutePath, - "-u", - "-b", "127.0.0.1", - "-l", app.dataStore.portProxy.toString, - "-t", "600", - "-c", "shadowsocks.json")) - - if (profile.route != Acl.ALL) { - cmd += "--acl" - cmd += Acl.getFile(profile.route match { - case Acl.CUSTOM_RULES => Acl.CUSTOM_RULES_FLATTENED - case route => route - }).getAbsolutePath - } - - if (TcpFastOpen.sendEnabled) cmd += "--fast-open" - - sslocalProcess = new GuardedProcess(cmd: _*).start() - } - - def createNotification(): ServiceNotification - def startRunner(): Unit = if (Build.VERSION.SDK_INT >= 26) startForegroundService(new Intent(this, getClass)) - else startService(new Intent(this, getClass)) - - def killProcesses() { - if (sslocalProcess != null) { - sslocalProcess.destroy() - sslocalProcess = null - } - } - - def stopRunner(stopService: Boolean, msg: String = null) { - // channge the state - changeState(ServiceState.STOPPING) - - app.track(TAG, "stop") - - killProcesses() - - // clean up recevier - if (closeReceiverRegistered) { - unregisterReceiver(closeReceiver) - closeReceiverRegistered = false - } - - if (notification != null) notification.destroy() - - // Make sure update total traffic when stopping the runner - updateTrafficTotal(TrafficMonitor.txTotal, TrafficMonitor.rxTotal) - - TrafficMonitor.reset() - if (trafficMonitorThread != null) { - trafficMonitorThread.stopThread() - trafficMonitorThread = null - } - - // change the state - changeState(ServiceState.STOPPED, msg) - - // stop the service if nothing has bound to it - if (stopService) stopSelf() - - profile = null - } - - def updateTrafficTotal(tx: Long, rx: Long) { - val profile = this.profile // avoid race conditions without locking - if (profile != null) { - app.profileManager.getProfile(profile.id) match { - case Some(p) => // default profile may have host, etc. modified - p.tx += tx - p.rx += rx - app.profileManager.updateProfile(p) - handler.post(() => { - if (bandwidthListeners.nonEmpty) { - val n = callbacks.beginBroadcast() - for (i <- 0 until n) { - try { - val item = callbacks.getBroadcastItem(i) - if (bandwidthListeners.contains(item.asBinder)) item.trafficPersisted(p.id) - } catch { - case _: Exception => // Ignore - } - } - callbacks.finishBroadcast() - } - }) - case None => - } - } - } - - def getState: Int = { - state - } - - def updateTrafficRate() { - handler.post(() => { - if (bandwidthListeners.nonEmpty) { - val txRate = TrafficMonitor.txRate - val rxRate = TrafficMonitor.rxRate - val txTotal = TrafficMonitor.txTotal - val rxTotal = TrafficMonitor.rxTotal - val n = callbacks.beginBroadcast() - for (i <- 0 until n) { - try { - val item = callbacks.getBroadcastItem(i) - if (bandwidthListeners.contains(item.asBinder)) - item.trafficUpdated(profile.id, txRate, rxRate, txTotal, rxTotal) - } catch { - case _: Exception => // Ignore - } - } - callbacks.finishBroadcast() - } - }) - } - - // Service of shadowsocks should always be started explicitly - override def onStartCommand(intent: Intent, flags: Int, startId: Int): Int = { - state match { - case ServiceState.STOPPED | ServiceState.IDLE => - case _ => return Service.START_NOT_STICKY // ignore request - } - - profile = app.currentProfile.orNull - if (profile == null) { - stopRunner(stopService = true) - return Service.START_NOT_STICKY - } - - profile.name = profile.getName // save original name before it's (possibly) overwritten by IP addresses - - TrafficMonitor.reset() - trafficMonitorThread = new TrafficMonitorThread(getApplicationContext) - trafficMonitorThread.start() - - if (!closeReceiverRegistered) { - // register close receiver - val filter = new IntentFilter() - filter.addAction(Action.RELOAD) - filter.addAction(Intent.ACTION_SHUTDOWN) - filter.addAction(Action.CLOSE) - registerReceiver(closeReceiver, filter) - closeReceiverRegistered = true - } - - notification = createNotification() - app.track(getClass.getSimpleName, "start") - - changeState(ServiceState.CONNECTING) - - Utils.ThrowableFuture(try { - if (profile.host == "198.199.101.152") { - val client = new OkHttpClient.Builder() - .dns(hostname => Utils.resolve(hostname, enableIPv6 = false) match { - case Some(ip) => util.Arrays.asList(InetAddress.getByName(ip)) - case _ => Dns.SYSTEM.lookup(hostname) - }) - .connectTimeout(10, TimeUnit.SECONDS) - .writeTimeout(10, TimeUnit.SECONDS) - .readTimeout(30, TimeUnit.SECONDS) - .build() - val requestBody = new FormBody.Builder() - .add("sig", Utils.getSignature(this)) - .build() - val request = new Request.Builder() - .url(app.remoteConfig.getString("proxy_url")) - .post(requestBody) - .build() - - val proxies = Random.shuffle(client.newCall(request).execute().body.string.split('|').toSeq) - val proxy = proxies.head.split(':') - profile.host = proxy(0).trim - profile.remotePort = proxy(1).trim.toInt - profile.password = proxy(2).trim - profile.method = proxy(3).trim - } - - if (profile.route == Acl.CUSTOM_RULES) Acl.save(Acl.CUSTOM_RULES_FLATTENED, Acl.customRules.flatten(10)) - - plugin = new PluginConfiguration(profile.plugin).selectedOptions - pluginPath = PluginManager.init(plugin) - - // Clean up - killProcesses() - - if (!Utils.isNumeric(profile.host)) Utils.resolve(profile.host, enableIPv6 = true) match { - case Some(a) => profile.host = a - case None => throw NameNotResolvedException() - } - - startNativeProcesses() - - if (profile.route != Acl.ALL && profile.route != Acl.CUSTOM_RULES) - AclSyncJob.schedule(profile.route) - - changeState(ServiceState.CONNECTED) - } catch { - case _: NameNotResolvedException => stopRunner(stopService = true, getString(R.string.invalid_server)) - case _: NullConnectionException => stopRunner(stopService = true, getString(R.string.reboot_required)) - case exc: Throwable => - stopRunner(stopService = true, getString(R.string.service_failed) + ": " + exc.getMessage) - exc.printStackTrace() - app.track(exc) - }) - Service.START_NOT_STICKY - } - - protected def changeState(s: Int, msg: String = null) { - val handler = new Handler(getMainLooper) - if (state != s || msg != null) { - if (callbacks.getRegisteredCallbackCount > 0) handler.post(() => { - val n = callbacks.beginBroadcast() - for (i <- 0 until n) { - try { - callbacks.getBroadcastItem(i).stateChanged(s, binder.getProfileName, msg) - } catch { - case _: Exception => // Ignore - } - } - callbacks.finishBroadcast() - }) - state = s - } - } - - protected final def buildShadowsocksConfig() { - val config = new JSONObject() - .put("server", profile.host) - .put("server_port", profile.remotePort) - .put("password", profile.password) - .put("method", profile.method) - if (pluginPath != null) { - val pluginCmd = ArrayBuffer(pluginPath) - if (TcpFastOpen.sendEnabled) pluginCmd += "--fast-open" - config - .put("plugin", Commandline.toString(buildAdditionalArguments(pluginCmd).toArray)) - .put("plugin_opts", plugin.toString) - } - IOUtils.writeString(new File(getFilesDir, "shadowsocks.json"), config.toString) - } -} diff --git a/mobile/src/main/scala/com/github/shadowsocks/bg/Executable.scala b/mobile/src/main/scala/com/github/shadowsocks/bg/Executable.scala deleted file mode 100644 index d024b747..00000000 --- a/mobile/src/main/scala/com/github/shadowsocks/bg/Executable.scala +++ /dev/null @@ -1,36 +0,0 @@ -package com.github.shadowsocks.bg - -import java.io.File -import java.util.Locale - -import android.text.TextUtils -import android.util.Log -import com.github.shadowsocks.JniHelper -import com.github.shadowsocks.ShadowsocksApplication.app - -import scala.io.Source - -/** - * @author Mygod - */ -object Executable { - val REDSOCKS = "libredsocks.so" - val PDNSD = "libpdnsd.so" - val SS_LOCAL = "libss-local.so" - val SS_TUNNEL = "libss-tunnel.so" - val TUN2SOCKS = "libtun2socks.so" - val OVERTURE = "liboverture.so" - - val EXECUTABLES = Set(SS_LOCAL, SS_TUNNEL, PDNSD, REDSOCKS, TUN2SOCKS, OVERTURE) - - def killAll(): Unit = - for (process <- new File("/proc").listFiles((_, name) => TextUtils.isDigitsOnly(name))) { - val exe = new File(Source.fromFile(new File(process, "cmdline")).mkString.split("\0", 2).head) - if (exe.getParent == app.getApplicationInfo.nativeLibraryDir && EXECUTABLES.contains(exe.getName)) - JniHelper.sigkill(process.getName.toInt) match { - case 0 => - case errno => Log.w("kill", "SIGKILL %s (%d) failed with %d" - .formatLocal(Locale.ENGLISH, exe.getAbsolutePath, process.getName, errno)) - } - } -} diff --git a/mobile/src/main/scala/com/github/shadowsocks/bg/LocalDnsService.scala b/mobile/src/main/scala/com/github/shadowsocks/bg/LocalDnsService.scala deleted file mode 100644 index 4e8511b6..00000000 --- a/mobile/src/main/scala/com/github/shadowsocks/bg/LocalDnsService.scala +++ /dev/null @@ -1,93 +0,0 @@ -package com.github.shadowsocks.bg - -import java.io.File -import java.net.Inet6Address - -import com.github.shadowsocks.GuardedProcess -import com.github.shadowsocks.ShadowsocksApplication.app -import com.github.shadowsocks.acl.Acl -import com.github.shadowsocks.utils.{IOUtils, Utils} -import org.json.{JSONArray, JSONObject} - -import scala.collection.mutable.ArrayBuffer - -/** - * Shadowsocks service with local DNS. - * - * @author Mygod - */ -trait LocalDnsService extends BaseService { - var overtureProcess: GuardedProcess = _ - - override def startNativeProcesses() { - super.startNativeProcesses() - if (!profile.udpdns) overtureProcess = new GuardedProcess(buildAdditionalArguments(ArrayBuffer[String]( - new File(getApplicationInfo.nativeLibraryDir, Executable.OVERTURE).getAbsolutePath, - "-c", buildOvertureConfig("overture.conf") - )): _*).start() - } - - private def buildOvertureConfig(file: String): String = { - val config = new JSONObject() - .put("BindAddress", "127.0.0.1:" + app.dataStore.portLocalDns) - .put("RedirectIPv6Record", true) - .put("DomainBase64Decode", true) - .put("HostsFile", "hosts") - .put("MinimumTTL", 3600) - .put("CacheSize", 4096) - def makeDns(name: String, address: String, edns: Boolean = true) = { - val dns = new JSONObject() - .put("Name", name) - .put("Address", (Utils.parseNumericAddress(address) match { - case _: Inet6Address => '[' + address + ']' - case _ => address - }) + ":53") - .put("Timeout", 6) - .put("EDNSClientSubnet", new JSONObject().put("Policy", "disable")) - if (edns) dns - .put("Protocol", "tcp") - .put("Socks5Address", "127.0.0.1:" + app.dataStore.portProxy) - else dns.put("Protocol", "udp") - dns - } - val remoteDns = new JSONArray(profile.remoteDns.split(",").zipWithIndex.map { - case (dns, i) => makeDns("UserDef-" + i, dns.trim) - }) - val localDns = new JSONArray(Array( - makeDns("Primary-1", "119.29.29.29", edns = false), - makeDns("Primary-2", "114.114.114.114", edns = false) - )) - - try { - val localLinkDns = com.github.shadowsocks.utils.Dns.getDnsResolver(this) - localDns.put(makeDns("Primary-3", localLinkDns, edns = false)) - } catch { - case _: Exception => // Ignore - } - - profile.route match { - case Acl.BYPASS_CHN | Acl.BYPASS_LAN_CHN | Acl.GFWLIST | Acl.CUSTOM_RULES => config - .put("PrimaryDNS", localDns) - .put("AlternativeDNS", remoteDns) - .put("IPNetworkFile", "china_ip_list.txt") - .put("DomainFile", "gfwlist.txt") - case Acl.CHINALIST => config - .put("PrimaryDNS", localDns) - .put("AlternativeDNS", remoteDns) - case _ => config - .put("PrimaryDNS", remoteDns) - // no need to setup AlternativeDNS in Acl.ALL/BYPASS_LAN mode - .put("OnlyPrimaryDNS", true) - } - IOUtils.writeString(new File(getFilesDir, file), config.toString) - file - } - - override def killProcesses() { - super.killProcesses() - if (overtureProcess != null) { - overtureProcess.destroy() - overtureProcess = null - } - } -} diff --git a/mobile/src/main/scala/com/github/shadowsocks/bg/ProxyService.scala b/mobile/src/main/scala/com/github/shadowsocks/bg/ProxyService.scala deleted file mode 100644 index 44fdf2c3..00000000 --- a/mobile/src/main/scala/com/github/shadowsocks/bg/ProxyService.scala +++ /dev/null @@ -1,12 +0,0 @@ -package com.github.shadowsocks.bg - -/** - * Shadowsocks service at its minimum. - * - * @author Mygod - */ -class ProxyService extends BaseService { - val TAG = "ShadowsocksProxyService" - - def createNotification() = new ServiceNotification(this, profile.name, "service-proxy", true) -} diff --git a/mobile/src/main/scala/com/github/shadowsocks/bg/ServiceNotification.scala b/mobile/src/main/scala/com/github/shadowsocks/bg/ServiceNotification.scala deleted file mode 100644 index e8ced018..00000000 --- a/mobile/src/main/scala/com/github/shadowsocks/bg/ServiceNotification.scala +++ /dev/null @@ -1,113 +0,0 @@ -/*******************************************************************************/ -/* */ -/* Copyright (C) 2017 by Max Lv */ -/* Copyright (C) 2017 by Mygod Studio */ -/* */ -/* This program is free software: you can redistribute it and/or modify */ -/* it under the terms of the GNU General Public License as published by */ -/* the Free Software Foundation, either version 3 of the License, or */ -/* (at your option) any later version. */ -/* */ -/* This program is distributed in the hope that it will be useful, */ -/* but WITHOUT ANY WARRANTY; without even the implied warranty of */ -/* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the */ -/* GNU General Public License for more details. */ -/* */ -/* You should have received a copy of the GNU General Public License */ -/* along with this program. If not, see . */ -/* */ -/*******************************************************************************/ - -package com.github.shadowsocks.bg - -import java.util.Locale - -import android.app.{KeyguardManager, NotificationManager, PendingIntent} -import android.content.{BroadcastReceiver, Context, Intent, IntentFilter} -import android.os.{Build, PowerManager} -import android.support.v4.app.NotificationCompat -import android.support.v4.app.NotificationCompat.BigTextStyle -import android.support.v4.content.ContextCompat -import com.github.shadowsocks.aidl.IShadowsocksServiceCallback.Stub -import com.github.shadowsocks.utils.{Action, Utils} -import com.github.shadowsocks.{MainActivity, R} - -/** - * @author Mygod - */ -class ServiceNotification(private val service: BaseService, profileName: String, - channel: String, visible: Boolean = false) { - private val keyGuard = service.getSystemService(Context.KEYGUARD_SERVICE).asInstanceOf[KeyguardManager] - private lazy val nm = service.getSystemService(Context.NOTIFICATION_SERVICE).asInstanceOf[NotificationManager] - private lazy val callback = new Stub { - override def stateChanged(state: Int, profileName: String, msg: String): Unit = () // ignore - override def trafficUpdated(profileId: Int, txRate: Long, rxRate: Long, txTotal: Long, rxTotal: Long) { - val txr = TrafficMonitor.formatTraffic(txRate) - val rxr = TrafficMonitor.formatTraffic(rxRate) - builder.setContentText(service.getString(R.string.traffic_summary).formatLocal(Locale.ENGLISH, txr, rxr)) - style.bigText(service.getString(R.string.stat_summary).formatLocal(Locale.ENGLISH, txr, rxr, - TrafficMonitor.formatTraffic(txTotal), TrafficMonitor.formatTraffic(rxTotal))) - show() - } - override def trafficPersisted(profileId: Int): Unit = () - } - private var lockReceiver: BroadcastReceiver = _ - private var callbackRegistered: Boolean = _ - - private val builder = new NotificationCompat.Builder(service, channel) - .setWhen(0) - .setColor(ContextCompat.getColor(service, R.color.material_primary_500)) - .setTicker(service.getString(R.string.forward_success)) - .setContentTitle(profileName) - .setContentIntent(PendingIntent.getActivity(service, 0, new Intent(service, classOf[MainActivity]) - .setFlags(Intent.FLAG_ACTIVITY_REORDER_TO_FRONT), 0)) - .setSmallIcon(R.drawable.ic_stat_shadowsocks) - if (Build.VERSION.SDK_INT < 24) builder.addAction(R.drawable.ic_navigation_close, - service.getString(R.string.stop), PendingIntent.getBroadcast(service, 0, new Intent(Action.CLOSE), 0)) - private lazy val style = new BigTextStyle(builder) - private var isVisible = true - update(if (service.getSystemService(Context.POWER_SERVICE).asInstanceOf[PowerManager].isScreenOn) - Intent.ACTION_SCREEN_ON else Intent.ACTION_SCREEN_OFF, forceShow = true) - lockReceiver = (_: Context, intent: Intent) => update(intent.getAction) - val screenFilter = new IntentFilter() - screenFilter.addAction(Intent.ACTION_SCREEN_ON) - screenFilter.addAction(Intent.ACTION_SCREEN_OFF) - if (visible && (21 until 26 contains Build.VERSION.SDK_INT)) screenFilter.addAction(Intent.ACTION_USER_PRESENT) - service.registerReceiver(lockReceiver, screenFilter) - - private def update(action: String, forceShow: Boolean = false) = - if (forceShow || service.getState == ServiceState.CONNECTED) action match { - case Intent.ACTION_SCREEN_OFF => - setVisible(visible && Build.VERSION.SDK_INT < 21, forceShow) - unregisterCallback() // unregister callback to save battery - case Intent.ACTION_SCREEN_ON => - setVisible(visible && (Build.VERSION.SDK_INT < 21 || !keyGuard.inKeyguardRestrictedInputMode), forceShow) - service.binder.registerCallback(callback) - service.binder.startListeningForBandwidth(callback) - callbackRegistered = true - case Intent.ACTION_USER_PRESENT => setVisible(visible = true, forceShow = forceShow) - } - - private def unregisterCallback() = if (callbackRegistered) { - service.binder.unregisterCallback(callback) - callbackRegistered = false - } - - def setVisible(visible: Boolean, forceShow: Boolean = false): Unit = if (isVisible != visible) { - isVisible = visible - builder.setPriority(if (visible) NotificationCompat.PRIORITY_LOW else NotificationCompat.PRIORITY_MIN) - show() - } else if (forceShow) show() - - def show(): Unit = service.startForeground(1, builder.build) - - def destroy() { - if (lockReceiver != null) { - service.unregisterReceiver(lockReceiver) - lockReceiver = null - } - unregisterCallback() - service.stopForeground(true) - nm.cancel(1) - } -} diff --git a/mobile/src/main/scala/com/github/shadowsocks/bg/ServiceState.scala b/mobile/src/main/scala/com/github/shadowsocks/bg/ServiceState.scala deleted file mode 100644 index 4b84a790..00000000 --- a/mobile/src/main/scala/com/github/shadowsocks/bg/ServiceState.scala +++ /dev/null @@ -1,16 +0,0 @@ -package com.github.shadowsocks.bg - -/** - * @author Mygod - */ -object ServiceState { - /** - * This state will never be broadcast by the service. This state is only used to indicate that the current context - * hasn't bound to any context. - */ - val IDLE = 0 - val CONNECTING = 1 - val CONNECTED = 2 - val STOPPING = 3 - val STOPPED = 4 -} diff --git a/mobile/src/main/scala/com/github/shadowsocks/bg/ServiceTileService.scala b/mobile/src/main/scala/com/github/shadowsocks/bg/ServiceTileService.scala deleted file mode 100644 index 7b3ee43a..00000000 --- a/mobile/src/main/scala/com/github/shadowsocks/bg/ServiceTileService.scala +++ /dev/null @@ -1,81 +0,0 @@ -/*******************************************************************************/ -/* */ -/* Copyright (C) 2017 by Max Lv */ -/* Copyright (C) 2017 by Mygod Studio */ -/* */ -/* This program is free software: you can redistribute it and/or modify */ -/* it under the terms of the GNU General Public License as published by */ -/* the Free Software Foundation, either version 3 of the License, or */ -/* (at your option) any later version. */ -/* */ -/* This program is distributed in the hope that it will be useful, */ -/* but WITHOUT ANY WARRANTY; without even the implied warranty of */ -/* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the */ -/* GNU General Public License for more details. */ -/* */ -/* You should have received a copy of the GNU General Public License */ -/* along with this program. If not, see . */ -/* */ -/*******************************************************************************/ - -package com.github.shadowsocks.bg - -import android.annotation.TargetApi -import android.graphics.drawable.Icon -import android.service.quicksettings.{Tile, TileService} -import com.github.shadowsocks.aidl.IShadowsocksServiceCallback -import com.github.shadowsocks.utils.Utils -import com.github.shadowsocks.{R, ServiceBoundContext} - -/** - * @author Mygod - */ -@TargetApi(24) -final class ServiceTileService extends TileService with ServiceBoundContext { - private lazy val iconIdle = Icon.createWithResource(this, R.drawable.ic_start_idle).setTint(0x80ffffff) - private lazy val iconBusy = Icon.createWithResource(this, R.drawable.ic_start_busy) - private lazy val iconConnected = Icon.createWithResource(this, R.drawable.ic_start_connected) - private lazy val callback = new IShadowsocksServiceCallback.Stub { - def trafficUpdated(profileId: Int, txRate: Long, rxRate: Long, txTotal: Long, rxTotal: Long): Unit = () - def stateChanged(state: Int, profileName: String, msg: String) { - val tile = getQsTile - if (tile != null) { - state match { - case ServiceState.STOPPED => - tile.setIcon(iconIdle) - tile.setLabel(getString(R.string.app_name)) - tile.setState(Tile.STATE_INACTIVE) - case ServiceState.CONNECTED => - tile.setIcon(iconConnected) - tile.setLabel(if (profileName == null) getString(R.string.app_name) else profileName) - tile.setState(Tile.STATE_ACTIVE) - case _ => - tile.setIcon(iconBusy) - tile.setLabel(getString(R.string.app_name)) - tile.setState(Tile.STATE_UNAVAILABLE) - } - tile.updateTile() - } - } - override def trafficPersisted(profileId: Int): Unit = () - } - - override def onServiceConnected(): Unit = callback.stateChanged(bgService.getState, bgService.getProfileName, null) - - override def onStartListening() { - super.onStartListening() - attachService(callback) - } - override def onStopListening() { - super.onStopListening() - detachService() // just in case the user switches to NAT mode, also saves battery - } - - override def onClick(): Unit = if (isLocked) unlockAndRun(toggle) else toggle() - - private def toggle() = if (bgService != null) bgService.getState match { - case ServiceState.STOPPED => Utils.startSsService(this) - case ServiceState.CONNECTED => Utils.stopSsService(this) - case _ => // ignore - } -} diff --git a/mobile/src/main/scala/com/github/shadowsocks/bg/TrafficMonitor.scala b/mobile/src/main/scala/com/github/shadowsocks/bg/TrafficMonitor.scala deleted file mode 100644 index f3ed96cd..00000000 --- a/mobile/src/main/scala/com/github/shadowsocks/bg/TrafficMonitor.scala +++ /dev/null @@ -1,103 +0,0 @@ -/*******************************************************************************/ -/* */ -/* Copyright (C) 2017 by Max Lv */ -/* Copyright (C) 2017 by Mygod Studio */ -/* */ -/* This program is free software: you can redistribute it and/or modify */ -/* it under the terms of the GNU General Public License as published by */ -/* the Free Software Foundation, either version 3 of the License, or */ -/* (at your option) any later version. */ -/* */ -/* This program is distributed in the hope that it will be useful, */ -/* but WITHOUT ANY WARRANTY; without even the implied warranty of */ -/* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the */ -/* GNU General Public License for more details. */ -/* */ -/* You should have received a copy of the GNU General Public License */ -/* along with this program. If not, see . */ -/* */ -/*******************************************************************************/ - -package com.github.shadowsocks.bg - -import java.text.DecimalFormat - -import com.github.shadowsocks.R -import com.github.shadowsocks.ShadowsocksApplication.app - -object TrafficMonitor { - // Bytes per second - var txRate: Long = _ - var rxRate: Long = _ - - // Bytes for the current session - var txTotal: Long = _ - var rxTotal: Long = _ - - // Bytes for the last query - var txLast: Long = _ - var rxLast: Long = _ - var timestampLast: Long = _ - @volatile var dirty = true - - private val units = Array("KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB", "BB", "NB", "DB", "CB") - private val numberFormat = new DecimalFormat("@@@") - def formatTraffic(size: Long): String = { - var n: Double = size - var i = -1 - while (n >= 999.5) { - n /= 1024 - i = i + 1 - } - if (i < 0) size + " " + app.getResources.getQuantityString(R.plurals.bytes, size.toInt) - else numberFormat.format(n) + ' ' + units(i) - } - - def updateRate(): Boolean = { - val now = System.currentTimeMillis() - val delta = now - timestampLast - var updated = false - if (delta != 0) { - if (dirty) { - txRate = (txTotal - txLast) * 1000 / delta - rxRate = (rxTotal - rxLast) * 1000 / delta - txLast = txTotal - rxLast = rxTotal - dirty = false - updated = true - } else { - if (txRate != 0) { - txRate = 0 - updated = true - } - if (rxRate != 0) { - rxRate = 0 - updated = true - } - } - timestampLast = now - } - updated - } - - def update(tx: Long, rx: Long) { - if (txTotal != tx) { - txTotal = tx - dirty = true - } - if (rxTotal != rx) { - rxTotal = rx - dirty = true - } - } - - def reset() { - txRate = 0 - rxRate = 0 - txTotal = 0 - rxTotal = 0 - txLast = 0 - rxLast = 0 - dirty = true - } -} diff --git a/mobile/src/main/scala/com/github/shadowsocks/bg/TrafficMonitorThread.scala b/mobile/src/main/scala/com/github/shadowsocks/bg/TrafficMonitorThread.scala deleted file mode 100644 index d1d02635..00000000 --- a/mobile/src/main/scala/com/github/shadowsocks/bg/TrafficMonitorThread.scala +++ /dev/null @@ -1,113 +0,0 @@ -/*******************************************************************************/ -/* */ -/* Copyright (C) 2017 by Max Lv */ -/* Copyright (C) 2017 by Mygod Studio */ -/* */ -/* This program is free software: you can redistribute it and/or modify */ -/* it under the terms of the GNU General Public License as published by */ -/* the Free Software Foundation, either version 3 of the License, or */ -/* (at your option) any later version. */ -/* */ -/* This program is distributed in the hope that it will be useful, */ -/* but WITHOUT ANY WARRANTY; without even the implied warranty of */ -/* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the */ -/* GNU General Public License for more details. */ -/* */ -/* You should have received a copy of the GNU General Public License */ -/* along with this program. If not, see . */ -/* */ -/*******************************************************************************/ - -package com.github.shadowsocks.bg - -import java.io.{File, IOException} -import java.nio.{ByteBuffer, ByteOrder} -import java.util.concurrent.Executors - -import android.content.Context -import android.net.{LocalServerSocket, LocalSocket, LocalSocketAddress} -import android.util.Log -import com.github.shadowsocks.ShadowsocksApplication.app - -class TrafficMonitorThread(context: Context) extends Thread { - - val TAG = "TrafficMonitorThread" - val stat = new File(context.getFilesDir, "/stat_path") - - @volatile var serverSocket: LocalServerSocket = _ - @volatile var isRunning: Boolean = true - - def closeServerSocket() { - if (serverSocket != null) { - try { - serverSocket.close() - } catch { - case _: Exception => // ignore - } - serverSocket = null - } - } - - def stopThread() { - isRunning = false - closeServerSocket() - } - - override def run() { - - stat.delete() - - try { - val localSocket = new LocalSocket - localSocket.bind(new LocalSocketAddress(stat.getAbsolutePath, LocalSocketAddress.Namespace.FILESYSTEM)) - serverSocket = new LocalServerSocket(localSocket.getFileDescriptor) - } catch { - case e: IOException => - Log.e(TAG, "unable to bind", e) - return - } - - val pool = Executors.newFixedThreadPool(1) - - while (isRunning) { - try { - val socket = serverSocket.accept() - - pool.execute(() => { - try { - val input = socket.getInputStream - val output = socket.getOutputStream - - val buffer = new Array[Byte](16) - if (input.read(buffer) != 16) throw new IOException("Unexpected traffic stat length") - val stat = ByteBuffer.wrap(buffer).order(ByteOrder.LITTLE_ENDIAN) - TrafficMonitor.update(stat.getLong(0), stat.getLong(8)) - - output.write(0) - - input.close() - output.close() - - } catch { - case e: Exception => - Log.e(TAG, "Error when recv traffic stat", e) - app.track(e) - } - - // close socket - try { - socket.close() - } catch { - case _: Exception => // ignore - } - - }) - } catch { - case e: IOException => - Log.e(TAG, "Error when accept socket", e) - app.track(e) - return - } - } - } -} diff --git a/mobile/src/main/scala/com/github/shadowsocks/bg/TransproxyService.scala b/mobile/src/main/scala/com/github/shadowsocks/bg/TransproxyService.scala deleted file mode 100644 index 6e3cea28..00000000 --- a/mobile/src/main/scala/com/github/shadowsocks/bg/TransproxyService.scala +++ /dev/null @@ -1,97 +0,0 @@ -/*******************************************************************************/ -/* */ -/* Copyright (C) 2017 by Max Lv */ -/* Copyright (C) 2017 by Mygod Studio */ -/* */ -/* This program is free software: you can redistribute it and/or modify */ -/* it under the terms of the GNU General Public License as published by */ -/* the Free Software Foundation, either version 3 of the License, or */ -/* (at your option) any later version. */ -/* */ -/* This program is distributed in the hope that it will be useful, */ -/* but WITHOUT ANY WARRANTY; without even the implied warranty of */ -/* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the */ -/* GNU General Public License for more details. */ -/* */ -/* You should have received a copy of the GNU General Public License */ -/* along with this program. If not, see . */ -/* */ -/*******************************************************************************/ - -package com.github.shadowsocks.bg - -import java.io.File -import java.util.Locale - -import com.github.shadowsocks.GuardedProcess -import com.github.shadowsocks.ShadowsocksApplication.app -import com.github.shadowsocks.utils.IOUtils - -import scala.collection.mutable.ArrayBuffer - -object TransproxyService { - private val REDSOCKS_CONFIG = "base {\n" + - " log_debug = off;\n" + - " log_info = off;\n" + - " log = stderr;\n" + - " daemon = off;\n" + - " redirector = iptables;\n" + - "}\n" + - "redsocks {\n" + - " local_ip = 127.0.0.1;\n" + - " local_port = %d;\n" + - " ip = 127.0.0.1;\n" + - " port = %d;\n" + - " type = socks5;\n" + - "}\n" -} - -class TransproxyService extends LocalDnsService { - import TransproxyService._ - - val TAG = "ShadowsocksTransproxyService" - - var sstunnelProcess: GuardedProcess = _ - var redsocksProcess: GuardedProcess = _ - - def startDNSTunnel() { - val cmd = ArrayBuffer[String](new File(getApplicationInfo.nativeLibraryDir, Executable.SS_TUNNEL).getAbsolutePath, - "-t", "10", - "-b", "127.0.0.1", - "-u", - "-l", app.dataStore.portLocalDns.toString, // ss-tunnel listens on the same port as overture - "-L", profile.remoteDns.split(",").head.trim + ":53", - "-c", "shadowsocks.json") // config is already built by BaseService - - sstunnelProcess = new GuardedProcess(cmd: _*).start() - } - - def startRedsocksDaemon() { - IOUtils.writeString(new File(getFilesDir, "redsocks.conf"), - REDSOCKS_CONFIG.formatLocal(Locale.ENGLISH, app.dataStore.portTransproxy, app.dataStore.portProxy)) - redsocksProcess = new GuardedProcess( - new File(getApplicationInfo.nativeLibraryDir, Executable.REDSOCKS).getAbsolutePath, - "-c", "redsocks.conf" - ).start() - } - - override def startNativeProcesses() { - startRedsocksDaemon() - super.startNativeProcesses() - if (profile.udpdns) startDNSTunnel() - } - - override def killProcesses() { - super.killProcesses() - if (sstunnelProcess != null) { - sstunnelProcess.destroy() - sstunnelProcess = null - } - if (redsocksProcess != null) { - redsocksProcess.destroy() - redsocksProcess = null - } - } - - def createNotification() = new ServiceNotification(this, profile.name, "service-transproxy", true) -} diff --git a/mobile/src/main/scala/com/github/shadowsocks/bg/VpnService.scala b/mobile/src/main/scala/com/github/shadowsocks/bg/VpnService.scala deleted file mode 100644 index 797d1eb6..00000000 --- a/mobile/src/main/scala/com/github/shadowsocks/bg/VpnService.scala +++ /dev/null @@ -1,194 +0,0 @@ -/*******************************************************************************/ -/* */ -/* Copyright (C) 2017 by Max Lv */ -/* Copyright (C) 2017 by Mygod Studio */ -/* */ -/* This program is free software: you can redistribute it and/or modify */ -/* it under the terms of the GNU General Public License as published by */ -/* the Free Software Foundation, either version 3 of the License, or */ -/* (at your option) any later version. */ -/* */ -/* This program is distributed in the hope that it will be useful, */ -/* but WITHOUT ANY WARRANTY; without even the implied warranty of */ -/* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the */ -/* GNU General Public License for more details. */ -/* */ -/* You should have received a copy of the GNU General Public License */ -/* along with this program. If not, see . */ -/* */ -/*******************************************************************************/ - -package com.github.shadowsocks.bg - -import java.io.File -import java.util.Locale - -import android.app.Service -import android.content.Intent -import android.content.pm.PackageManager.NameNotFoundException -import android.net.{VpnService => BaseVpnService} -import android.os.{IBinder, ParcelFileDescriptor} -import android.util.Log -import com.github.shadowsocks.ShadowsocksApplication.app -import com.github.shadowsocks._ -import com.github.shadowsocks.acl.{Acl, Subnet} -import com.github.shadowsocks.utils.Utils - -import scala.collection.mutable.ArrayBuffer - -class VpnService extends BaseVpnService with LocalDnsService { - val TAG = "ShadowsocksVpnService" - val VPN_MTU = 1500 - val PRIVATE_VLAN = "26.26.26.%s" - val PRIVATE_VLAN6 = "fdfe:dcba:9876::%s" - var conn: ParcelFileDescriptor = _ - var vpnThread: VpnThread = _ - - var tun2socksProcess: GuardedProcess = _ - - override def onBind(intent: Intent): IBinder = intent.getAction match { - case BaseVpnService.SERVICE_INTERFACE => super[VpnService].onBind(intent) - case _ => super[LocalDnsService].onBind(intent) - } - - override def onRevoke() { - stopRunner(stopService = true) - } - - override def killProcesses() { - if (vpnThread != null) { - vpnThread.stopThread() - vpnThread = null - } - super.killProcesses() - if (tun2socksProcess != null) { - tun2socksProcess.destroy() - tun2socksProcess = null - } - if (overtureProcess != null) { - overtureProcess.destroy() - overtureProcess = null - } - // close connections - if (conn != null) { - conn.close() - conn = null - } - } - - override def onStartCommand(intent: Intent, flags: Int, startId: Int): Int = if (app.usingVpnMode) - // ensure the VPNService is prepared - if (BaseVpnService.prepare(this) != null) { - val i = new Intent(this, classOf[ShadowsocksRunnerActivity]) - i.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - startActivity(i) - stopRunner(stopService = true) - Service.START_NOT_STICKY - } else super.onStartCommand(intent, flags, startId) - else { // system or other apps are trying to start this service but other services could be running - stopSelf() - Service.START_NOT_STICKY - } - - override def createNotification() = new ServiceNotification(this, profile.name, "service-vpn") - - /** Called when the activity is first created. */ - override def startNativeProcesses() { - vpnThread = new VpnThread(this) - vpnThread.start() - - super.startNativeProcesses() - - val fd = startVpn() - if (!sendFd(fd)) throw new Exception("sendFd failed") - } - - override protected def buildAdditionalArguments(cmd: ArrayBuffer[String]): ArrayBuffer[String] = cmd += "-V" - - def startVpn(): Int = { - - val builder = new Builder() - builder - .setSession(profile.getName) - .setMtu(VPN_MTU) - .addAddress(PRIVATE_VLAN.formatLocal(Locale.ENGLISH, "1"), 24) - - profile.remoteDns.split(",").foreach(dns => builder.addDnsServer(dns.trim)) - - if (profile.ipv6) { - builder.addAddress(PRIVATE_VLAN6.formatLocal(Locale.ENGLISH, "1"), 126) - builder.addRoute("::", 0) - } - - if (Utils.isLollipopOrAbove) { - - if (profile.proxyApps) { - for (pkg <- profile.individual.split('\n')) { - try { - if (!profile.bypass) { - builder.addAllowedApplication(pkg) - } else { - builder.addDisallowedApplication(pkg) - } - } catch { - case ex: NameNotFoundException => - Log.e(TAG, "Invalid package name", ex) - } - } - } - } - - if (profile.route == Acl.ALL - || profile.route == Acl.BYPASS_CHN - || profile.route == Acl.CUSTOM_RULES) { - builder.addRoute("0.0.0.0", 0) - } else { - getResources.getStringArray(R.array.bypass_private_route).foreach(cidr => { - val subnet = Subnet.fromString(cidr) - builder.addRoute(subnet.address.getHostAddress, subnet.prefixSize) - }) - profile.remoteDns.split(",").map(dns => Utils.parseNumericAddress(dns.trim)).foreach(dns => - builder.addRoute(dns, dns.getAddress.length << 3)) - } - - conn = builder.establish() - if (conn == null) throw new NullConnectionException - - val fd = conn.getFd - - var cmd = ArrayBuffer[String](new File(getApplicationInfo.nativeLibraryDir, Executable.TUN2SOCKS).getAbsolutePath, - "--netif-ipaddr", PRIVATE_VLAN.formatLocal(Locale.ENGLISH, "2"), - "--netif-netmask", "255.255.255.0", - "--socks-server-addr", "127.0.0.1:" + app.dataStore.portProxy, - "--tunfd", fd.toString, - "--tunmtu", VPN_MTU.toString, - "--sock-path", "sock_path", - "--loglevel", "3") - - if (profile.ipv6) - cmd += ("--netif-ip6addr", PRIVATE_VLAN6.formatLocal(Locale.ENGLISH, "2")) - - cmd += "--enable-udprelay" - - if (!profile.udpdns) - cmd += ("--dnsgw", "%s:%d".formatLocal(Locale.ENGLISH, "127.0.0.1", app.dataStore.portLocalDns)) - - tun2socksProcess = new GuardedProcess(cmd: _*).start(() => sendFd(fd)) - - fd - } - - def sendFd(fd: Int): Boolean = { - if (fd != -1) { - var tries = 1 - while (tries < 5) { - Thread.sleep(1000 * tries) - if (JniHelper.sendFd(fd, new File(getFilesDir, "sock_path").getAbsolutePath) != -1) { - return true - } - tries += 1 - } - } - false - } -} diff --git a/mobile/src/main/scala/com/github/shadowsocks/bg/VpnThread.scala b/mobile/src/main/scala/com/github/shadowsocks/bg/VpnThread.scala deleted file mode 100644 index 337fec65..00000000 --- a/mobile/src/main/scala/com/github/shadowsocks/bg/VpnThread.scala +++ /dev/null @@ -1,130 +0,0 @@ -/*******************************************************************************/ -/* */ -/* Copyright (C) 2017 by Max Lv */ -/* Copyright (C) 2017 by Mygod Studio */ -/* */ -/* This program is free software: you can redistribute it and/or modify */ -/* it under the terms of the GNU General Public License as published by */ -/* the Free Software Foundation, either version 3 of the License, or */ -/* (at your option) any later version. */ -/* */ -/* This program is distributed in the hope that it will be useful, */ -/* but WITHOUT ANY WARRANTY; without even the implied warranty of */ -/* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the */ -/* GNU General Public License for more details. */ -/* */ -/* You should have received a copy of the GNU General Public License */ -/* along with this program. If not, see . */ -/* */ -/*******************************************************************************/ - -package com.github.shadowsocks.bg - -import java.io.{File, FileDescriptor, IOException} -import java.lang.reflect.Method -import java.util.concurrent.Executors - -import android.net.{LocalServerSocket, LocalSocket, LocalSocketAddress} -import android.util.Log -import com.github.shadowsocks.JniHelper -import com.github.shadowsocks.ShadowsocksApplication.app - -object VpnThread { - val getInt: Method = classOf[FileDescriptor].getDeclaredMethod("getInt$") -} - -class VpnThread(service: VpnService) extends Thread { - import VpnThread._ - - val TAG = "ShadowsocksVpnService" - val protect = new File(service.getFilesDir, "protect_path") - - @volatile var isRunning: Boolean = true - @volatile var serverSocket: LocalServerSocket = _ - - def closeServerSocket() { - if (serverSocket != null) { - try { - serverSocket.close() - } catch { - case _: Exception => // ignore - } - serverSocket = null - } - } - - def stopThread() { - isRunning = false - closeServerSocket() - } - - override def run() { - - protect.delete() - - try { - val localSocket = new LocalSocket - localSocket.bind(new LocalSocketAddress(protect.getAbsolutePath, LocalSocketAddress.Namespace.FILESYSTEM)) - serverSocket = new LocalServerSocket(localSocket.getFileDescriptor) - } catch { - case e: IOException => - Log.e(TAG, "unable to bind", e) - app.track(e) - return - } - - val pool = Executors.newFixedThreadPool(4) - - while (isRunning) { - try { - val socket = serverSocket.accept() - - pool.execute(() => { - try { - val input = socket.getInputStream - val output = socket.getOutputStream - - input.read() - - val fds = socket.getAncillaryFileDescriptors - - if (fds.nonEmpty) { - val fd = getInt.invoke(fds(0)).asInstanceOf[Int] - val ret = service.protect(fd) - - // Trick to close file decriptor - JniHelper.close(fd) - - if (ret) { - output.write(0) - } else { - output.write(1) - } - } - - input.close() - output.close() - - } catch { - case e: Exception => - Log.e(TAG, "Error when protect socket", e) - app.track(e) - } - - // close socket - try { - socket.close() - } catch { - case _: Exception => // ignore - } - - }) - } catch { - case e: IOException => - Log.e(TAG, "Error when accept socket", e) - app.track(e) - return - } - } - } -} diff --git a/mobile/src/main/scala/com/github/shadowsocks/database/DBHelper.scala b/mobile/src/main/scala/com/github/shadowsocks/database/DBHelper.scala deleted file mode 100644 index f92e1c3d..00000000 --- a/mobile/src/main/scala/com/github/shadowsocks/database/DBHelper.scala +++ /dev/null @@ -1,166 +0,0 @@ -/*******************************************************************************/ -/* */ -/* Copyright (C) 2017 by Max Lv */ -/* Copyright (C) 2017 by Mygod Studio */ -/* */ -/* This program is free software: you can redistribute it and/or modify */ -/* it under the terms of the GNU General Public License as published by */ -/* the Free Software Foundation, either version 3 of the License, or */ -/* (at your option) any later version. */ -/* */ -/* This program is distributed in the hope that it will be useful, */ -/* but WITHOUT ANY WARRANTY; without even the implied warranty of */ -/* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the */ -/* GNU General Public License for more details. */ -/* */ -/* You should have received a copy of the GNU General Public License */ -/* along with this program. If not, see . */ -/* */ -/*******************************************************************************/ - -package com.github.shadowsocks.database - -import java.nio.ByteBuffer - -import android.content.Context -import android.content.pm.ApplicationInfo -import android.database.sqlite.SQLiteDatabase -import android.preference.PreferenceManager -import com.github.shadowsocks.ShadowsocksApplication.app -import com.github.shadowsocks.utils.Key -import com.j256.ormlite.android.AndroidDatabaseConnection -import com.j256.ormlite.android.apptools.OrmLiteSqliteOpenHelper -import com.j256.ormlite.dao.Dao -import com.j256.ormlite.support.ConnectionSource -import com.j256.ormlite.table.TableUtils - -import scala.collection.JavaConverters._ -import scala.collection.mutable - -object DBHelper { - final val PROFILE = "profile.db" - private var apps: mutable.Buffer[ApplicationInfo] = _ - - def isAllDigits(x: String): Boolean = !x.isEmpty && (x forall Character.isDigit) - - def updateProxiedApps(context: Context, old: String): String = { - synchronized(if (apps == null) apps = context.getPackageManager.getInstalledApplications(0).asScala) - val uidSet = old.split('|').filter(isAllDigits).map(_.toInt).toSet - apps.filter(ai => uidSet.contains(ai.uid)).map(_.packageName).mkString("\n") - } -} - -class DBHelper(val context: Context) - extends OrmLiteSqliteOpenHelper(context, DBHelper.PROFILE, null, 24) { - import DBHelper._ - - lazy val profileDao: Dao[Profile, Int] = getDao(classOf[Profile]) - lazy val kvPairDao: Dao[KeyValuePair, String] = getDao(classOf[KeyValuePair]) - - def onCreate(database: SQLiteDatabase, connectionSource: ConnectionSource) { - TableUtils.createTable(connectionSource, classOf[Profile]) - TableUtils.createTable(connectionSource, classOf[KeyValuePair]) - } - - def recreate(database: SQLiteDatabase, connectionSource: ConnectionSource) { - TableUtils.dropTable(connectionSource, classOf[Profile], true) - TableUtils.dropTable(connectionSource, classOf[KeyValuePair], true) - onCreate(database, connectionSource) - } - - def onUpgrade(database: SQLiteDatabase, connectionSource: ConnectionSource, oldVersion: Int, newVersion: Int) { - if (oldVersion < 7) { - recreate(database, connectionSource) - return - } - - try { - if (oldVersion < 8) { - profileDao.executeRawNoArgs("ALTER TABLE `profile` ADD COLUMN udpdns SMALLINT;") - } - if (oldVersion < 9) { - profileDao.executeRawNoArgs("ALTER TABLE `profile` ADD COLUMN route VARCHAR DEFAULT 'all';") - } else if (oldVersion < 19) { - profileDao.executeRawNoArgs("UPDATE `profile` SET route = 'all' WHERE route IS NULL;") - } - if (oldVersion < 11) { - profileDao.executeRawNoArgs("ALTER TABLE `profile` ADD COLUMN ipv6 SMALLINT;") - } - if (oldVersion < 12) { - profileDao.executeRawNoArgs("BEGIN TRANSACTION;") - profileDao.executeRawNoArgs("ALTER TABLE `profile` RENAME TO `tmp`;") - TableUtils.createTable(connectionSource, classOf[Profile]) - profileDao.executeRawNoArgs( - "INSERT INTO `profile`(id, name, host, localPort, remotePort, password, method, route, proxyApps, bypass," + - " udpdns, ipv6, individual) " + - "SELECT id, name, host, localPort, remotePort, password, method, route, 1 - global, bypass, udpdns, ipv6," + - " individual FROM `tmp`;") - profileDao.executeRawNoArgs("DROP TABLE `tmp`;") - profileDao.executeRawNoArgs("COMMIT;") - } else if (oldVersion < 13) { - profileDao.executeRawNoArgs("ALTER TABLE `profile` ADD COLUMN tx LONG;") - profileDao.executeRawNoArgs("ALTER TABLE `profile` ADD COLUMN rx LONG;") - profileDao.executeRawNoArgs("ALTER TABLE `profile` ADD COLUMN date VARCHAR;") - } - - if (oldVersion < 15) { - if (oldVersion >= 12) profileDao.executeRawNoArgs("ALTER TABLE `profile` ADD COLUMN userOrder LONG;") - var i = 0 - for (profile <- profileDao.queryForAll.asScala) { - if (oldVersion < 14) profile.individual = updateProxiedApps(context, profile.individual) - profile.userOrder = i - profileDao.update(profile) - i += 1 - } - } - - if (oldVersion < 16) { - profileDao.executeRawNoArgs("UPDATE `profile` SET route = 'bypass-lan-china' WHERE route = 'bypass-china'") - } - - if (oldVersion < 21) { - profileDao.executeRawNoArgs("ALTER TABLE `profile` ADD COLUMN remoteDns VARCHAR DEFAULT '8.8.8.8';") - } - - if (oldVersion < 17) { - profileDao.executeRawNoArgs("ALTER TABLE `profile` ADD COLUMN plugin VARCHAR;") - } else if (oldVersion < 22) { - // upgrade kcptun to SIP003 plugin - profileDao.executeRawNoArgs("BEGIN TRANSACTION;") - profileDao.executeRawNoArgs("ALTER TABLE `profile` RENAME TO `tmp`;") - TableUtils.createTable(connectionSource, classOf[Profile]) - profileDao.executeRawNoArgs( - "INSERT INTO `profile`(id, name, host, localPort, remotePort, password, method, route, remoteDns, " + - "proxyApps, bypass, udpdns, ipv6, individual, tx, rx, date, userOrder, plugin) " + - "SELECT id, name, host, localPort, CASE WHEN kcp = 1 THEN kcpPort ELSE remotePort END, password, method, " + - "route, remoteDns, proxyApps, bypass, udpdns, ipv6, individual, tx, rx, date, userOrder, " + - "CASE WHEN kcp = 1 THEN 'kcptun ' || kcpcli ELSE NULL END FROM `tmp`;") - profileDao.executeRawNoArgs("DROP TABLE `tmp`;") - profileDao.executeRawNoArgs("COMMIT;") - } - - if (oldVersion < 23) { - profileDao.executeRawNoArgs("BEGIN TRANSACTION;") - TableUtils.createTable(connectionSource, classOf[KeyValuePair]) - profileDao.executeRawNoArgs("COMMIT;") - import KeyValuePair._ - val old = PreferenceManager.getDefaultSharedPreferences(app) - kvPairDao.createOrUpdate(new KeyValuePair(Key.id, TYPE_INT, - ByteBuffer.allocate(4).putInt(old.getInt(Key.id, 0)).array())) - kvPairDao.createOrUpdate(new KeyValuePair(Key.tfo, TYPE_BOOLEAN, - ByteBuffer.allocate(1).put((if (old.getBoolean(Key.tfo, false)) 1 else 0).toByte).array())) - } - } catch { - case ex: Exception => - app.track(ex) - recreate(database, connectionSource) - } - } - - override def onDowngrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) { - val connection = new AndroidDatabaseConnection(db, true) - connectionSource.saveSpecialConnection(connection) - recreate(db, connectionSource) - connectionSource.clearSpecialConnection(connection) - } -} diff --git a/mobile/src/main/scala/com/github/shadowsocks/database/KeyValuePair.scala b/mobile/src/main/scala/com/github/shadowsocks/database/KeyValuePair.scala deleted file mode 100644 index 625f8c41..00000000 --- a/mobile/src/main/scala/com/github/shadowsocks/database/KeyValuePair.scala +++ /dev/null @@ -1,32 +0,0 @@ -package com.github.shadowsocks.database - -import com.j256.ormlite.field.{DataType, DatabaseField} - -/** - * @author Mygod - */ -object KeyValuePair { - val TYPE_UNINITIALIZED = 0 - val TYPE_BOOLEAN = 1 - val TYPE_FLOAT = 2 - val TYPE_INT = 3 - val TYPE_LONG = 4 - val TYPE_STRING = 5 - val TYPE_STRING_SET = 6 -} - -class KeyValuePair { - @DatabaseField(id = true) - var key: String = _ - @DatabaseField - var valueType: Int = _ - @DatabaseField(dataType = DataType.BYTE_ARRAY) - var value: Array[Byte] = _ - - def this(key: String, valueType: Int, value: Array[Byte]) = { - this() - this.key = key - this.valueType = valueType - this.value = value - } -} diff --git a/mobile/src/main/scala/com/github/shadowsocks/database/Profile.scala b/mobile/src/main/scala/com/github/shadowsocks/database/Profile.scala deleted file mode 100644 index 67eb797f..00000000 --- a/mobile/src/main/scala/com/github/shadowsocks/database/Profile.scala +++ /dev/null @@ -1,138 +0,0 @@ -/*******************************************************************************/ -/* */ -/* Copyright (C) 2017 by Max Lv */ -/* Copyright (C) 2017 by Mygod Studio */ -/* */ -/* This program is free software: you can redistribute it and/or modify */ -/* it under the terms of the GNU General Public License as published by */ -/* the Free Software Foundation, either version 3 of the License, or */ -/* (at your option) any later version. */ -/* */ -/* This program is distributed in the hope that it will be useful, */ -/* but WITHOUT ANY WARRANTY; without even the implied warranty of */ -/* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the */ -/* GNU General Public License for more details. */ -/* */ -/* You should have received a copy of the GNU General Public License */ -/* along with this program. If not, see . */ -/* */ -/*******************************************************************************/ - -package com.github.shadowsocks.database - -import java.util.Locale - -import android.net.Uri -import android.util.Base64 -import com.github.shadowsocks.plugin.PluginConfiguration -import com.github.shadowsocks.preference.OrmLitePreferenceDataStore -import com.github.shadowsocks.utils.{Key, Utils} -import com.j256.ormlite.field.{DataType, DatabaseField} - -class Profile { - @DatabaseField(generatedId = true) - var id: Int = _ - - @DatabaseField - var name: String = "" - - @DatabaseField - var host: String = "198.199.101.152" - - @DatabaseField - var remotePort: Int = 8388 - - @DatabaseField - var password: String = "u1rRWTssNv0p" - - @DatabaseField - var method: String = "aes-256-cfb" - - @DatabaseField - var route: String = "all" - - @DatabaseField - var remoteDns: String = "8.8.8.8" - - @DatabaseField - var proxyApps: Boolean = _ - - @DatabaseField - var bypass: Boolean = _ - - @DatabaseField - var udpdns: Boolean = _ - - @DatabaseField - var ipv6: Boolean = _ - - @DatabaseField(dataType = DataType.LONG_STRING) - var individual: String = "" - - @DatabaseField - var tx: Long = _ - - @DatabaseField - var rx: Long = _ - - @DatabaseField - val date: java.util.Date = new java.util.Date() - - @DatabaseField - var userOrder: Long = _ - - @DatabaseField - var plugin: String = _ - - def formattedAddress: String = (if (host.contains(":")) "[%s]:%d" else "%s:%d").format(host, remotePort) - def nameIsEmpty: Boolean = name == null || name.isEmpty - def getName: String = if (nameIsEmpty) formattedAddress else name - - def toUri: Uri = { - val builder = new Uri.Builder() - .scheme("ss") - .encodedAuthority("%s@%s:%d".formatLocal(Locale.ENGLISH, - Base64.encodeToString("%s:%s".formatLocal(Locale.ENGLISH, method, password).getBytes, - Base64.NO_PADDING | Base64.NO_WRAP | Base64.URL_SAFE), - if (host.contains(':')) '[' + host + ']' else host, remotePort)) - val configuration = new PluginConfiguration(plugin) - if (configuration.selected.nonEmpty) - builder.appendQueryParameter(Key.plugin, configuration.selectedOptions.toString(false)) - if (!nameIsEmpty) builder.fragment(name) - builder.build() - } - override def toString: String = toUri.toString - - def serialize(store: OrmLitePreferenceDataStore) { - store.putString(Key.name, name) - store.putString(Key.host, host) - store.putString(Key.remotePort, remotePort.toString) - store.putString(Key.password, password) - store.putString(Key.route, route) - store.putString(Key.remoteDns, remoteDns) - store.putString(Key.method, method) - store.proxyApps = proxyApps - store.bypass = bypass - store.putBoolean(Key.udpdns, udpdns) - store.putBoolean(Key.ipv6, ipv6) - store.individual = individual - store.plugin = plugin - store.remove(Key.dirty) - } - def deserialize(store: OrmLitePreferenceDataStore) { - // It's assumed that default values are never used, so 0/false/null is always used even if that isn't the case - name = store.getString(Key.name, null) - host = store.getString(Key.host, null) - remotePort = Utils.parsePort(store.getString(Key.remotePort), 8388, 1) - password = store.getString(Key.password, null) - method = store.getString(Key.method, null) - route = store.getString(Key.route, null) - remoteDns = store.getString(Key.remoteDns, null) - proxyApps = store.proxyApps - bypass = store.bypass - udpdns = store.getBoolean(Key.udpdns, false) - ipv6 = store.getBoolean(Key.ipv6, false) - individual = store.individual - plugin = store.plugin - } -} diff --git a/mobile/src/main/scala/com/github/shadowsocks/database/ProfileManager.scala b/mobile/src/main/scala/com/github/shadowsocks/database/ProfileManager.scala deleted file mode 100644 index 90dbf17f..00000000 --- a/mobile/src/main/scala/com/github/shadowsocks/database/ProfileManager.scala +++ /dev/null @@ -1,124 +0,0 @@ -/*******************************************************************************/ -/* */ -/* Copyright (C) 2017 by Max Lv */ -/* Copyright (C) 2017 by Mygod Studio */ -/* */ -/* This program is free software: you can redistribute it and/or modify */ -/* it under the terms of the GNU General Public License as published by */ -/* the Free Software Foundation, either version 3 of the License, or */ -/* (at your option) any later version. */ -/* */ -/* This program is distributed in the hope that it will be useful, */ -/* but WITHOUT ANY WARRANTY; without even the implied warranty of */ -/* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the */ -/* GNU General Public License for more details. */ -/* */ -/* You should have received a copy of the GNU General Public License */ -/* along with this program. If not, see . */ -/* */ -/*******************************************************************************/ - -package com.github.shadowsocks.database - -import android.util.Log -import com.github.shadowsocks.ProfilesFragment -import com.github.shadowsocks.ShadowsocksApplication.app - -object ProfileManager { - private final val TAG = "ProfileManager" -} - -class ProfileManager(dbHelper: DBHelper) { - import ProfileManager._ - - def createProfile(p: Profile = null): Profile = { - val profile = if (p == null) new Profile else p - profile.id = 0 - try { - app.currentProfile match { - case Some(oldProfile) => - // Copy Feature Settings from old profile - profile.route = oldProfile.route - profile.ipv6 = oldProfile.ipv6 - profile.proxyApps = oldProfile.proxyApps - profile.bypass = oldProfile.bypass - profile.individual = oldProfile.individual - profile.udpdns = oldProfile.udpdns - case _ => - } - val last = dbHelper.profileDao.queryRaw(dbHelper.profileDao.queryBuilder.selectRaw("MAX(userOrder)") - .prepareStatementString).getFirstResult - if (last != null && last.length == 1 && last(0) != null) profile.userOrder = last(0).toInt + 1 - dbHelper.profileDao.createOrUpdate(profile) - if (ProfilesFragment.instance != null) ProfilesFragment.instance.profilesAdapter.add(profile) - } catch { - case ex: Exception => - Log.e(TAG, "addProfile", ex) - app.track(ex) - } - profile - } - - def updateProfile(profile: Profile): Boolean = { - try { - dbHelper.profileDao.update(profile) - true - } catch { - case ex: Exception => - Log.e(TAG, "updateProfile", ex) - app.track(ex) - false - } - } - - def getProfile(id: Int): Option[Profile] = { - try { - dbHelper.profileDao.queryForId(id) match { - case profile: Profile => Option(profile) - case _ => None - } - } catch { - case ex: Exception => - Log.e(TAG, "getProfile", ex) - app.track(ex) - None - } - } - - def delProfile(id: Int): Boolean = { - try { - dbHelper.profileDao.deleteById(id) - if (ProfilesFragment.instance != null) ProfilesFragment.instance.profilesAdapter.removeId(id) - true - } catch { - case ex: Exception => - Log.e(TAG, "delProfile", ex) - app.track(ex) - false - } - } - - def getFirstProfile: Option[Profile] = { - try { - val result = dbHelper.profileDao.query(dbHelper.profileDao.queryBuilder.limit(1L).prepare) - if (result.size == 1) Option(result.get(0)) else None - } catch { - case ex: Exception => - Log.e(TAG, "getAllProfiles", ex) - app.track(ex) - None - } - } - - def getAllProfiles: Option[List[Profile]] = { - try { - import scala.collection.JavaConversions._ - Option(dbHelper.profileDao.query(dbHelper.profileDao.queryBuilder.orderBy("userOrder", true).prepare).toList) - } catch { - case ex: Exception => - Log.e(TAG, "getAllProfiles", ex) - app.track(ex) - None - } - } -} diff --git a/mobile/src/main/scala/com/github/shadowsocks/plugin/NativePlugin.scala b/mobile/src/main/scala/com/github/shadowsocks/plugin/NativePlugin.scala deleted file mode 100644 index 34c65a79..00000000 --- a/mobile/src/main/scala/com/github/shadowsocks/plugin/NativePlugin.scala +++ /dev/null @@ -1,14 +0,0 @@ -package com.github.shadowsocks.plugin - -import android.content.pm.{PackageManager, ResolveInfo} -import android.os.Bundle - -/** - * @author Mygod - */ -final class NativePlugin(resolveInfo: ResolveInfo, packageManager: PackageManager) - extends ResolvedPlugin(resolveInfo, packageManager) { - assert(resolveInfo.providerInfo != null) - override protected def metaData: Bundle = resolveInfo.providerInfo.metaData - override def packageName: String = resolveInfo.providerInfo.packageName -} diff --git a/mobile/src/main/scala/com/github/shadowsocks/plugin/NoPlugin.scala b/mobile/src/main/scala/com/github/shadowsocks/plugin/NoPlugin.scala deleted file mode 100644 index 7081b79c..00000000 --- a/mobile/src/main/scala/com/github/shadowsocks/plugin/NoPlugin.scala +++ /dev/null @@ -1,11 +0,0 @@ -package com.github.shadowsocks.plugin - -import com.github.shadowsocks.ShadowsocksApplication.app - -/** - * @author Mygod - */ -object NoPlugin extends Plugin { - override def id: String = "" - override def label: CharSequence = app.getText(com.github.shadowsocks.R.string.plugin_disabled) -} diff --git a/mobile/src/main/scala/com/github/shadowsocks/plugin/Plugin.scala b/mobile/src/main/scala/com/github/shadowsocks/plugin/Plugin.scala deleted file mode 100644 index f206034e..00000000 --- a/mobile/src/main/scala/com/github/shadowsocks/plugin/Plugin.scala +++ /dev/null @@ -1,15 +0,0 @@ -package com.github.shadowsocks.plugin - -import android.graphics.drawable.Drawable - -/** - * @author Mygod - */ -abstract class Plugin { - def id: String - def label: CharSequence - def icon: Drawable = null - def defaultConfig: String = null - def packageName: String = null - def trusted: Boolean = true -} diff --git a/mobile/src/main/scala/com/github/shadowsocks/plugin/PluginConfiguration.scala b/mobile/src/main/scala/com/github/shadowsocks/plugin/PluginConfiguration.scala deleted file mode 100644 index cbb37f0a..00000000 --- a/mobile/src/main/scala/com/github/shadowsocks/plugin/PluginConfiguration.scala +++ /dev/null @@ -1,48 +0,0 @@ -package com.github.shadowsocks.plugin - -import android.util.Log -import com.github.shadowsocks.utils.Commandline - -import scala.collection.mutable - -/** - * @author Mygod - */ -class PluginConfiguration(val pluginsOptions: Map[String, PluginOptions], val selected: String) { - private def this(plugins: Array[PluginOptions]) = - this(plugins.filter(_.id.nonEmpty).map(opt => opt.id -> opt).toMap, if (plugins.isEmpty) "" else plugins(0).id) - def this(plugin: String) = this(if (plugin == null) Array[PluginOptions]() else plugin.split("\n").map { - case line if line.startsWith("kcptun ") => - val opt = new PluginOptions() - opt.id = "kcptun" - try { - val args = mutable.Queue(Commandline.translateCommandline(line): _*) - args.dequeue() - while (args.nonEmpty) args.dequeue() match { - case "--nocomp" => opt.put("nocomp", null) - case option if option.startsWith("--") => opt.put(option.substring(2), args.dequeue()) - case option => throw new IllegalArgumentException("Unknown kcptun parameter: " + option) - } - } catch { - case exc: Exception => Log.w("PluginConfiguration", exc.getMessage) - } - opt - case line => new PluginOptions(line) - }) - - def getOptions(id: String): PluginOptions = if (id.isEmpty) new PluginOptions() else pluginsOptions.get(id) match { - case Some(options) => options - case None => new PluginOptions(id, PluginManager.fetchPlugins()(id).defaultConfig) - } - def selectedOptions: PluginOptions = getOptions(selected) - - override def toString: String = { - val result = new mutable.ListBuffer[PluginOptions]() - for ((id, opt) <- pluginsOptions) id match { - case this.selected => result.prepend(opt) - case _ => result.append(opt) - } - if (!pluginsOptions.contains(selected)) result.prepend(selectedOptions) - result.map(_.toString(false)).mkString("\n") - } -} diff --git a/mobile/src/main/scala/com/github/shadowsocks/plugin/PluginManager.scala b/mobile/src/main/scala/com/github/shadowsocks/plugin/PluginManager.scala deleted file mode 100644 index b32b62a1..00000000 --- a/mobile/src/main/scala/com/github/shadowsocks/plugin/PluginManager.scala +++ /dev/null @@ -1,164 +0,0 @@ -package com.github.shadowsocks.plugin - -import java.io.{File, FileNotFoundException, FileOutputStream, IOException} - -import android.content.pm.{PackageManager, Signature} -import android.content.{BroadcastReceiver, ContentResolver, Intent} -import android.net.Uri -import android.os.Bundle -import android.util.{Base64, Log} -import com.github.shadowsocks.ShadowsocksApplication.app -import com.github.shadowsocks.utils.CloseUtils.autoClose -import com.github.shadowsocks.utils.{Commandline, IOUtils} -import eu.chainfire.libsuperuser.Shell - -import scala.collection.JavaConversions._ -import scala.collection.mutable.ListBuffer - -/** - * @author Mygod - */ -object PluginManager { - /** - * Trusted signatures by the app. Third-party fork should add their public key to their fork if the developer wishes - * to publish or has published plugins for this app. You can obtain your public key by executing: - * - * $ keytool -export -alias key-alias -keystore /path/to/keystore.jks -rfc - * - * If you don't plan to publish any plugin but is developing/has developed some, it's not necessary to add your - * public key yet since it will also automatically trust packages signed by the same signatures, e.g. debug keys. - */ - lazy val trustedSignatures: Set[Signature] = app.info.signatures.toSet + - new Signature(Base64.decode( // @Mygod - """ - |MIIDWzCCAkOgAwIBAgIEUzfv8DANBgkqhkiG9w0BAQsFADBdMQswCQYDVQQGEwJD - |TjEOMAwGA1UECBMFTXlnb2QxDjAMBgNVBAcTBU15Z29kMQ4wDAYDVQQKEwVNeWdv - |ZDEOMAwGA1UECxMFTXlnb2QxDjAMBgNVBAMTBU15Z29kMCAXDTE0MDUwMjA5MjQx - |OVoYDzMwMTMwOTAyMDkyNDE5WjBdMQswCQYDVQQGEwJDTjEOMAwGA1UECBMFTXln - |b2QxDjAMBgNVBAcTBU15Z29kMQ4wDAYDVQQKEwVNeWdvZDEOMAwGA1UECxMFTXln - |b2QxDjAMBgNVBAMTBU15Z29kMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKC - |AQEAjm5ikHoP3w6zavvZU5bRo6Birz41JL/nZidpdww21q/G9APA+IiJMUeeocy0 - |L7/QY8MQZABVwNq79LXYWJBcmmFXM9xBPgDqQP4uh9JsvazCI9bvDiMn92mz9HiS - |Sg9V4KGg0AcY0r230KIFo7hz+2QBp1gwAAE97myBfA3pi3IzJM2kWsh4LWkKQMfL - |M6KDhpb4mdDQnHlgi4JWe3SYbLtpB6whnTqjHaOzvyiLspx1tmrb0KVxssry9KoX - |YQzl56scfE/QJX0jJ5qYmNAYRCb4PibMuNSGB2NObDabSOMAdT4JLueOcHZ/x9tw - |agGQ9UdymVZYzf8uqc+29ppKdQIDAQABoyEwHzAdBgNVHQ4EFgQUBK4uJ0cqmnho - |6I72VmOVQMvVCXowDQYJKoZIhvcNAQELBQADggEBABZQ3yNESQdgNJg+NRIcpF9l - |YSKZvrBZ51gyrC7/2ZKMpRIyXruUOIrjuTR5eaONs1E4HI/uA3xG1eeW2pjPxDnO - |zgM4t7EPH6QbzibihoHw1MAB/mzECzY8r11PBhDQlst0a2hp+zUNR8CLbpmPPqTY - |RSo6EooQ7+NBejOXysqIF1q0BJs8Y5s/CaTOmgbL7uPCkzArB6SS/hzXgDk5gw6v - |wkGeOtzcj1DlbUTvt1s5GlnwBTGUmkbLx+YUje+n+IBgMbohLUDYBtUHylRVgMsc - |1WS67kDqeJiiQZvrxvyW6CZZ/MIGI+uAkkj3DqJpaZirkwPgvpcOIrjZy0uFvQM= - """, Base64.DEFAULT)) + - new Signature(Base64.decode( // @madeye - """ - |MIICQzCCAaygAwIBAgIETV9OhjANBgkqhkiG9w0BAQUFADBmMQswCQYDVQQGEwJjbjERMA8GA1UE - |CBMIU2hhbmdoYWkxDzANBgNVBAcTBlB1ZG9uZzEUMBIGA1UEChMLRnVkYW4gVW5pdi4xDDAKBgNV - |BAsTA1BQSTEPMA0GA1UEAxMGTWF4IEx2MB4XDTExMDIxOTA1MDA1NFoXDTM2MDIxMzA1MDA1NFow - |ZjELMAkGA1UEBhMCY24xETAPBgNVBAgTCFNoYW5naGFpMQ8wDQYDVQQHEwZQdWRvbmcxFDASBgNV - |BAoTC0Z1ZGFuIFVuaXYuMQwwCgYDVQQLEwNQUEkxDzANBgNVBAMTBk1heCBMdjCBnzANBgkqhkiG - |9w0BAQEFAAOBjQAwgYkCgYEAq6lA8LqdeEI+es9SDX85aIcx8LoL3cc//iRRi+2mFIWvzvZ+bLKr - |4Wd0rhu/iU7OeMm2GvySFyw/GdMh1bqh5nNPLiRxAlZxpaZxLOdRcxuvh5Nc5yzjM+QBv8ECmuvu - |AOvvT3UDmA0AMQjZqSCmxWIxc/cClZ/0DubreBo2st0CAwEAATANBgkqhkiG9w0BAQUFAAOBgQAQ - |Iqonxpwk2ay+Dm5RhFfZyG9SatM/JNFx2OdErU16WzuK1ItotXGVJaxCZv3u/tTwM5aaMACGED5n - |AvHaDGCWynY74oDAopM4liF/yLe1wmZDu6Zo/7fXrH+T03LBgj2fcIkUfN1AA4dvnBo8XWAm9VrI - |1iNuLIssdhDz3IL9Yg== - """, Base64.DEFAULT)) - - private var receiver: BroadcastReceiver = _ - private var cachedPlugins: Map[String, Plugin] = _ - def fetchPlugins(): Map[String, Plugin] = { - if (receiver == null) receiver = app.listenForPackageChanges(synchronized(cachedPlugins = null)) - synchronized(if (cachedPlugins == null) { - val pm = app.getPackageManager - cachedPlugins = ( - pm.queryIntentContentProviders(new Intent(PluginContract.ACTION_NATIVE_PLUGIN), PackageManager.GET_META_DATA) - .map(new NativePlugin(_, pm)) :+ - NoPlugin - ).map(plugin => plugin.id -> plugin).toMap - }) - cachedPlugins - } - - private def buildUri(id: String) = new Uri.Builder() - .scheme(PluginContract.SCHEME) - .authority(PluginContract.AUTHORITY) - .path('/' + id) - .build() - def buildIntent(id: String, action: String): Intent = new Intent(action, buildUri(id)) - - // the following parts are meant to be used by :bg - @throws[Throwable] - def init(options: PluginOptions): String = { - if (options.id.isEmpty) return null - var throwable: Throwable = null - - try initNative(options) match { - case null => - case path => return path - } catch { - case t: Throwable => - t.printStackTrace() - if (throwable == null) throwable = t - } - - // add other plugin types here - - throw if (throwable == null) new FileNotFoundException( - app.getString(com.github.shadowsocks.R.string.plugin_unknown, options.id)) else throwable - } - - @throws[IOException] - @throws[IndexOutOfBoundsException] - @throws[AssertionError] - private def initNative(options: PluginOptions): String = { - val providers = app.getPackageManager.queryIntentContentProviders( - new Intent(PluginContract.ACTION_NATIVE_PLUGIN, buildUri(options.id)), 0) - assert(providers.length == 1) - val uri = new Uri.Builder() - .scheme(ContentResolver.SCHEME_CONTENT) - .authority(providers(0).providerInfo.authority) - .build() - val cr = app.getContentResolver - try initNativeFast(cr, options, uri) catch { - case t: Throwable => - t.printStackTrace() - Log.w("PluginManager", "Initializing native plugin fast mode failed. Falling back to slow mode.") - initNativeSlow(cr, options, uri) - } - } - - private def initNativeFast(cr: ContentResolver, options: PluginOptions, uri: Uri): String = { - val out = new Bundle() - out.putString(PluginContract.EXTRA_OPTIONS, options.id) - val result = cr.call(uri, PluginContract.METHOD_GET_EXECUTABLE, null, out).getString(PluginContract.EXTRA_ENTRY) - assert(new File(result).canExecute) - result - } - - private def initNativeSlow(cr: ContentResolver, options: PluginOptions, uri: Uri): String = - cr.query(uri, Array(PluginContract.COLUMN_PATH, PluginContract.COLUMN_MODE), null, null, null) match { - case null => null - case cursor => - var initialized = false - val pluginDir = new File(app.getFilesDir, "plugin") - def entryNotFound() = throw new IndexOutOfBoundsException("Plugin entry binary not found") - if (!cursor.moveToFirst()) entryNotFound() - IOUtils.deleteRecursively(pluginDir) - if (!pluginDir.mkdirs()) throw new FileNotFoundException("Unable to create plugin directory") - val pluginDirPath = pluginDir.getAbsolutePath + '/' - val list = new ListBuffer[String] - do { - val path = cursor.getString(0) - val file = new File(pluginDir, path) - assert(file.getAbsolutePath.startsWith(pluginDirPath)) - autoClose(cr.openInputStream(uri.buildUpon().path(path).build()))(in => - autoClose(new FileOutputStream(file))(out => IOUtils.copy(in, out))) - list += Commandline.toString(Array("chmod", cursor.getString(1), file.getAbsolutePath)) - if (path == options.id) initialized = true - } while (cursor.moveToNext()) - if (!initialized) entryNotFound() - Shell.SH.run(list) - new File(pluginDir, options.id).getAbsolutePath - } -} diff --git a/mobile/src/main/scala/com/github/shadowsocks/plugin/ResolvedPlugin.scala b/mobile/src/main/scala/com/github/shadowsocks/plugin/ResolvedPlugin.scala deleted file mode 100644 index 5ef19149..00000000 --- a/mobile/src/main/scala/com/github/shadowsocks/plugin/ResolvedPlugin.scala +++ /dev/null @@ -1,23 +0,0 @@ -package com.github.shadowsocks.plugin - -import android.content.pm.{PackageManager, ResolveInfo} -import android.graphics.drawable.Drawable -import android.os.Bundle -import com.github.shadowsocks.ShadowsocksApplication.app - -/** - * Base class for any kind of plugin that can be used. - * - * @author Mygod - */ -abstract class ResolvedPlugin(resolveInfo: ResolveInfo, packageManager: PackageManager = app.getPackageManager) - extends Plugin { - protected def metaData: Bundle - - override final lazy val id: String = metaData.getString(PluginContract.METADATA_KEY_ID) - override final lazy val label: CharSequence = resolveInfo.loadLabel(packageManager) - override final lazy val icon: Drawable = resolveInfo.loadIcon(packageManager) - override final lazy val defaultConfig: String = metaData.getString(PluginContract.METADATA_KEY_DEFAULT_CONFIG) - override def packageName: String = resolveInfo.resolvePackageName - override final lazy val trusted: Boolean = app.info.signatures.exists(PluginManager.trustedSignatures.contains) -} diff --git a/mobile/src/main/scala/com/github/shadowsocks/preference/BottomSheetPreferenceDialogFragment.scala b/mobile/src/main/scala/com/github/shadowsocks/preference/BottomSheetPreferenceDialogFragment.scala deleted file mode 100644 index 34b7164e..00000000 --- a/mobile/src/main/scala/com/github/shadowsocks/preference/BottomSheetPreferenceDialogFragment.scala +++ /dev/null @@ -1,92 +0,0 @@ -package com.github.shadowsocks.preference - -import android.content.{ActivityNotFoundException, Intent} -import android.graphics.Typeface -import android.graphics.drawable.Drawable -import android.net.Uri -import android.os.Bundle -import android.provider.Settings -import android.support.design.widget.BottomSheetDialog -import android.support.v7.preference.PreferenceDialogFragmentCompat -import android.support.v7.widget.RecyclerView.ViewHolder -import android.support.v7.widget.{LinearLayoutManager, RecyclerView} -import android.view.ViewGroup.LayoutParams -import android.view.{LayoutInflater, View, ViewGroup} -import android.widget.{ImageView, TextView} -import com.github.shadowsocks.R - -/** - * @author Mygod - */ -final class BottomSheetPreferenceDialogFragment extends PreferenceDialogFragmentCompat { - private lazy val preference = getPreference.asInstanceOf[IconListPreference] - private lazy val index: Int = preference.selectedEntry - private lazy val entries: Array[CharSequence] = preference.getEntries - private lazy val entryValues: Array[CharSequence] = preference.getEntryValues - private lazy val entryIcons: Array[Drawable] = preference.getEntryIcons - private var clickedIndex = -1 - - override def onCreateDialog(savedInstanceState: Bundle): BottomSheetDialog = { - val activity = getActivity - val dialog = new BottomSheetDialog(activity, getTheme) - val recycler = new RecyclerView(activity) - recycler.setHasFixedSize(true) - recycler.setLayoutManager(new LinearLayoutManager(activity)) - recycler.setAdapter(new IconListAdapter(dialog)) - recycler.setLayoutParams(new LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)) - dialog.setContentView(recycler) - dialog - } - - def onDialogClosed(positiveResult: Boolean): Unit = if (clickedIndex >= 0 && clickedIndex != index) { - val value = preference.getEntryValues()(clickedIndex).toString - if (preference.callChangeListener(value)) preference.setValue(value) - } - - private final class IconListViewHolder(val dialog: BottomSheetDialog, view: View) extends ViewHolder(view) - with View.OnClickListener with View.OnLongClickListener { - private var index: Int = _ - private val text1 = view.findViewById[TextView](android.R.id.text1) - private val text2 = view.findViewById[TextView](android.R.id.text2) - private val icon = view.findViewById[ImageView](android.R.id.icon) - view.setOnClickListener(this) - view.setOnLongClickListener(this) - - def bind(i: Int, selected: Boolean = false) { - text1.setText(entries(i)) - text2.setText(entryValues(i)) - val typeface = if (selected) Typeface.BOLD else Typeface.NORMAL - text1.setTypeface(null, typeface) - text2.setTypeface(null, typeface) - text2.setVisibility(if (entryValues(i).length > 0 && entries(i) != entryValues(i)) View.VISIBLE else View.GONE) - icon.setImageDrawable(entryIcons(i)) - index = i - } - def onClick(v: View) { - clickedIndex = index - dialog.dismiss() - } - override def onLongClick(v: View): Boolean = preference.entryPackageNames(index) match { - case null => false - case pn => try { - startActivity(new Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, new Uri.Builder() - .scheme("package") - .opaquePart(pn) - .build())) - true - } catch { - case _: ActivityNotFoundException => false - } - } - } - private final class IconListAdapter(dialog: BottomSheetDialog) extends RecyclerView.Adapter[IconListViewHolder] { - def getItemCount: Int = entries.length - def onBindViewHolder(vh: IconListViewHolder, i: Int): Unit = if (index < 0) vh.bind(i) else i match { - case 0 => vh.bind(index, selected = true) - case _ if i > index => vh.bind(i) - case _ => vh.bind(i - 1) - } - def onCreateViewHolder(vg: ViewGroup, i: Int): IconListViewHolder = new IconListViewHolder(dialog, - LayoutInflater.from(vg.getContext).inflate(R.layout.icon_list_item_2, vg, false)) - } -} diff --git a/mobile/src/main/scala/com/github/shadowsocks/preference/IconListPreference.scala b/mobile/src/main/scala/com/github/shadowsocks/preference/IconListPreference.scala deleted file mode 100644 index 9cb90b1e..00000000 --- a/mobile/src/main/scala/com/github/shadowsocks/preference/IconListPreference.scala +++ /dev/null @@ -1,64 +0,0 @@ -package com.github.shadowsocks.preference - -import android.content.Context -import android.graphics.drawable.Drawable -import android.support.annotation.ArrayRes -import android.support.v7.preference.ListPreference -import android.support.v7.preference.Preference.OnPreferenceChangeListener -import android.util.AttributeSet - -/** - * @author Mygod - */ -class IconListPreference(context: Context, attrs: AttributeSet = null) extends ListPreference(context, attrs) { - private var mEntryIcons: Array[Drawable] = _ - def selectedEntry: Int = getEntryValues.indexOf(getValue) - - var unknownValueSummary: String = _ - var entryPackageNames: Array[String] = _ - - private var listener: OnPreferenceChangeListener = _ - override def getOnPreferenceChangeListener: OnPreferenceChangeListener = listener - override def setOnPreferenceChangeListener(listener: OnPreferenceChangeListener): Unit = this.listener = listener - super.setOnPreferenceChangeListener((preference, newValue) => - if (listener == null || listener.onPreferenceChange(preference, newValue)) { - setValue(newValue.toString) - checkSummary() - if (mEntryIcons != null) setIcon(getEntryIcon) - true - } else false) - def checkSummary(): Unit = - if (unknownValueSummary != null) setSummary(if (selectedEntry < 0) unknownValueSummary.format(getValue) else "%s") - -// { -// val a: TypedArray = context.obtainStyledAttributes(attrs, R.styleable.IconListPreference) -// val entryIconsResId: Int = a.getResourceId(R.styleable.IconListPreference_entryIcons, -1) -// if (entryIconsResId != -1) setEntryIcons(entryIconsResId) -// a.recycle() -// } - - def getEntryIcon: Drawable = try mEntryIcons(selectedEntry) catch { - case _: ArrayIndexOutOfBoundsException => null - } - def getEntryIcons: Array[Drawable] = mEntryIcons - def setEntryIcons(entryIcons: Array[Drawable]): Unit = mEntryIcons = entryIcons - - def setEntryIcons(@ArrayRes entryIconsResId: Int) { - val icons_array = getContext.getResources.obtainTypedArray(entryIconsResId) - val icon_ids_array = new Array[Drawable](icons_array.length) - for (i <- 0 until icons_array.length) icon_ids_array(i) = icons_array.getDrawable(i) - setEntryIcons(icon_ids_array) - icons_array.recycle() - } - - def init() { - val entryValues = getEntryValues - if (entryValues == null) return - if (mEntryIcons != null) setIcon(getEntryIcon) - } - - protected override def onSetInitialValue(restoreValue: Boolean, defaultValue: AnyRef) { - super.onSetInitialValue(restoreValue, defaultValue) - init() - } -} diff --git a/mobile/src/main/scala/com/github/shadowsocks/preference/OnPreferenceDataStoreChangeListener.scala b/mobile/src/main/scala/com/github/shadowsocks/preference/OnPreferenceDataStoreChangeListener.scala deleted file mode 100644 index 6e690b73..00000000 --- a/mobile/src/main/scala/com/github/shadowsocks/preference/OnPreferenceDataStoreChangeListener.scala +++ /dev/null @@ -1,10 +0,0 @@ -package com.github.shadowsocks.preference - -import android.support.v7.preference.PreferenceDataStore - -/** - * @author Mygod - */ -trait OnPreferenceDataStoreChangeListener { - def onPreferenceDataStoreChanged(store: PreferenceDataStore, key: String) -} diff --git a/mobile/src/main/scala/com/github/shadowsocks/preference/OrmLitePreferenceDataStore.scala b/mobile/src/main/scala/com/github/shadowsocks/preference/OrmLitePreferenceDataStore.scala deleted file mode 100644 index cc3e2387..00000000 --- a/mobile/src/main/scala/com/github/shadowsocks/preference/OrmLitePreferenceDataStore.scala +++ /dev/null @@ -1,136 +0,0 @@ -package com.github.shadowsocks.preference - -import java.io.ByteArrayOutputStream -import java.nio.ByteBuffer -import java.util - -import android.os.Binder -import android.support.v7.preference.PreferenceDataStore -import com.github.shadowsocks.database.{DBHelper, KeyValuePair} -import com.github.shadowsocks.utils.{Key, Utils} - -import scala.collection.JavaConversions._ - -/** - * @author Mygod - */ -//noinspection AccessorLikeMethodIsUnit -final class OrmLitePreferenceDataStore(dbHelper: DBHelper) extends PreferenceDataStore { - import KeyValuePair._ - - override def getBoolean(key: String, defValue: Boolean = false): Boolean = dbHelper.kvPairDao.queryForId(key) match { - case pair: KeyValuePair => - if (pair.valueType == TYPE_BOOLEAN) ByteBuffer.wrap(pair.value).get() != 0 else defValue - case _ => defValue - } - override def getFloat(key: String, defValue: Float): Float = dbHelper.kvPairDao.queryForId(key) match { - case pair: KeyValuePair => - if (pair.valueType == TYPE_FLOAT) ByteBuffer.wrap(pair.value).getFloat() else defValue - case _ => defValue - } - override def getInt(key: String, defValue: Int): Int = dbHelper.kvPairDao.queryForId(key) match { - case pair: KeyValuePair => - if (pair.valueType == TYPE_INT) ByteBuffer.wrap(pair.value).getInt() else defValue - case _ => defValue - } - override def getLong(key: String, defValue: Long): Long = dbHelper.kvPairDao.queryForId(key) match { - case pair: KeyValuePair => - if (pair.valueType == TYPE_LONG) ByteBuffer.wrap(pair.value).getLong() else defValue - case _ => defValue - } - override def getString(key: String, defValue: String = null): String = dbHelper.kvPairDao.queryForId(key) match { - case pair: KeyValuePair => - if (pair.valueType == TYPE_STRING) new String(pair.value) else defValue - case _ => defValue - } - override def getStringSet(key: String, defValue: util.Set[String]): util.Set[String] = - dbHelper.kvPairDao.queryForId(key) match { - case pair: KeyValuePair => if (pair.valueType == TYPE_STRING_SET) { - val buffer = ByteBuffer.wrap(pair.value) - val result = new util.HashSet[String]() - while (buffer.hasRemaining) { - val chArr = new Array[Byte](buffer.getInt) - buffer.get(chArr) - result.add(new String(chArr)) - } - result - } else defValue - case _ => defValue - } - - override def putBoolean(key: String, value: Boolean) { - dbHelper.kvPairDao.createOrUpdate( - new KeyValuePair(key, TYPE_BOOLEAN, ByteBuffer.allocate(1).put((if (value) 1 else 0).toByte).array())) - fireChangeListener(key) - } - override def putFloat(key: String, value: Float) { - dbHelper.kvPairDao.createOrUpdate(new KeyValuePair(key, TYPE_FLOAT, ByteBuffer.allocate(4).putFloat(value).array())) - fireChangeListener(key) - } - override def putInt(key: String, value: Int) { - dbHelper.kvPairDao.createOrUpdate(new KeyValuePair(key, TYPE_INT, ByteBuffer.allocate(4).putInt(value).array())) - fireChangeListener(key) - } - override def putLong(key: String, value: Long) { - dbHelper.kvPairDao.createOrUpdate(new KeyValuePair(key, TYPE_LONG, ByteBuffer.allocate(8).putLong(value).array())) - fireChangeListener(key) - } - override def putString(key: String, value: String) { - value match { - case null => remove(key) - case _ => dbHelper.kvPairDao.createOrUpdate(new KeyValuePair(key, TYPE_STRING, value.getBytes())) - } - fireChangeListener(key) - } - override def putStringSet(key: String, value: util.Set[String]) { - val stream = new ByteArrayOutputStream() - for (v <- value) { - stream.write(ByteBuffer.allocate(4).putInt(v.length).array()) - stream.write(v.getBytes()) - } - dbHelper.kvPairDao.createOrUpdate(new KeyValuePair(key, TYPE_STRING_SET, stream.toByteArray)) - fireChangeListener(key) - } - - def remove(key: String): Int = dbHelper.kvPairDao.deleteById(key) - - private var listeners: Set[OnPreferenceDataStoreChangeListener] = Set.empty - private def fireChangeListener(key: String) = listeners.foreach(_.onPreferenceDataStoreChanged(this, key)) - def registerChangeListener(listener: OnPreferenceDataStoreChangeListener): Unit = listeners += listener - def unregisterChangeListener(listener: OnPreferenceDataStoreChangeListener): Unit = listeners -= listener - - // hopefully hashCode = mHandle doesn't change, currently this is true from KitKat to Nougat - private lazy val userIndex = Binder.getCallingUserHandle.hashCode - private def getLocalPort(key: String, default: Int) = getInt(key, 0) match { - case 0 => Utils.parsePort(getString(key), default + userIndex) - case value => - putString(key, value.toString) - value - } - - def profileId: Int = getInt(Key.id, 0) - def profileId_=(i: Int): Unit = putInt(Key.id, i) - def serviceMode: String = getString(Key.serviceMode, Key.modeVpn) - def portProxy: Int = getLocalPort(Key.portProxy, 1080) - def portLocalDns: Int = getLocalPort(Key.portLocalDns, 5450) - def portTransproxy: Int = getLocalPort(Key.portTransproxy, 8200) - - def proxyApps: Boolean = getBoolean(Key.proxyApps) - def proxyApps_=(value: Boolean): Unit = putBoolean(Key.proxyApps, value) - def bypass: Boolean = getBoolean(Key.bypass) - def bypass_=(value: Boolean): Unit = putBoolean(Key.bypass, value) - def individual: String = getString(Key.individual) - def individual_=(value: String): Unit = putString(Key.individual, value) - def plugin: String = getString(Key.plugin) - def plugin_=(value: String): Unit = putString(Key.plugin, value) - def dirty: Boolean = getBoolean(Key.dirty) - def dirty_=(value: Boolean): Unit = putBoolean(Key.dirty, value) - - def initGlobal() { - // temporary workaround for support lib bug - if (getString(Key.serviceMode) == null) putString(Key.serviceMode, serviceMode) - if (getString(Key.portProxy) == null) putString(Key.portProxy, portProxy.toString) - if (getString(Key.portLocalDns) == null) putString(Key.portLocalDns, portLocalDns.toString) - if (getString(Key.portTransproxy) == null) putString(Key.portTransproxy, portTransproxy.toString) - } -} diff --git a/mobile/src/main/scala/com/github/shadowsocks/preference/PluginConfigurationDialogFragment.scala b/mobile/src/main/scala/com/github/shadowsocks/preference/PluginConfigurationDialogFragment.scala deleted file mode 100644 index e298e7fc..00000000 --- a/mobile/src/main/scala/com/github/shadowsocks/preference/PluginConfigurationDialogFragment.scala +++ /dev/null @@ -1,55 +0,0 @@ -/*******************************************************************************/ -/* */ -/* Copyright (C) 2017 by Max Lv */ -/* Copyright (C) 2017 by Mygod Studio */ -/* */ -/* This program is free software: you can redistribute it and/or modify */ -/* it under the terms of the GNU General Public License as published by */ -/* the Free Software Foundation, either version 3 of the License, or */ -/* (at your option) any later version. */ -/* */ -/* This program is distributed in the hope that it will be useful, */ -/* but WITHOUT ANY WARRANTY; without even the implied warranty of */ -/* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the */ -/* GNU General Public License for more details. */ -/* */ -/* You should have received a copy of the GNU General Public License */ -/* along with this program. If not, see . */ -/* */ -/*******************************************************************************/ - -package com.github.shadowsocks.preference - -import android.content.DialogInterface -import android.support.v7.app.AlertDialog -import android.support.v7.preference.EditTextPreferenceDialogFragmentCompat -import android.view.View -import android.widget.EditText -import com.github.shadowsocks.ProfileConfigActivity -import com.github.shadowsocks.plugin.{PluginContract, PluginManager} - -/** - * @author Mygod - */ -object PluginConfigurationDialogFragment { - final val PLUGIN_ID_FRAGMENT_TAG = "com.github.shadowsocks.preference.PluginConfigurationDialogFragment.PLUGIN_ID" -} - -class PluginConfigurationDialogFragment extends EditTextPreferenceDialogFragmentCompat { - import PluginConfigurationDialogFragment._ - - private var editText: EditText = _ - - override def onPrepareDialogBuilder(builder: AlertDialog.Builder) { - super.onPrepareDialogBuilder(builder) - val intent = PluginManager.buildIntent(getArguments.getString(PLUGIN_ID_FRAGMENT_TAG), PluginContract.ACTION_HELP) - if (intent.resolveActivity(getActivity.getPackageManager) != null) builder.setNeutralButton("?", ((_, _) => - getActivity.startActivityForResult(intent.putExtra(PluginContract.EXTRA_OPTIONS, editText.getText.toString), - ProfileConfigActivity.REQUEST_CODE_PLUGIN_HELP)): DialogInterface.OnClickListener) - } - - override def onBindDialogView(view: View) { - super.onBindDialogView(view) - editText = view.findViewById(android.R.id.edit) - } -} diff --git a/mobile/src/main/scala/com/github/shadowsocks/utils/CloseUtils.scala b/mobile/src/main/scala/com/github/shadowsocks/utils/CloseUtils.scala deleted file mode 100644 index dc768143..00000000 --- a/mobile/src/main/scala/com/github/shadowsocks/utils/CloseUtils.scala +++ /dev/null @@ -1,55 +0,0 @@ -/*******************************************************************************/ -/* */ -/* Copyright (C) 2017 by Max Lv */ -/* Copyright (C) 2017 by Mygod Studio */ -/* */ -/* This program is free software: you can redistribute it and/or modify */ -/* it under the terms of the GNU General Public License as published by */ -/* the Free Software Foundation, either version 3 of the License, or */ -/* (at your option) any later version. */ -/* */ -/* This program is distributed in the hope that it will be useful, */ -/* but WITHOUT ANY WARRANTY; without even the implied warranty of */ -/* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the */ -/* GNU General Public License for more details. */ -/* */ -/* You should have received a copy of the GNU General Public License */ -/* along with this program. If not, see . */ -/* */ -/*******************************************************************************/ - -package com.github.shadowsocks.utils - -/** - * @author Mygod - */ -object CloseUtils { - type Disconnectable = { - def disconnect() - } - - def autoClose[A <: AutoCloseable, B](x: => A)(block: A => B): B = { - var a: Option[A] = None - try { - a = Some(x) - block(a.get) - } finally if (a.nonEmpty) try { - val v = a.get - if (v ne null) v.close() - } catch { - case _: Exception => - } - } - def autoDisconnect[A <: Disconnectable, B](x: => A)(block: A => B): B = { - var a: Option[A] = None - try { - a = Some(x) - block(a.get) - } finally if (a.nonEmpty) try { - val v = a.get - if (v ne null) v.disconnect() - } catch { - case _: Exception => - } - } -} diff --git a/mobile/src/main/scala/com/github/shadowsocks/utils/Constants.scala b/mobile/src/main/scala/com/github/shadowsocks/utils/Constants.scala deleted file mode 100644 index 86fd17e2..00000000 --- a/mobile/src/main/scala/com/github/shadowsocks/utils/Constants.scala +++ /dev/null @@ -1,67 +0,0 @@ -/*******************************************************************************/ -/* */ -/* Copyright (C) 2017 by Max Lv */ -/* Copyright (C) 2017 by Mygod Studio */ -/* */ -/* This program is free software: you can redistribute it and/or modify */ -/* it under the terms of the GNU General Public License as published by */ -/* the Free Software Foundation, either version 3 of the License, or */ -/* (at your option) any later version. */ -/* */ -/* This program is distributed in the hope that it will be useful, */ -/* but WITHOUT ANY WARRANTY; without even the implied warranty of */ -/* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the */ -/* GNU General Public License for more details. */ -/* */ -/* You should have received a copy of the GNU General Public License */ -/* along with this program. If not, see . */ -/* */ -/*******************************************************************************/ - -package com.github.shadowsocks.utils - -object Key { - val id = "profileId" - val name = "profileName" - - val individual = "Proxyed" - - val serviceMode = "serviceMode" - val modeProxy = "proxy" - val modeVpn = "vpn" - val modeTransproxy = "transproxy" - val portProxy = "portProxy" - val portLocalDns = "portLocalDns" - val portTransproxy = "portTransproxy" - - val route = "route" - - val isAutoConnect = "isAutoConnect" - - val proxyApps = "isProxyApps" - val bypass = "isBypassApps" - val udpdns = "isUdpDns" - val ipv6 = "isIpv6" - - val host = "proxy" - val password = "sitekey" - val method = "encMethod" - val remotePort = "remotePortNum" - val remoteDns = "remoteDns" - - val plugin = "plugin" - val pluginConfigure = "plugin.configure" - - val dirty = "profileDirty" - - val tfo = "tcp_fastopen" - val assetUpdateTime = "assetUpdateTime" -} - -object Action { - final val SERVICE = "com.github.shadowsocks.SERVICE" - final val CLOSE = "com.github.shadowsocks.CLOSE" - final val RELOAD = "com.github.shadowsocks.RELOAD" - - final val EXTRA_PROFILE_ID = "com.github.shadowsocks.EXTRA_PROFILE_ID" -} diff --git a/mobile/src/main/scala/com/github/shadowsocks/utils/IOUtils.scala b/mobile/src/main/scala/com/github/shadowsocks/utils/IOUtils.scala deleted file mode 100644 index 993a4cf6..00000000 --- a/mobile/src/main/scala/com/github/shadowsocks/utils/IOUtils.scala +++ /dev/null @@ -1,50 +0,0 @@ -/*******************************************************************************/ -/* */ -/* Copyright (C) 2017 by Max Lv */ -/* Copyright (C) 2017 by Mygod Studio */ -/* */ -/* This program is free software: you can redistribute it and/or modify */ -/* it under the terms of the GNU General Public License as published by */ -/* the Free Software Foundation, either version 3 of the License, or */ -/* (at your option) any later version. */ -/* */ -/* This program is distributed in the hope that it will be useful, */ -/* but WITHOUT ANY WARRANTY; without even the implied warranty of */ -/* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the */ -/* GNU General Public License for more details. */ -/* */ -/* You should have received a copy of the GNU General Public License */ -/* along with this program. If not, see . */ -/* */ -/*******************************************************************************/ - -package com.github.shadowsocks.utils - -import com.github.shadowsocks.utils.CloseUtils._ -import java.io._ - -/** - * @author Mygod - */ -object IOUtils { - private final val BUFFER_SIZE = 32 * 1024 - - def copy(in: InputStream, out: OutputStream) { - val buffer = new Array[Byte](BUFFER_SIZE) - while (true) { - val count = in.read(buffer) - if (count >= 0) out.write(buffer, 0, count) else return - } - } - - def writeString(file: File, content: String): Unit = - autoClose(new FileWriter(file))(writer => writer.write(content)) - def writeString(file: String, content: String): Unit = - autoClose(new FileWriter(file))(writer => writer.write(content)) - - @throws[FileNotFoundException] - def deleteRecursively(file: File): Unit = if (file.exists()) { - if (file.isDirectory) file.listFiles().foreach(deleteRecursively) - if (!file.delete()) throw new FileNotFoundException("Failed to delete: " + file.getAbsolutePath) - } -} diff --git a/mobile/src/main/scala/com/github/shadowsocks/utils/Parser.scala b/mobile/src/main/scala/com/github/shadowsocks/utils/Parser.scala deleted file mode 100644 index 3742af60..00000000 --- a/mobile/src/main/scala/com/github/shadowsocks/utils/Parser.scala +++ /dev/null @@ -1,74 +0,0 @@ -/*******************************************************************************/ -/* */ -/* Copyright (C) 2017 by Max Lv */ -/* Copyright (C) 2017 by Mygod Studio */ -/* */ -/* This program is free software: you can redistribute it and/or modify */ -/* it under the terms of the GNU General Public License as published by */ -/* the Free Software Foundation, either version 3 of the License, or */ -/* (at your option) any later version. */ -/* */ -/* This program is distributed in the hope that it will be useful, */ -/* but WITHOUT ANY WARRANTY; without even the implied warranty of */ -/* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the */ -/* GNU General Public License for more details. */ -/* */ -/* You should have received a copy of the GNU General Public License */ -/* along with this program. If not, see . */ -/* */ -/*******************************************************************************/ - -package com.github.shadowsocks.utils - -import java.net.URI - -import android.net.Uri -import android.util.{Base64, Log} -import com.github.shadowsocks.database.Profile - -object Parser { - val TAG = "ShadowParser" - private val pattern = "(?i)ss://[-a-zA-Z0-9+&@#/%?=~_|!:,.;\\[\\]]*[-a-zA-Z0-9+&@#/%=~_|\\[\\]]".r - private val userInfoPattern = "^(.+?):(.*)$".r - private val legacyPattern = "^(.+?):(.*)@(.+?):(\\d+?)$".r - - def findAll(data: CharSequence): Iterator[Profile] = - pattern.findAllMatchIn(if (data == null) "" else data).map(m => { - val uri = Uri.parse(m.matched) - uri.getUserInfo match { - case null => val str = new String(Base64.decode(uri.getHost, Base64.NO_PADDING), "UTF-8"); str match { - case legacyPattern(method, password, host, port) => // legacy uri - val profile = new Profile - profile.method = method.toLowerCase - profile.password = password - profile.host = host - profile.remotePort = port.toInt - profile.plugin = uri.getQueryParameter(Key.plugin) - profile.name = uri.getFragment - profile - case _ => - Log.e(TAG, "Unrecognized URI: " + m.matched) - null - } - case userInfo => - new String(Base64.decode(userInfo, Base64.NO_PADDING | Base64.NO_WRAP | Base64.URL_SAFE), "UTF-8") match { - case userInfoPattern(method, password) => - val profile = new Profile - profile.method = method - profile.password = password - // bug in Android: https://code.google.com/p/android/issues/detail?id=192855 - val javaURI = new URI(m.matched) - profile.host = javaURI.getHost - if (profile.host.headOption.contains('[') && profile.host.lastOption.contains(']')) - profile.host = profile.host.substring(1, profile.host.length - 1) - profile.remotePort = javaURI.getPort - profile.plugin = uri.getQueryParameter(Key.plugin) - profile.name = uri.getFragment - profile - case _ => - Log.e(TAG, "Unknown user info: " + m.matched) - null - } - } - }).filter(_ != null) -} diff --git a/mobile/src/main/scala/com/github/shadowsocks/utils/TaskerSettings.scala b/mobile/src/main/scala/com/github/shadowsocks/utils/TaskerSettings.scala deleted file mode 100644 index c256f5a8..00000000 --- a/mobile/src/main/scala/com/github/shadowsocks/utils/TaskerSettings.scala +++ /dev/null @@ -1,53 +0,0 @@ -/*******************************************************************************/ -/* */ -/* Copyright (C) 2017 by Max Lv */ -/* Copyright (C) 2017 by Mygod Studio */ -/* */ -/* This program is free software: you can redistribute it and/or modify */ -/* it under the terms of the GNU General Public License as published by */ -/* the Free Software Foundation, either version 3 of the License, or */ -/* (at your option) any later version. */ -/* */ -/* This program is distributed in the hope that it will be useful, */ -/* but WITHOUT ANY WARRANTY; without even the implied warranty of */ -/* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the */ -/* GNU General Public License for more details. */ -/* */ -/* You should have received a copy of the GNU General Public License */ -/* along with this program. If not, see . */ -/* */ -/*******************************************************************************/ - -package com.github.shadowsocks.utils - -import android.content.{Context, Intent} -import android.os.Bundle -import com.github.shadowsocks.R -import com.github.shadowsocks.ShadowsocksApplication.app -import com.twofortyfouram.locale.api.{Intent => ApiIntent} - -object TaskerSettings { - private val KEY_SWITCH_ON = "switch_on" - private val KEY_PROFILE_ID = "profile_id" - - def fromIntent(intent: Intent) = new TaskerSettings(intent.getBundleExtra(ApiIntent.EXTRA_BUNDLE)) -} - -class TaskerSettings(bundle: Bundle) { - import TaskerSettings._ - - var switchOn: Boolean = bundle.getBoolean(KEY_SWITCH_ON, true) - var profileId: Int = bundle.getInt(KEY_PROFILE_ID, -1) - - def toIntent(context: Context): Intent = { - val bundle = new Bundle() - if (!switchOn) bundle.putBoolean(KEY_SWITCH_ON, false) - if (profileId >= 0) bundle.putInt(KEY_PROFILE_ID, profileId) - new Intent().putExtra(ApiIntent.EXTRA_BUNDLE, bundle).putExtra(ApiIntent.EXTRA_STRING_BLURB, - app.profileManager.getProfile(profileId) match { - case Some(p) => context.getString(if (switchOn) R.string.start_service else R.string.stop_service, p.getName) - case None => context.getString(if (switchOn) R.string.start_service_default else R.string.stop) - }) - } -} - diff --git a/mobile/src/main/scala/com/github/shadowsocks/utils/TcpFastOpen.scala b/mobile/src/main/scala/com/github/shadowsocks/utils/TcpFastOpen.scala deleted file mode 100644 index 033fc03d..00000000 --- a/mobile/src/main/scala/com/github/shadowsocks/utils/TcpFastOpen.scala +++ /dev/null @@ -1,61 +0,0 @@ -/*******************************************************************************/ -/* */ -/* Copyright (C) 2017 by Max Lv */ -/* Copyright (C) 2017 by Mygod Studio */ -/* */ -/* This program is free software: you can redistribute it and/or modify */ -/* it under the terms of the GNU General Public License as published by */ -/* the Free Software Foundation, either version 3 of the License, or */ -/* (at your option) any later version. */ -/* */ -/* This program is distributed in the hope that it will be useful, */ -/* but WITHOUT ANY WARRANTY; without even the implied warranty of */ -/* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the */ -/* GNU General Public License for more details. */ -/* */ -/* You should have received a copy of the GNU General Public License */ -/* along with this program. If not, see . */ -/* */ -/*******************************************************************************/ - -package com.github.shadowsocks.utils - -import java.io.File - -import eu.chainfire.libsuperuser.Shell - -import scala.collection.JavaConverters._ -import scala.io.Source - -/** - * @author Mygod - */ -object TcpFastOpen { - /** - * Is kernel version >= 3.7.1. - */ - lazy val supported: Boolean = "^(\\d+)\\.(\\d+)\\.(\\d+)".r.findFirstMatchIn(System.getProperty("os.version")) match { - case Some(m) => - val kernel = m.group(1).toInt - if (kernel < 3) false else if (kernel > 3) true else { - val major = m.group(2).toInt - if (major < 7) false else if (major > 7) true else m.group(3).toInt >= 1 - } - case _ => false - } - - def sendEnabled: Boolean = { - val file = new File("/proc/sys/net/ipv4/tcp_fastopen") - file.canRead && (Source.fromFile(file).mkString.trim.toInt & 1) > 0 - } - - def enabled(value: Boolean): String = if (sendEnabled != value) { - val res = Shell.run("su", Array( - "if echo " + (if (value) 3 else 0) + " > /proc/sys/net/ipv4/tcp_fastopen; then", - " echo Success.", - "else", - " echo Failed.", - "fi"), null, true) - if (res != null) res.asScala.mkString("\n") else null - } else null -} diff --git a/mobile/src/main/scala/com/github/shadowsocks/utils/Typefaces.scala b/mobile/src/main/scala/com/github/shadowsocks/utils/Typefaces.scala deleted file mode 100644 index 5ae007e9..00000000 --- a/mobile/src/main/scala/com/github/shadowsocks/utils/Typefaces.scala +++ /dev/null @@ -1,48 +0,0 @@ -/*******************************************************************************/ -/* */ -/* Copyright (C) 2017 by Max Lv */ -/* Copyright (C) 2017 by Mygod Studio */ -/* */ -/* This program is free software: you can redistribute it and/or modify */ -/* it under the terms of the GNU General Public License as published by */ -/* the Free Software Foundation, either version 3 of the License, or */ -/* (at your option) any later version. */ -/* */ -/* This program is distributed in the hope that it will be useful, */ -/* but WITHOUT ANY WARRANTY; without even the implied warranty of */ -/* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the */ -/* GNU General Public License for more details. */ -/* */ -/* You should have received a copy of the GNU General Public License */ -/* along with this program. If not, see . */ -/* */ -/*******************************************************************************/ - -package com.github.shadowsocks.utils - -import java.util - -import android.content.Context -import android.graphics.Typeface -import android.util.Log -import com.github.shadowsocks.ShadowsocksApplication.app - -object Typefaces { - def get(c: Context, assetPath: String): Typeface = { - cache synchronized { - if (!cache.containsKey(assetPath)) { - try { - cache.put(assetPath, Typeface.createFromAsset(c.getAssets, assetPath)) - } catch { - case e: Exception => - Log.e("Typefaces", "Could not get typeface '" + assetPath + "' because " + e.getMessage) - app.track(e) - return null - } - } - return cache.get(assetPath) - } - } - - private final val cache = new util.Hashtable[String, Typeface] -} diff --git a/mobile/src/main/scala/com/github/shadowsocks/utils/Utils.scala b/mobile/src/main/scala/com/github/shadowsocks/utils/Utils.scala deleted file mode 100644 index 67bd2880..00000000 --- a/mobile/src/main/scala/com/github/shadowsocks/utils/Utils.scala +++ /dev/null @@ -1,163 +0,0 @@ -/*******************************************************************************/ -/* */ -/* Copyright (C) 2017 by Max Lv */ -/* Copyright (C) 2017 by Mygod Studio */ -/* */ -/* This program is free software: you can redistribute it and/or modify */ -/* it under the terms of the GNU General Public License as published by */ -/* the Free Software Foundation, either version 3 of the License, or */ -/* (at your option) any later version. */ -/* */ -/* This program is distributed in the hope that it will be useful, */ -/* but WITHOUT ANY WARRANTY; without even the implied warranty of */ -/* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the */ -/* GNU General Public License for more details. */ -/* */ -/* You should have received a copy of the GNU General Public License */ -/* along with this program. If not, see . */ -/* */ -/*******************************************************************************/ - -package com.github.shadowsocks.utils - -import java.lang.reflect.InvocationTargetException -import java.net._ -import java.security.MessageDigest - -import android.content.pm.PackageManager -import android.content.{Context, Intent} -import android.graphics._ -import android.os.Build -import android.util.{Base64, DisplayMetrics, Log} -import android.view.View.MeasureSpec -import android.view.{Gravity, View, Window} -import android.widget.Toast -import com.github.shadowsocks.ShadowsocksApplication.app -import com.github.shadowsocks.bg.{ProxyService, TransproxyService, VpnService} -import com.github.shadowsocks.BuildConfig -import org.xbill.DNS._ - -import scala.collection.JavaConversions._ -import scala.concurrent.ExecutionContext.Implicits.global -import scala.concurrent.Future -import scala.util.{Failure, Try} - -object Utils { - private val TAG = "Shadowsocks" - - def isLollipopOrAbove: Boolean = Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP - - def getSignature(context: Context): String = { - val mdg = MessageDigest.getInstance("SHA-1") - mdg.update(app.info.signatures(0).toByteArray) - new String(Base64.encode(mdg.digest, 0)) - } - - /* - * round or floor depending on whether you are using offsets(floor) or - * widths(round) - */ - - // Based on: http://stackoverflow.com/a/21026866/2245107 - def positionToast(toast: Toast, view: View, window: Window, offsetX: Int = 0, offsetY: Int = 0): Toast = { - val rect = new Rect - window.getDecorView.getWindowVisibleDisplayFrame(rect) - val viewLocation = new Array[Int](2) - view.getLocationInWindow(viewLocation) - val metrics = new DisplayMetrics - window.getWindowManager.getDefaultDisplay.getMetrics(metrics) - val toastView = toast.getView - toastView.measure(MeasureSpec.makeMeasureSpec(metrics.widthPixels, MeasureSpec.UNSPECIFIED), - MeasureSpec.makeMeasureSpec(metrics.heightPixels, MeasureSpec.UNSPECIFIED)) - toast.setGravity(Gravity.LEFT | Gravity.TOP, - viewLocation(0) - rect.left + (view.getWidth - toast.getView.getMeasuredWidth) / 2 + offsetX, - viewLocation(1) - rect.top + view.getHeight + offsetY) - toast - } - - def resolve(host: String, addrType: Int): Option[String] = { - try { - val lookup = new Lookup(host, addrType) - val resolver = new SimpleResolver("208.67.220.220") - resolver.setTCP(true) - resolver.setPort(443) - resolver.setTimeout(5) - lookup.setResolver(resolver) - val result = lookup.run() - if (result == null) return None - val records = scala.util.Random.shuffle(result.toList) - for (r <- records) { - addrType match { - case Type.A => - return Some(r.asInstanceOf[ARecord].getAddress.getHostAddress) - case Type.AAAA => - return Some(r.asInstanceOf[AAAARecord].getAddress.getHostAddress) - } - } - } catch { - case _: Exception => - } - None - } - - def resolve(host: String): Option[String] = try Some(InetAddress.getByName(host).getHostAddress) catch { - case _: UnknownHostException => None - } - - def resolve(host: String, enableIPv6: Boolean): Option[String] = - (if (enableIPv6 && isIPv6Support) resolve(host, Type.AAAA) else None).orElse(resolve(host, Type.A)) - .orElse(resolve(host)) - - private lazy val isNumericMethod = classOf[InetAddress].getMethod("isNumeric", classOf[String]) - private lazy val parseNumericAddressMethod = classOf[InetAddress].getMethod("parseNumericAddress", classOf[String]) - def isNumeric(address: String): Boolean = isNumericMethod.invoke(null, address).asInstanceOf[Boolean] - def parseNumericAddress(address: String): InetAddress = - try parseNumericAddressMethod.invoke(null, address).asInstanceOf[InetAddress] catch { - case exc: InvocationTargetException => throw exc.getCause match { - case null => exc - case cause => cause - } - } - - /** - * If there exists a valid IPv6 interface - */ - def isIPv6Support: Boolean = try { - val result = NetworkInterface.getNetworkInterfaces.flatMap(_.getInetAddresses) - .count(addr => addr.isInstanceOf[Inet6Address] && !addr.isLoopbackAddress && !addr.isLinkLocalAddress) > 0 - if (result && BuildConfig.DEBUG) Log.d(TAG, "IPv6 address detected") - result - } catch { - case ex: Exception => - Log.e(TAG, "Failed to get interfaces' addresses.", ex) - app.track(ex) - false - } - - def startSsService(context: Context) { - val intent = new Intent(context, app.serviceClass) - if (Build.VERSION.SDK_INT >= 26) context.startForegroundService(intent) else context.startService(intent) - } - def reloadSsService(context: Context): Unit = context.sendBroadcast(new Intent(Action.RELOAD)) - def stopSsService(context: Context) { - val intent = new Intent(Action.CLOSE) - context.sendBroadcast(intent) - } - - private val handleFailure: Try[_] => Unit = { - case Failure(e) => - e.printStackTrace() - app.track(e) - case _ => - } - - def ThrowableFuture[T](f: => T): Unit = Future(f) onComplete handleFailure - - def parsePort(str: String, default: Int, min: Int = 1025): Int = (try str.toInt catch { - case _: NumberFormatException => default - }) match { - case x if x < min => default - case x if x > 65535 => default - case x => x - } -} diff --git a/mobile/src/main/scala/com/github/shadowsocks/widget/BoundedGridLayout.scala b/mobile/src/main/scala/com/github/shadowsocks/widget/BoundedGridLayout.scala deleted file mode 100644 index c3aa5e62..00000000 --- a/mobile/src/main/scala/com/github/shadowsocks/widget/BoundedGridLayout.scala +++ /dev/null @@ -1,32 +0,0 @@ -/*******************************************************************************/ -/* */ -/* Copyright (C) 2017 by Max Lv */ -/* Copyright (C) 2017 by Mygod Studio */ -/* */ -/* This program is free software: you can redistribute it and/or modify */ -/* it under the terms of the GNU General Public License as published by */ -/* the Free Software Foundation, either version 3 of the License, or */ -/* (at your option) any later version. */ -/* */ -/* This program is distributed in the hope that it will be useful, */ -/* but WITHOUT ANY WARRANTY; without even the implied warranty of */ -/* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the */ -/* GNU General Public License for more details. */ -/* */ -/* You should have received a copy of the GNU General Public License */ -/* along with this program. If not, see . */ -/* */ -/*******************************************************************************/ - -package com.github.shadowsocks.widget - -import android.content.Context -import android.support.v7.widget.GridLayout -import android.util.AttributeSet - -/** - * @author Mygod - */ -class BoundedGridLayout(context: Context, attrs: AttributeSet) extends GridLayout(context, attrs) with BoundedView { - initAttrs(context, attrs) -} diff --git a/mobile/src/main/scala/com/github/shadowsocks/widget/BoundedView.scala b/mobile/src/main/scala/com/github/shadowsocks/widget/BoundedView.scala deleted file mode 100644 index 45526920..00000000 --- a/mobile/src/main/scala/com/github/shadowsocks/widget/BoundedView.scala +++ /dev/null @@ -1,52 +0,0 @@ -/*******************************************************************************/ -/* */ -/* Copyright (C) 2017 by Max Lv */ -/* Copyright (C) 2017 by Mygod Studio */ -/* */ -/* This program is free software: you can redistribute it and/or modify */ -/* it under the terms of the GNU General Public License as published by */ -/* the Free Software Foundation, either version 3 of the License, or */ -/* (at your option) any later version. */ -/* */ -/* This program is distributed in the hope that it will be useful, */ -/* but WITHOUT ANY WARRANTY; without even the implied warranty of */ -/* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the */ -/* GNU General Public License for more details. */ -/* */ -/* You should have received a copy of the GNU General Public License */ -/* along with this program. If not, see . */ -/* */ -/*******************************************************************************/ - -package com.github.shadowsocks.widget - -import android.content.Context -import android.util.AttributeSet -import android.view.View -import android.view.View.MeasureSpec -import com.github.shadowsocks.R - -/** - * Based on: http://stackoverflow.com/a/6212120/2245107 - * - * @author Mygod - */ -trait BoundedView extends View { - private var boundedWidth: Int = _ - private var boundedHeight: Int = _ - - protected def initAttrs(context: Context, attrs: AttributeSet) { - val arr = context.obtainStyledAttributes(attrs, R.styleable.BoundedView) - boundedWidth = arr.getDimensionPixelSize(R.styleable.BoundedView_bounded_width, 0) - boundedHeight = arr.getDimensionPixelSize(R.styleable.BoundedView_bounded_height, 0) - arr.recycle() - } - - override def onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int): Unit = super.onMeasure( - if (boundedWidth > 0 && boundedWidth < MeasureSpec.getSize(widthMeasureSpec)) - MeasureSpec.makeMeasureSpec(boundedWidth, MeasureSpec.getMode(widthMeasureSpec)) - else widthMeasureSpec, - if (boundedHeight > 0 && boundedHeight < MeasureSpec.getSize(heightMeasureSpec)) - MeasureSpec.makeMeasureSpec(boundedHeight, MeasureSpec.getMode(heightMeasureSpec)) - else heightMeasureSpec) -} diff --git a/mobile/src/main/scala/com/github/shadowsocks/widget/UndoSnackbarManager.scala b/mobile/src/main/scala/com/github/shadowsocks/widget/UndoSnackbarManager.scala deleted file mode 100644 index 37811829..00000000 --- a/mobile/src/main/scala/com/github/shadowsocks/widget/UndoSnackbarManager.scala +++ /dev/null @@ -1,67 +0,0 @@ -/*******************************************************************************/ -/* */ -/* Copyright (C) 2017 by Max Lv */ -/* Copyright (C) 2017 by Mygod Studio */ -/* */ -/* This program is free software: you can redistribute it and/or modify */ -/* it under the terms of the GNU General Public License as published by */ -/* the Free Software Foundation, either version 3 of the License, or */ -/* (at your option) any later version. */ -/* */ -/* This program is distributed in the hope that it will be useful, */ -/* but WITHOUT ANY WARRANTY; without even the implied warranty of */ -/* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the */ -/* GNU General Public License for more details. */ -/* */ -/* You should have received a copy of the GNU General Public License */ -/* along with this program. If not, see . */ -/* */ -/*******************************************************************************/ - -package com.github.shadowsocks.widget - -import android.support.design.widget.Snackbar -import android.view.View -import com.github.shadowsocks.R - -import scala.collection.mutable.ArrayBuffer - -/** - * @author Mygod - * @param view The view to find a parent from. - * @param undo Callback for undoing removals. - * @param commit Callback for committing removals. - * @tparam T Item type. - */ -class UndoSnackbarManager[T](view: View, undo: Iterator[(Int, T)] => Unit, - commit: Iterator[(Int, T)] => Unit = null) { - private val recycleBin = new ArrayBuffer[(Int, T)] - private val removedCallback = new Snackbar.Callback { - override def onDismissed(snackbar: Snackbar, event: Int) { - event match { - case Snackbar.Callback.DISMISS_EVENT_SWIPE | Snackbar.Callback.DISMISS_EVENT_MANUAL | - Snackbar.Callback.DISMISS_EVENT_TIMEOUT => - if (commit != null) commit(recycleBin.iterator) - recycleBin.clear() - case _ => - } - last = null - } - } - private var last: Snackbar = _ - - def remove(items: (Int, T)*) { - recycleBin.appendAll(items) - val count = recycleBin.length - last = Snackbar - .make(view, view.getResources.getQuantityString(R.plurals.removed, count, count: Integer), Snackbar.LENGTH_LONG) - .addCallback(removedCallback) - .setAction(R.string.undo, (_ => { - undo(recycleBin.reverseIterator) - recycleBin.clear - }): View.OnClickListener) - last.show() - } - - def flush(): Unit = if (last != null) last.dismiss() -} diff --git a/mobile/src/main/scala/scala/collection/mutable/SortedList.scala b/mobile/src/main/scala/scala/collection/mutable/SortedList.scala deleted file mode 100644 index 9fa9d144..00000000 --- a/mobile/src/main/scala/scala/collection/mutable/SortedList.scala +++ /dev/null @@ -1,155 +0,0 @@ -package scala.collection.mutable - -import scala.collection.SortedSetLike -import scala.collection.generic.MutableSortedSetFactory -import scala.collection.immutable.RedBlackTree.Tree -import scala.collection.immutable.{RedBlackTree => RB} -import scala.runtime.ObjectRef - -/** - * @define Coll `mutable.SortedList` - * @define coll mutable sorted list - * @factoryInfo - * Companion object of SortedList providing factory related utilities. - * @author Mygod - */ -//noinspection ReferenceMustBePrefixed,ScalaDocUnknownTag -object SortedList extends MutableSortedSetFactory[SortedList] { - /** - * The empty list of this type - */ - def empty[A](implicit ordering: Ordering[A]) = new SortedList[A]() -} - -/** - * A mutable SortedList heavily based on SortedSet which "is not designed to enable meaningful subclassing". - * - * Based on: https://github.com/scala/scala/blob/3cc99d7/src/library/scala/collection/mutable/TreeSet.scala - * - * @author Mygod - */ -//noinspection ReferenceMustBePrefixed -class SortedList[A] private(treeRef: ObjectRef[RB.Tree[A, Null]], from: Option[A], until: Option[A]) - (implicit val ordering: Ordering[A]) - extends SortedSet[A] with SetLike[A, SortedList[A]] with SortedSetLike[A, SortedList[A]] with Set[A] with Serializable { - def insert(elem: A): Boolean = { - val old = size - this += elem - old < size - } - - // AbstractSeq - def apply(i: Int): A = try RB.nth(treeRef.elem, i).key catch { - // out of bounds will result in accessing null nodes - case e: NullPointerException => throw new IndexOutOfBoundsException().initCause(e) - } - def length: Int = size - - // Buffer - def +=:(elem: A): SortedList.this.type = +=(elem) // prepending is the same as the list is sorted - def remove(n: Int): A = { - val result = apply(n) - remove(result) - result - } - def update(idx: Int, elem: A) { - val x = apply(idx) - if (!ordering.equiv(x, elem)) { - remove(x) - this.add(elem) - } - } - - // Optimized methods - override def clear(): Unit = treeRef.elem = null - def indexOf[B >: A](elem: B, from: Int = 0): Int = lookupIndex(treeRef.elem, elem.asInstanceOf[A]) match { - case result if result < from => -1 - case result => result - } - override def last: A = RB.greatest(treeRef.elem).key - - // Tree helper methods, TODO: expand to non-recursive form? - private def lookupIndex(tree: Tree[A, Null], x: A): Int = - if (tree eq null) -1 else ordering.compare(x, tree.key) match { - case 0 => RB.count(tree.left) - case cmp if cmp < 0 => lookupIndex(tree.left, x) - case _ => lookupIndex(tree.right, x) match { - case i if i < 0 => i - case i => RB.count(tree.left) + 1 + i - } - } - - // The following is copied from TreeSet - - if (ordering eq null) - throw new NullPointerException("ordering must not be null") - - def this()(implicit ordering: Ordering[A]) = this(new ObjectRef(null), None, None) - - override def size: Int = RB.countInRange(treeRef.elem, from, until) - - override def stringPrefix = "SortedList" - - override def empty: SortedList[A] = SortedList.empty - - private def pickBound(comparison: (A, A) => A, oldBound: Option[A], newBound: Option[A]) = (newBound, oldBound) match { - case (Some(newB), Some(oldB)) => Some(comparison(newB, oldB)) - case (None, _) => oldBound - case _ => newBound - } - - override def rangeImpl(fromArg: Option[A], untilArg: Option[A]): SortedList[A] = { - val newFrom = pickBound(ordering.max, fromArg, from) - val newUntil = pickBound(ordering.min, untilArg, until) - - new SortedList(treeRef, newFrom, newUntil) - } - - override def -=(elem: A): this.type = { - treeRef.elem = RB.delete(treeRef.elem, elem) - this - } - - override def +=(elem: A): this.type = { - treeRef.elem = RB.update(treeRef.elem, elem, null, overwrite = false) - this - } - - /** - * Thanks to the immutable nature of the - * underlying Tree, we can share it with - * the clone. So clone complexity in time is O(1). - * - */ - override def clone(): SortedList[A] = - new SortedList[A](new ObjectRef(treeRef.elem), from, until) - - private val notProjection = !(from.isDefined || until.isDefined) - - override def contains(elem: A): Boolean = { - def leftAcceptable: Boolean = from match { - case Some(lb) => ordering.gteq(elem, lb) - case _ => true - } - - def rightAcceptable: Boolean = until match { - case Some(ub) => ordering.lt(elem, ub) - case _ => true - } - - (notProjection || (leftAcceptable && rightAcceptable)) && - RB.contains(treeRef.elem, elem) - } - - override def iterator: Iterator[A] = iteratorFrom(None) - - override def keysIteratorFrom(start: A): Iterator[A] = iteratorFrom(Some(start)) - - private def iteratorFrom(start: Option[A]) = { - val it = RB.keysIterator(treeRef.elem, pickBound(ordering.max, from, start)) - until match { - case None => it - case Some(ub) => it takeWhile (k => ordering.lt(k, ub)) - } - } -} diff --git a/mobile/src/test/java/com/github/shadowsocks/ExampleUnitTest.kt b/mobile/src/test/java/com/github/shadowsocks/ExampleUnitTest.kt new file mode 100644 index 00000000..fb4ec4b4 --- /dev/null +++ b/mobile/src/test/java/com/github/shadowsocks/ExampleUnitTest.kt @@ -0,0 +1,17 @@ +package com.github.shadowsocks + +import org.junit.Test + +import org.junit.Assert.* + +/** + * Example local unit test, which will execute on the development machine (host). + * + * @see [Testing documentation](http://d.android.com/tools/testing) + */ +class ExampleUnitTest { + @Test + fun addition_isCorrect() { + assertEquals(4, (2 + 2).toLong()) + } +} \ No newline at end of file diff --git a/mobile/src/test/java/com/github/shadowsocks/utils/SubnetTest.kt b/mobile/src/test/java/com/github/shadowsocks/utils/SubnetTest.kt new file mode 100644 index 00000000..07d9a26b --- /dev/null +++ b/mobile/src/test/java/com/github/shadowsocks/utils/SubnetTest.kt @@ -0,0 +1,32 @@ +package com.github.shadowsocks.utils + +import com.github.shadowsocks.utils.Subnet +import org.junit.Assert +import org.junit.Test +import java.net.InetAddress + +object SubnetTest { + @Test + fun parsingAndEquals() { + Assert.assertEquals(Subnet(InetAddress.getByName("1.10.11.12"), 25), Subnet.fromString("1.10.11.12/25")) + Assert.assertEquals(Subnet(InetAddress.getByName("12.118.130.86"), 32), Subnet.fromString("12.118.130.86")) + Assert.assertEquals(Subnet(InetAddress.getByName("caec:cec6:c4ef:bb7b:1a78:d055:216d:3a78"), 96), + Subnet.fromString("caec:cec6:c4ef:bb7b:1a78:d055:216d:3a78/96")) + Assert.assertEquals(Subnet(InetAddress.getByName("be37:44bd:8630:7a0:2a3d:ff95:dd33:42f0"), 128), + Subnet.fromString("be37:44bd:8630:7a0:2a3d:ff95:dd33:42f0")) + Assert.assertNotEquals(Subnet.fromString("1.2.3.4/12"), Subnet.fromString("1.2.3.5/12")) + } + + @Test(expected = IllegalArgumentException::class) + fun invalidParsing1() { + Subnet.fromString("caec:cec6:c4ef:bb7b:1a78:d055:216d:3a78/129") + } + @Test(expected = IllegalArgumentException::class) + fun invalidParsing2() { + Subnet.fromString("caec:cec6:c4ef:bb7b:1a78:d055:216d:3a78/-99") + } + @Test(expected = IllegalArgumentException::class) + fun invalidParsing3() { + Subnet.fromString("caec:cec6:c4ef:bb7b:1a78:d055:216d:3a78/1/0") + } +} diff --git a/plugin/.gitignore b/plugin/.gitignore new file mode 100644 index 00000000..796b96d1 --- /dev/null +++ b/plugin/.gitignore @@ -0,0 +1 @@ +/build diff --git a/plugin/build.gradle b/plugin/build.gradle new file mode 100644 index 00000000..18db03de --- /dev/null +++ b/plugin/build.gradle @@ -0,0 +1,34 @@ +apply plugin: 'com.android.library' +apply plugin: 'kotlin-android' + +android { + compileSdkVersion rootProject.sdkVersion + + defaultConfig { + minSdkVersion rootProject.minSdkVersion + targetSdkVersion rootProject.sdkVersion + versionCode 5 + versionName "0.1.0" + + testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner" + } + + buildTypes { + release { + minifyEnabled false + } + } +} + +dependencies { + api "com.android.support:preference-v7:$supportLibraryVersion" + api "com.android.support:preference-v14:$supportLibraryVersion" + api "com.takisoft.fix:preference-v7:$takisoftFixVersion" + api "org.jetbrains.kotlin:kotlin-stdlib-jre7:$kotlinVersion" + testImplementation "junit:junit:$junitVersion" + androidTestImplementation "com.android.support.test:runner:$androidTestVersion" + androidTestImplementation "com.android.support.test.espresso:espresso-core:$androidEspressoVersion" +} +repositories { + mavenCentral() +} diff --git a/plugin/build.sbt b/plugin/build.sbt deleted file mode 100644 index 5e5df138..00000000 --- a/plugin/build.sbt +++ /dev/null @@ -1,32 +0,0 @@ -enablePlugins(AndroidLib) -android.useSupportVectors - -name := "plugin" -version := "0.0.4" - -pomExtra in Global := { - https://github.com/shadowsocks/shadowsocks-android - - - GPLv3 - https://www.gnu.org/licenses/gpl-3.0.html - - - - scm:git:git://github.com/shadowsocks/shadowsocks-android.git - scm:git:git@github.com:shadowsocks/shadowsocks-android.git - github.com/shadowsocks/shadowsocks-android - - - - madeye - Max Lv - https://github.com/madeye - - - Mygod - Mygod Studio - https://github.com/Mygod - - -} diff --git a/plugin/src/androidTest/java/com/github/shadowsocks/plugin/ExampleInstrumentedTest.java b/plugin/src/androidTest/java/com/github/shadowsocks/plugin/ExampleInstrumentedTest.java new file mode 100644 index 00000000..e0b3716d --- /dev/null +++ b/plugin/src/androidTest/java/com/github/shadowsocks/plugin/ExampleInstrumentedTest.java @@ -0,0 +1,26 @@ +package com.github.shadowsocks.plugin; + +import android.content.Context; +import android.support.test.InstrumentationRegistry; +import android.support.test.runner.AndroidJUnit4; + +import org.junit.Test; +import org.junit.runner.RunWith; + +import static org.junit.Assert.*; + +/** + * Instrumented test, which will execute on an Android device. + * + * @see Testing documentation + */ +@RunWith(AndroidJUnit4.class) +public class ExampleInstrumentedTest { + @Test + public void useAppContext() throws Exception { + // Context of the app under test. + Context appContext = InstrumentationRegistry.getTargetContext(); + + assertEquals("com.github.shadowsocks.plugin.test", appContext.getPackageName()); + } +} diff --git a/plugin/src/main/AndroidManifest.xml b/plugin/src/main/AndroidManifest.xml index 25974a70..46ec0acd 100644 --- a/plugin/src/main/AndroidManifest.xml +++ b/plugin/src/main/AndroidManifest.xml @@ -1,11 +1,8 @@ - + android:theme="@style/Theme.Shadowsocks"> + android:value="0.1.0"/> diff --git a/plugin/src/main/java/com/github/shadowsocks/plugin/ConfigurationActivity.kt b/plugin/src/main/java/com/github/shadowsocks/plugin/ConfigurationActivity.kt new file mode 100644 index 00000000..bb88ad02 --- /dev/null +++ b/plugin/src/main/java/com/github/shadowsocks/plugin/ConfigurationActivity.kt @@ -0,0 +1,69 @@ +/******************************************************************************* + * * + * Copyright (C) 2017 by Max Lv * + * Copyright (C) 2017 by Mygod Studio * + * * + * This program is free software: you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation, either version 3 of the License, or * + * (at your option) any later version. * + * * + * This program is distributed in the hope that it will be useful, * + * but WITHOUT ANY WARRANTY; without even the implied warranty of * + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * + * GNU General Public License for more details. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program. If not, see . * + * * + *******************************************************************************/ + +package com.github.shadowsocks.plugin + +import android.app.Activity +import android.content.Intent + +/** + * Base class for configuration activity. A configuration activity is started when user wishes to configure the + * selected plugin. To create a configuration activity, extend this class, implement abstract methods, invoke + * `saveChanges(options)` and `discardChanges()` when appropriate, and add it to your manifest like this: + * + *

<manifest>
+ *    ...
+ *    <application>
+ *        ...
+ *        <activity android:name=".ConfigureActivity">
+ *            <intent-filter>
+ *                <action android:name="com.github.shadowsocks.plugin.ACTION_CONFIGURE"/>
+ *                <category android:name="android.intent.category.DEFAULT"/>
+ *                <data android:scheme="plugin"
+ *                         android:host="com.github.shadowsocks"
+ *                         android:path="/$PLUGIN_ID"/>
+ *            </intent-filter>
+ *        </activity>
+ *        ...
+ *    </application>
+ *</manifest>
+ */ +abstract class ConfigurationActivity : OptionsCapableActivity() { + /** + * Equivalent to setResult(RESULT_CANCELED). + */ + fun discardChanges() = setResult(Activity.RESULT_CANCELED) + + /** + * Equivalent to setResult(RESULT_OK, args_with_correct_format). + * + * @param options PluginOptions to save. + */ + fun saveChanges(options: PluginOptions) = + setResult(Activity.RESULT_OK, Intent().putExtra(PluginContract.EXTRA_OPTIONS, options.toString())) + + /** + * Finish this activity and request manual editor to pop up instead. + */ + fun fallbackToManualEditor() { + setResult(PluginContract.RESULT_FALLBACK) + finish() + } +} diff --git a/plugin/src/main/java/com/github/shadowsocks/plugin/HelpActivity.kt b/plugin/src/main/java/com/github/shadowsocks/plugin/HelpActivity.kt new file mode 100644 index 00000000..f1e3c752 --- /dev/null +++ b/plugin/src/main/java/com/github/shadowsocks/plugin/HelpActivity.kt @@ -0,0 +1,46 @@ +/******************************************************************************* + * * + * Copyright (C) 2017 by Max Lv * + * Copyright (C) 2017 by Mygod Studio * + * * + * This program is free software: you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation, either version 3 of the License, or * + * (at your option) any later version. * + * * + * This program is distributed in the hope that it will be useful, * + * but WITHOUT ANY WARRANTY; without even the implied warranty of * + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * + * GNU General Public License for more details. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program. If not, see . * + * * + *******************************************************************************/ + +package com.github.shadowsocks.plugin + +/** + * Base class for a help activity. A help activity is started when user taps help when configuring options for your + * plugin. To create a help activity, just extend this class, and add it to your manifest like this: + * + *
<manifest>
+ *    ...
+ *    <application>
+ *        ...
+ *        <activity android:name=".HelpActivity">
+ *            <intent-filter>
+ *                <action android:name="com.github.shadowsocks.plugin.ACTION_HELP"/>
+ *                <category android:name="android.intent.category.DEFAULT"/>
+ *                <data android:scheme="plugin"
+ *                         android:host="com.github.shadowsocks"
+ *                         android:path="/$PLUGIN_ID"/>
+ *            </intent-filter>
+ *        </activity>
+ *        ...
+ *    </application>
+ *</manifest>
+ */ +abstract class HelpActivity : OptionsCapableActivity() { + override fun onInitializePluginOptions(options: PluginOptions) { } +} diff --git a/plugin/src/main/java/com/github/shadowsocks/plugin/HelpCallback.kt b/plugin/src/main/java/com/github/shadowsocks/plugin/HelpCallback.kt new file mode 100644 index 00000000..30bc3af5 --- /dev/null +++ b/plugin/src/main/java/com/github/shadowsocks/plugin/HelpCallback.kt @@ -0,0 +1,37 @@ +/******************************************************************************* + * * + * Copyright (C) 2017 by Max Lv * + * Copyright (C) 2017 by Mygod Studio * + * * + * This program is free software: you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation, either version 3 of the License, or * + * (at your option) any later version. * + * * + * This program is distributed in the hope that it will be useful, * + * but WITHOUT ANY WARRANTY; without even the implied warranty of * + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * + * GNU General Public License for more details. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program. If not, see . * + * * + *******************************************************************************/ + +package com.github.shadowsocks.plugin + +import android.content.Intent + +/** + * HelpCallback is an HelpActivity but you just need to produce a CharSequence help message instead of having to + * provide UI. To create a help callback, just extend this class, implement abstract methods, and add it to your + * manifest following the same procedure as adding a HelpActivity. + */ +abstract class HelpCallback : HelpActivity() { + abstract fun produceHelpMessage(options: PluginOptions): CharSequence + + override fun onInitializePluginOptions(options: PluginOptions) { + setResult(RESULT_OK, Intent().putExtra(PluginContract.EXTRA_HELP_MESSAGE, produceHelpMessage(options))) + finish() + } +} diff --git a/plugin/src/main/java/com/github/shadowsocks/plugin/NativePluginProvider.kt b/plugin/src/main/java/com/github/shadowsocks/plugin/NativePluginProvider.kt new file mode 100644 index 00000000..104484d7 --- /dev/null +++ b/plugin/src/main/java/com/github/shadowsocks/plugin/NativePluginProvider.kt @@ -0,0 +1,100 @@ +/******************************************************************************* + * * + * Copyright (C) 2017 by Max Lv * + * Copyright (C) 2017 by Mygod Studio * + * * + * This program is free software: you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation, either version 3 of the License, or * + * (at your option) any later version. * + * * + * This program is distributed in the hope that it will be useful, * + * but WITHOUT ANY WARRANTY; without even the implied warranty of * + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * + * GNU General Public License for more details. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program. If not, see . * + * * + *******************************************************************************/ + +package com.github.shadowsocks.plugin + +import android.content.ContentProvider +import android.content.ContentValues +import android.database.Cursor +import android.database.MatrixCursor +import android.net.Uri +import android.os.Bundle +import android.os.ParcelFileDescriptor + +/** + * Base class for a native plugin provider. A native plugin provider offers read-only access to files that are required + * to run a plugin, such as binary files and other configuration files. To create a native plugin provider, extend this + * class, implement the abstract methods, and add it to your manifest like this: + * + *
<manifest>
+ *    ...
+ *    <application>
+ *        ...
+ *        <provider android:name="com.github.shadowsocks.$PLUGIN_ID.BinaryProvider"
+ *                     android:authorities="com.github.shadowsocks.plugin.$PLUGIN_ID.BinaryProvider">
+ *            <intent-filter>
+ *                <category android:name="com.github.shadowsocks.plugin.ACTION_NATIVE_PLUGIN" />
+ *            </intent-filter>
+ *        </provider>
+ *        ...
+ *    </application>
+ *</manifest>
+ */ +abstract class NativePluginProvider : ContentProvider() { + override fun getType(p0: Uri?): String = "application/x-elf" + + override fun onCreate(): Boolean = true + + /** + * Provide all files needed for native plugin. + * + * @param provider A helper object to use to add files. + */ + protected abstract fun populateFiles(provider: PathProvider) + + override fun query(uri: Uri, projection: Array?, selection: String?, selectionArgs: Array?, + sortOrder: String?): Cursor { + assert(selection == null && selectionArgs == null && sortOrder == null) + val result = MatrixCursor(projection) + populateFiles(PathProvider(uri, result)) + return result + } + + /** + * Returns executable entry absolute path. This is used if plugin is sharing UID with the host. + * + * Default behavior is throwing UnsupportedOperationException. If you don't wish to use this feature, use the default + * behavior. + * + * @return Absolute path for executable entry. + */ + open fun getExecutable(): String = throw UnsupportedOperationException() + + abstract fun openFile(uri: Uri?): ParcelFileDescriptor + override fun openFile(uri: Uri?, mode: String?): ParcelFileDescriptor { + assert(mode == "r") + return openFile(uri) + } + + override fun call(method: String?, arg: String?, extras: Bundle?): Bundle = when (method) { + PluginContract.METHOD_GET_EXECUTABLE -> { + val out = Bundle() + out.putString(PluginContract.EXTRA_ENTRY, getExecutable()) + out + } + else -> super.call(method, arg, extras) + } + + // Methods that should not be used + override fun insert(p0: Uri?, p1: ContentValues?): Uri = throw UnsupportedOperationException() + override fun update(p0: Uri?, p1: ContentValues?, p2: String?, p3: Array?): Int = + throw UnsupportedOperationException() + override fun delete(p0: Uri?, p1: String?, p2: Array?): Int = throw UnsupportedOperationException() +} diff --git a/plugin/src/main/java/com/github/shadowsocks/plugin/OptionsCapableActivity.kt b/plugin/src/main/java/com/github/shadowsocks/plugin/OptionsCapableActivity.kt new file mode 100644 index 00000000..48809668 --- /dev/null +++ b/plugin/src/main/java/com/github/shadowsocks/plugin/OptionsCapableActivity.kt @@ -0,0 +1,52 @@ +/******************************************************************************* + * * + * Copyright (C) 2017 by Max Lv * + * Copyright (C) 2017 by Mygod Studio * + * * + * This program is free software: you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation, either version 3 of the License, or * + * (at your option) any later version. * + * * + * This program is distributed in the hope that it will be useful, * + * but WITHOUT ANY WARRANTY; without even the implied warranty of * + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * + * GNU General Public License for more details. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program. If not, see . * + * * + *******************************************************************************/ + +package com.github.shadowsocks.plugin + +import android.content.Intent +import android.os.Bundle +import android.support.v7.app.AppCompatActivity +import android.widget.Toast + +/** + * Activity that's capable of getting EXTRA_OPTIONS input. + */ +abstract class OptionsCapableActivity : AppCompatActivity() { + protected fun pluginOptions(intent: Intent = this.intent): PluginOptions { + return try { + PluginOptions(intent.getStringExtra(PluginContract.EXTRA_OPTIONS)) + } catch (exc: IllegalArgumentException) { + Toast.makeText(this, exc.message, Toast.LENGTH_SHORT).show() + PluginOptions() + } + } + + /** + * Populate args to your user interface. + * + * @param options PluginOptions parsed. + */ + protected abstract fun onInitializePluginOptions(options: PluginOptions = pluginOptions()) + + override fun onPostCreate(savedInstanceState: Bundle?) { + super.onPostCreate(savedInstanceState) + if (savedInstanceState == null) onInitializePluginOptions() + } +} diff --git a/plugin/src/main/java/com/github/shadowsocks/plugin/PathProvider.kt b/plugin/src/main/java/com/github/shadowsocks/plugin/PathProvider.kt new file mode 100644 index 00000000..bbe2d6fd --- /dev/null +++ b/plugin/src/main/java/com/github/shadowsocks/plugin/PathProvider.kt @@ -0,0 +1,53 @@ +/******************************************************************************* + * * + * Copyright (C) 2017 by Max Lv * + * Copyright (C) 2017 by Mygod Studio * + * * + * This program is free software: you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation, either version 3 of the License, or * + * (at your option) any later version. * + * * + * This program is distributed in the hope that it will be useful, * + * but WITHOUT ANY WARRANTY; without even the implied warranty of * + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * + * GNU General Public License for more details. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program. If not, see . * + * * + *******************************************************************************/ + +package com.github.shadowsocks.plugin + +import android.database.MatrixCursor +import android.net.Uri +import java.io.File + +/** + * Helper class to provide relative paths of files to copy. + */ +class PathProvider internal constructor(baseUri: Uri, private val cursor: MatrixCursor) { + private val basePath = baseUri.path?.trim('/') ?: "" + + fun addPath(path: String, mode: String = "644"): PathProvider { + val trimmed = path.trim('/') + if (trimmed.startsWith(basePath)) cursor.newRow() + .add(PluginContract.COLUMN_PATH, trimmed) + .add(PluginContract.COLUMN_MODE, mode) + return this + } + fun addTo(file: File, to: String = "", mode: String = "644"): PathProvider { + var sub = to + file.name + if (basePath.startsWith(sub)) if (file.isDirectory) { + sub += '/' + file.listFiles().forEach { addTo(it, sub, mode) } + } else addPath(sub, mode) + return this + } + fun addAt(file: File, at: String = "", mode: String = "644"): PathProvider { + if (basePath.startsWith(at)) + if (file.isDirectory) file.listFiles().forEach { addTo(it, at, mode) } else addPath(at, mode) + return this + } +} diff --git a/plugin/src/main/java/com/github/shadowsocks/plugin/PluginContract.java b/plugin/src/main/java/com/github/shadowsocks/plugin/PluginContract.java index e716d2cb..34ba1e6d 100644 --- a/plugin/src/main/java/com/github/shadowsocks/plugin/PluginContract.java +++ b/plugin/src/main/java/com/github/shadowsocks/plugin/PluginContract.java @@ -1,11 +1,29 @@ +/******************************************************************************* + * * + * Copyright (C) 2017 by Max Lv * + * Copyright (C) 2017 by Mygod Studio * + * * + * This program is free software: you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation, either version 3 of the License, or * + * (at your option) any later version. * + * * + * This program is distributed in the hope that it will be useful, * + * but WITHOUT ANY WARRANTY; without even the implied warranty of * + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * + * GNU General Public License for more details. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program. If not, see . * + * * + *******************************************************************************/ + package com.github.shadowsocks.plugin; /** * The contract between the plugin provider and host. Contains definitions for the supported actions, extras, etc. * * This class is written in Java to keep Java interoperability. - * - * @author Mygod */ public final class PluginContract { private PluginContract() { } diff --git a/plugin/src/main/java/com/github/shadowsocks/plugin/PluginOptions.java b/plugin/src/main/java/com/github/shadowsocks/plugin/PluginOptions.java index 7aa436db..c5f4cc24 100644 --- a/plugin/src/main/java/com/github/shadowsocks/plugin/PluginOptions.java +++ b/plugin/src/main/java/com/github/shadowsocks/plugin/PluginOptions.java @@ -1,3 +1,23 @@ +/******************************************************************************* + * * + * Copyright (C) 2017 by Max Lv * + * Copyright (C) 2017 by Mygod Studio * + * * + * This program is free software: you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation, either version 3 of the License, or * + * (at your option) any later version. * + * * + * This program is distributed in the hope that it will be useful, * + * but WITHOUT ANY WARRANTY; without even the implied warranty of * + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * + * GNU General Public License for more details. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program. If not, see . * + * * + *******************************************************************************/ + package com.github.shadowsocks.plugin; import android.text.TextUtils; @@ -10,8 +30,6 @@ import java.util.StringTokenizer; * Helper class for processing plugin options. * * Based on: https://github.com/apache/ant/blob/588ce1f/src/main/org/apache/tools/ant/types/Commandline.java - * - * @author Mygod */ public final class PluginOptions extends HashMap { public PluginOptions() { diff --git a/plugin/src/main/scala/com/github/shadowsocks/plugin/ConfigurationActivity.scala b/plugin/src/main/scala/com/github/shadowsocks/plugin/ConfigurationActivity.scala deleted file mode 100644 index 3759c868..00000000 --- a/plugin/src/main/scala/com/github/shadowsocks/plugin/ConfigurationActivity.scala +++ /dev/null @@ -1,51 +0,0 @@ -package com.github.shadowsocks.plugin - -import android.app.Activity -import android.content.Intent - -/** - * Base class for configuration activity. A configuration activity is started when user wishes to configure the - * selected plugin. To create a configuration activity, extend this class, implement abstract methods, invoke - * `saveChanges(options)` and `discardChanges()` when appropriate, and add it to your manifest like this: - * - *
<manifest>
-  *    ...
-  *    <application>
-  *        ...
-  *        <activity android:name=".ConfigureActivity">
-  *            <intent-filter>
-  *                <action android:name="com.github.shadowsocks.plugin.ACTION_CONFIGURE"/>
-  *                <category android:name="android.intent.category.DEFAULT"/>
-  *                <data android:scheme="plugin"
-  *                         android:host="com.github.shadowsocks"
-  *                         android:path="/$PLUGIN_ID"/>
-  *            </intent-filter>
-  *        </activity>
-  *        ...
-  *    </application>
-  *</manifest>
- * - * @author Mygod - */ -trait ConfigurationActivity extends OptionsCapableActivity { - /** - * Equivalent to setResult(RESULT_CANCELED). - */ - final def discardChanges(): Unit = setResult(Activity.RESULT_CANCELED) - - /** - * Equivalent to setResult(RESULT_OK, args_with_correct_format). - * - * @param options PluginOptions to save. - */ - final def saveChanges(options: PluginOptions): Unit = - setResult(Activity.RESULT_OK, new Intent().putExtra(PluginContract.EXTRA_OPTIONS, options.toString)) - - /** - * Finish this activity and request manual editor to pop up instead. - */ - final def fallbackToManualEditor() { - setResult(PluginContract.RESULT_FALLBACK) - finish() - } -} diff --git a/plugin/src/main/scala/com/github/shadowsocks/plugin/HelpActivity.scala b/plugin/src/main/scala/com/github/shadowsocks/plugin/HelpActivity.scala deleted file mode 100644 index 591833d9..00000000 --- a/plugin/src/main/scala/com/github/shadowsocks/plugin/HelpActivity.scala +++ /dev/null @@ -1,29 +0,0 @@ -package com.github.shadowsocks.plugin - -/** - * Base class for a help activity. A help activity is started when user taps help when configuring options for your - * plugin. To create a help activity, just extend this class, and add it to your manifest like this: - * - *
<manifest>
-  *    ...
-  *    <application>
-  *        ...
-  *        <activity android:name=".HelpActivity">
-  *            <intent-filter>
-  *                <action android:name="com.github.shadowsocks.plugin.ACTION_HELP"/>
-  *                <category android:name="android.intent.category.DEFAULT"/>
-  *                <data android:scheme="plugin"
-  *                         android:host="com.github.shadowsocks"
-  *                         android:path="/$PLUGIN_ID"/>
-  *            </intent-filter>
-  *        </activity>
-  *        ...
-  *    </application>
-  *</manifest>
- * - * @author Mygod - */ -trait HelpActivity extends OptionsCapableActivity { - // HelpActivity can choose to ignore options - override protected def onInitializePluginOptions(options: PluginOptions): Unit = () -} diff --git a/plugin/src/main/scala/com/github/shadowsocks/plugin/HelpCallback.scala b/plugin/src/main/scala/com/github/shadowsocks/plugin/HelpCallback.scala deleted file mode 100644 index 76c11b09..00000000 --- a/plugin/src/main/scala/com/github/shadowsocks/plugin/HelpCallback.scala +++ /dev/null @@ -1,20 +0,0 @@ -package com.github.shadowsocks.plugin - -import android.app.Activity -import android.content.Intent - -/** - * HelpCallback is an HelpActivity but you just need to produce a CharSequence help message instead of having to - * provide UI. To create a help callback, just extend this class, implement abstract methods, and add it to your - * manifest following the same procedure as adding a HelpActivity. - * - * @author Mygod - */ -trait HelpCallback extends HelpActivity { - def produceHelpMessage(options: PluginOptions): CharSequence - - override protected def onInitializePluginOptions(options: PluginOptions) { - setResult(Activity.RESULT_OK, new Intent().putExtra(PluginContract.EXTRA_HELP_MESSAGE, produceHelpMessage(options))) - finish() - } -} diff --git a/plugin/src/main/scala/com/github/shadowsocks/plugin/NativePluginProvider.scala b/plugin/src/main/scala/com/github/shadowsocks/plugin/NativePluginProvider.scala deleted file mode 100644 index 412ab0c1..00000000 --- a/plugin/src/main/scala/com/github/shadowsocks/plugin/NativePluginProvider.scala +++ /dev/null @@ -1,83 +0,0 @@ -package com.github.shadowsocks.plugin - -import android.content.{ContentProvider, ContentValues} -import android.database.{Cursor, MatrixCursor} -import android.net.Uri -import android.os.{Bundle, ParcelFileDescriptor} - -/** - * Base class for a native plugin provider. A native plugin provider offers read-only access to files that are required - * to run a plugin, such as binary files and other configuration files. To create a native plugin provider, extend this - * class, implement the abstract methods, and add it to your manifest like this: - * - *
<manifest>
-  *    ...
-  *    <application>
-  *        ...
-  *        <provider android:name="com.github.shadowsocks.$PLUGIN_ID.BinaryProvider"
-  *                     android:authorities="com.github.shadowsocks.plugin.$PLUGIN_ID.BinaryProvider">
-  *            <intent-filter>
-  *                <category android:name="com.github.shadowsocks.plugin.ACTION_NATIVE_PLUGIN" />
-  *            </intent-filter>
-  *        </provider>
-  *        ...
-  *    </application>
-  *</manifest>
- * - * @author Mygod - */ -abstract class NativePluginProvider extends ContentProvider { - /** - * @inheritdoc - * - * NativePluginProvider returns application/x-elf by default. It's implementer's responsibility to change this to - * correct type. - */ - override def getType(uri: Uri): String = "application/x-elf" - - override def onCreate(): Boolean = true - - /** - * Provide all files needed for native plugin. - * - * @param provider A helper object to use to add files. - */ - protected def populateFiles(provider: PathProvider) - - override def query(uri: Uri, projection: Array[String], selection: String, selectionArgs: Array[String], - sortOrder: String): Cursor = { - assert(selection == null && selectionArgs == null && sortOrder == null) - val result = new MatrixCursor(projection) - populateFiles(new PathProvider(uri, result)) - result - } - - /** - * Returns executable entry absolute path. This is used if plugin is sharing UID with the host. - * - * Default behavior is throwing UnsupportedOperationException. If you don't wish to use this feature, use the default - * behavior. - * - * @return Absolute path for executable entry. - */ - def getExecutable: String = throw new UnsupportedOperationException - - def openFile(uri: Uri): ParcelFileDescriptor - override def openFile(uri: Uri, mode: String): ParcelFileDescriptor = { - assert(mode == "r") - openFile(uri) - } - - override def call(method: String, arg: String, extras: Bundle): Bundle = method match { - case PluginContract.METHOD_GET_EXECUTABLE => - val out = new Bundle() - out.putString(PluginContract.EXTRA_ENTRY, getExecutable) - out - case _ => super.call(method, arg, extras) - } - - // Methods that should not be used - override def update(uri: Uri, values: ContentValues, selection: String, selectionArgs: Array[String]): Int = ??? - override def insert(uri: Uri, values: ContentValues): Uri = ??? - override def delete(uri: Uri, selection: String, selectionArgs: Array[String]): Int = ??? -} diff --git a/plugin/src/main/scala/com/github/shadowsocks/plugin/OptionsCapableActivity.scala b/plugin/src/main/scala/com/github/shadowsocks/plugin/OptionsCapableActivity.scala deleted file mode 100644 index 829e495d..00000000 --- a/plugin/src/main/scala/com/github/shadowsocks/plugin/OptionsCapableActivity.scala +++ /dev/null @@ -1,32 +0,0 @@ -package com.github.shadowsocks.plugin - -import android.content.Intent -import android.os.Bundle -import android.support.v7.app.AppCompatActivity -import android.widget.Toast - -/** - * Activity that's capable of getting EXTRA_OPTIONS input. - * - * @author Mygod - */ -trait OptionsCapableActivity extends AppCompatActivity { - protected def pluginOptions(intent: Intent = getIntent): PluginOptions = - try new PluginOptions(intent.getStringExtra(PluginContract.EXTRA_OPTIONS)) catch { - case exc: IllegalArgumentException => - Toast.makeText(this, exc.getMessage, Toast.LENGTH_SHORT).show() - null - } - - /** - * Populate args to your user interface. - * - * @param options PluginOptions parsed. - */ - protected def onInitializePluginOptions(options: PluginOptions = pluginOptions()): Unit - - override protected def onPostCreate(savedInstanceState: Bundle) { - super.onPostCreate(savedInstanceState) - if (savedInstanceState == null) onInitializePluginOptions() - } -} diff --git a/plugin/src/main/scala/com/github/shadowsocks/plugin/PathProvider.scala b/plugin/src/main/scala/com/github/shadowsocks/plugin/PathProvider.scala deleted file mode 100644 index 536e94e8..00000000 --- a/plugin/src/main/scala/com/github/shadowsocks/plugin/PathProvider.scala +++ /dev/null @@ -1,39 +0,0 @@ -package com.github.shadowsocks.plugin - -import java.io.File - -import android.database.MatrixCursor -import android.net.Uri - -/** - * Helper class to provide relative paths of files to copy. - * - * @author Mygod - */ -final class PathProvider private[plugin](baseUri: Uri, cursor: MatrixCursor) { - private val basePath = baseUri.getPath match { - case null => "" - case p => p.stripPrefix("/").stripSuffix("/") - } - - def addPath(path: String, mode: String = "644"): PathProvider = { - val stripped = path.stripPrefix("/").stripSuffix("/") - if (stripped.startsWith(basePath)) cursor.newRow() - .add(PluginContract.COLUMN_PATH, stripped) - .add(PluginContract.COLUMN_MODE, mode) - this - } - def addTo(file: File, to: String = "", mode: String = "644"): PathProvider = { - var sub = to + file.getName - if (basePath.startsWith(sub)) if (file.isDirectory) { - sub += '/' - file.listFiles().foreach(addTo(_, sub, mode)) - } else addPath(sub, mode) - this - } - def addAt(file: File, at: String = "", mode: String = "644"): PathProvider = { - if (basePath.startsWith(at)) - if (file.isDirectory) file.listFiles().foreach(addTo(_, at, mode)) else addPath(at, mode) - this - } -} diff --git a/plugin/src/test/java/com/github/shadowsocks/plugin/ExampleUnitTest.java b/plugin/src/test/java/com/github/shadowsocks/plugin/ExampleUnitTest.java new file mode 100644 index 00000000..7b4ec7d4 --- /dev/null +++ b/plugin/src/test/java/com/github/shadowsocks/plugin/ExampleUnitTest.java @@ -0,0 +1,17 @@ +package com.github.shadowsocks.plugin; + +import org.junit.Test; + +import static org.junit.Assert.*; + +/** + * Example local unit test, which will execute on the development machine (host). + * + * @see Testing documentation + */ +public class ExampleUnitTest { + @Test + public void addition_isCorrect() throws Exception { + assertEquals(4, 2 + 2); + } +} \ No newline at end of file diff --git a/project/build.properties b/project/build.properties deleted file mode 100644 index c091b86c..00000000 --- a/project/build.properties +++ /dev/null @@ -1 +0,0 @@ -sbt.version=0.13.16 diff --git a/project/plugins.sbt b/project/plugins.sbt deleted file mode 100644 index bf868f42..00000000 --- a/project/plugins.sbt +++ /dev/null @@ -1,11 +0,0 @@ -addSbtPlugin("org.scala-android" % "sbt-android" % "1.7.10") - -addSbtPlugin("com.timushev.sbt" % "sbt-updates" % "0.1.10") - -addSbtPlugin("net.virtual-void" % "sbt-dependency-graph" % "0.8.2") - -addSbtPlugin("org.xerial.sbt" % "sbt-sonatype" % "1.1") - -addSbtPlugin("com.jsuereth" % "sbt-pgp" % "1.0.0") // fot sbt-0.13.5 or higher - -addSbtPlugin("org.scala-android" % "sbt-android-gms" % "0.4") diff --git a/settings.gradle b/settings.gradle new file mode 100644 index 00000000..94dd6878 --- /dev/null +++ b/settings.gradle @@ -0,0 +1 @@ +include ':mobile', ':core', ':plugin'