diff --git a/mobile/src/main/java/com/github/shadowsocks/utils/Commandline.java b/mobile/src/main/java/com/github/shadowsocks/utils/Commandline.java new file mode 100644 index 00000000..112df2d5 --- /dev/null +++ b/mobile/src/main/java/com/github/shadowsocks/utils/Commandline.java @@ -0,0 +1,146 @@ +package com.github.shadowsocks.utils; + +import java.util.ArrayList; +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 line 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[] line) { + // empty path return empty string + if (line == null || line.length == 0) { + return ""; + } + // path containing one or more elements + final StringBuilder result = new StringBuilder(); + for (int i = 0; i < line.length; i++) { + if (i > 0) { + result.append(' '); + } + for (int j = 0; j < line[i].length(); ++j) { + char ch = line[i].charAt(j); + switch (ch) { + case ' ': case '\\': case '"': case '\'': result.append('\\'); // intentionally no break + default: result.append(ch); + } + } + } + return result.toString(); + } + + /** + * 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/scala/com/github/shadowsocks/utils/Utils.scala b/mobile/src/main/scala/com/github/shadowsocks/utils/Utils.scala index 348fc6a4..4937adfd 100644 --- a/mobile/src/main/scala/com/github/shadowsocks/utils/Utils.scala +++ b/mobile/src/main/scala/com/github/shadowsocks/utils/Utils.scala @@ -23,9 +23,8 @@ package com.github.shadowsocks.utils import java.io.File import java.net._ import java.security.MessageDigest -import java.util.{Scanner, StringTokenizer} +import java.util.Scanner -import android.animation.{Animator, AnimatorListenerAdapter} import android.content.pm.PackageManager import android.content.{Context, Intent} import android.graphics._ @@ -39,7 +38,6 @@ import com.github.shadowsocks.{BuildConfig, ShadowsocksRunnerService} import org.xbill.DNS._ import scala.collection.JavaConversions._ -import scala.collection.mutable.ArrayBuffer import scala.concurrent.ExecutionContext.Implicits.global import scala.concurrent.Future import scala.util.{Failure, Try} @@ -98,48 +96,6 @@ object Utils { } } - /** - * Crack a command line. - * Based on: https://github.com/apache/ant/blob/588ce1f/src/main/org/apache/tools/ant/types/Commandline.java#L471 - * @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 ArrayBuffer. - */ - def translateCommandline(toProcess: String): ArrayBuffer[String] = { - if (toProcess == null || toProcess.length == 0) return ArrayBuffer[String]() - val tok = new StringTokenizer(toProcess, "\"' ", true) - val result = ArrayBuffer[String]() - val current = new StringBuilder() - var quote = ' ' - var last = " " - while (tok.hasMoreTokens) { - val nextTok = tok.nextToken - quote match { - case '\'' => nextTok match { - case "'" => quote = ' ' - case _ => current.append(nextTok) - } - case '"' => nextTok match { - case "\"" => quote = ' ' - case _ => current.append(nextTok) - } - case _ => nextTok match { - case "'" => quote = '\'' - case "\"" => quote = '"' - case " " => if (last != " ") { - result.append(current.toString) - current.setLength(0) - } - case _ => current.append(nextTok) - } - } - last = nextTok - } - if (current.nonEmpty) result.append(current.toString) - if (quote == '\'' || quote == '"') throw new Exception("Unbalanced quotes in " + toProcess) - result - } - def resolve(host: String, addrType: Int): Option[String] = { try { val lookup = new Lookup(host, addrType) diff --git a/plugin/src/main/java/com/github/shadowsocks/plugin/PluginInterface.java b/plugin/src/main/java/com/github/shadowsocks/plugin/PluginInterface.java new file mode 100644 index 00000000..480de520 --- /dev/null +++ b/plugin/src/main/java/com/github/shadowsocks/plugin/PluginInterface.java @@ -0,0 +1,82 @@ +package com.github.shadowsocks.plugin; + +/** + * This class provides String constants that will be used in plugin interfaces. + * + * This class is written in Java to keep Java interoperability. + * + * @author Mygod + */ +public final class PluginInterface { + private PluginInterface() { } + + /** + * Should be a NativePluginProvider. + * + * Constant Value: "com.github.shadowsocks.plugin.CATEGORY_NATIVE_PLUGIN" + */ + public static final String CATEGORY_NATIVE_PLUGIN = "com.github.shadowsocks.plugin.CATEGORY_NATIVE_PLUGIN"; + + /** + * The lookup key for a string that provides the whole command as a string. + * + * Example: "obfs=http;obfs-host=www.baidu.com" + * + * Constant Value: "com.github.shadowsocks.plugin.EXTRA_OPTIONS" + */ + public static final String EXTRA_OPTIONS = "com.github.shadowsocks.plugin.EXTRA_OPTIONS"; + /** + * The lookup key for a CharSequence that provides user relevant help message. + * + * Example: "obfs= Enable obfuscating: HTTP or TLS (Experimental). + * obfs-host= Hostname for obfuscating (Experimental)." + * + * Constant Value: "com.github.shadowsocks.plugin.EXTRA_HELP_MESSAGE" + */ + public static final String EXTRA_HELP_MESSAGE = "com.github.shadowsocks.plugin.EXTRA_HELP_MESSAGE"; + + /** + * The metadata key to retrieve default configuration. Default value is empty. + * + * Constant Value: "com.github.shadowsocks.plugin.default_config" + */ + public static final String METADATA_KEY_DEFAULT_CONFIG = "com.github.shadowsocks.plugin.default_config"; + + /** + * Relative to the file to be copied. This column is required. + * + * Example: "kcptun", "doc/help.txt" + * + * Type: String + */ + public static final String COLUMN_PATH = "path"; + + /** + * Authority to use for native plugin ContentProvider. + * + * @param pluginId Plugin ID. + * @return com.github.shadowsocks.plugin.$PLUGIN_ID + */ + public static String getAuthority(String pluginId) { + return "com.github.shadowsocks.plugin." + pluginId; + } + + /** + * Activity Action: Used for ConfigurationActivity. + * + * @param pluginId Plugin ID. + * @return com.github.shadowsocks.plugin.$PLUGIN_ID.ACTION_CONFIGURE + */ + public static String ACTION_CONFIGURE(String pluginId) { + return getAuthority(pluginId) + ".ACTION_CONFIGURE"; + } + /** + * Activity Action: Used for HelpActivity or HelpCallback. + * + * @param pluginId Plugin ID. + * @return com.github.shadowsocks.plugin.$PLUGIN_ID.ACTION_HELP + */ + public static String ACTION_HELP(String pluginId) { + return getAuthority(pluginId) + ".ACTION_HELP"; + } +} diff --git a/plugin/src/main/java/com/github/shadowsocks/plugin/PluginOptions.java b/plugin/src/main/java/com/github/shadowsocks/plugin/PluginOptions.java new file mode 100644 index 00000000..de76ac71 --- /dev/null +++ b/plugin/src/main/java/com/github/shadowsocks/plugin/PluginOptions.java @@ -0,0 +1,78 @@ +package com.github.shadowsocks.plugin; + +import android.text.TextUtils; + +import java.util.HashMap; +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() { + super(); + } + public PluginOptions(int initialCapacity) { + super(initialCapacity); + } + public PluginOptions(int initialCapacity, float loadFactor) { + super(initialCapacity, loadFactor); + } + + public PluginOptions(String options) throws IllegalArgumentException { + if (TextUtils.isEmpty(options)) return; + final StringTokenizer tokenizer = new StringTokenizer(options, "\\=;", true); + final StringBuilder current = new StringBuilder(); + String key = null; + boolean firstEntry = true; + while (tokenizer.hasMoreTokens()) { + String nextToken = tokenizer.nextToken(); + if ("\\".equals(nextToken)) current.append(tokenizer.nextToken()); + else if ("=".equals(nextToken)) { + if (key != null) throw new IllegalArgumentException("Duplicate keys in " + options); + key = current.toString(); + current.setLength(0); + } else if (";".equals(nextToken)) { + if (current.length() > 0) put(key, current.toString()); + else if (firstEntry) id = key; + else throw new IllegalArgumentException("Value missing in " + options); + firstEntry = false; + } + } + } + public PluginOptions(String id, String options) throws IllegalArgumentException { + this(options); + this.id = id; + } + + public String id; + + private static void append(StringBuilder result, String str) { + for (int i = 0; i < str.length(); ++i) { + char ch = str.charAt(i); + switch (ch) { + case '\\': case '=': case ';': result.append('\\'); // intentionally no break + default: result.append(ch); + } + } + } + public String toString(boolean trimId) { + final StringBuilder result = new StringBuilder(); + if (!trimId && !TextUtils.isEmpty(id)) append(result, id); + for (Entry entry : entrySet()) if (entry.getValue() != null) { + if (result.length() > 0) result.append(';'); + append(result, entry.getKey()); + result.append('='); + append(result, entry.getValue()); + } + return result.toString(); + } + @Override + public String toString() { + return toString(false); + } +} diff --git a/mobile/src/main/res/layout/toolbar_light_dark.xml b/plugin/src/main/res/layout/toolbar_light_dark.xml similarity index 100% rename from mobile/src/main/res/layout/toolbar_light_dark.xml rename to plugin/src/main/res/layout/toolbar_light_dark.xml diff --git a/plugin/src/main/scala/com/github/shadowsocks/plugin/ConfigurationActivity.scala b/plugin/src/main/scala/com/github/shadowsocks/plugin/ConfigurationActivity.scala new file mode 100644 index 00000000..2200d79f --- /dev/null +++ b/plugin/src/main/scala/com/github/shadowsocks/plugin/ConfigurationActivity.scala @@ -0,0 +1,39 @@ +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="com.github.shadowsocks.$PLUGIN_ID.ConfigureActivity">
+  *            <intent-filter>
+  *                <action android:name="com.github.shadowsocks.plugin.$PLUGIN_ID.ACTION_CONFIGURE" />
+  *            </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(PluginInterface.EXTRA_OPTIONS, options.toString)) +} diff --git a/plugin/src/main/scala/com/github/shadowsocks/plugin/HelpActivity.scala b/plugin/src/main/scala/com/github/shadowsocks/plugin/HelpActivity.scala new file mode 100644 index 00000000..40288b8c --- /dev/null +++ b/plugin/src/main/scala/com/github/shadowsocks/plugin/HelpActivity.scala @@ -0,0 +1,25 @@ +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="com.github.shadowsocks.$PLUGIN_ID.HelpActivity">
+  *            <intent-filter>
+  *                <action android:name="com.github.shadowsocks.plugin.$PLUGIN_ID.ACTION_HELP" />
+  *            </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 new file mode 100644 index 00000000..1babb814 --- /dev/null +++ b/plugin/src/main/scala/com/github/shadowsocks/plugin/HelpCallback.scala @@ -0,0 +1,18 @@ +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): Unit = setResult(Activity.RESULT_OK, + new Intent().putExtra(PluginInterface.EXTRA_HELP_MESSAGE, produceHelpMessage(options))) +} diff --git a/plugin/src/main/scala/com/github/shadowsocks/plugin/NativePluginProvider.scala b/plugin/src/main/scala/com/github/shadowsocks/plugin/NativePluginProvider.scala new file mode 100644 index 00000000..1fdc7845 --- /dev/null +++ b/plugin/src/main/scala/com/github/shadowsocks/plugin/NativePluginProvider.scala @@ -0,0 +1,65 @@ +package com.github.shadowsocks.plugin + +import android.content.{ContentProvider, ContentValues} +import android.database.{Cursor, MatrixCursor} +import android.net.Uri +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.PluginProvider"
+  *                     android:authorities="com.github.shadowsocks.$PLUGIN_ID">
+  *            <intent-filter>
+  *                <category android:name="com.github.shadowsocks.plugin.CATEGORY_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 = { + if (selection != null || selectionArgs != null || sortOrder != null) ??? + val result = new MatrixCursor(projection.filter(_ == PluginInterface.COLUMN_PATH)) + populateFiles(new PathProvider(uri, result)) + result + } + + def openFile(uri: Uri): ParcelFileDescriptor + override def openFile(uri: Uri, mode: String): ParcelFileDescriptor = { + if (mode != "r") ??? + openFile(uri) + } + + // 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 new file mode 100644 index 00000000..40dbd96a --- /dev/null +++ b/plugin/src/main/scala/com/github/shadowsocks/plugin/OptionsCapableActivity.scala @@ -0,0 +1,32 @@ +package com.github.shadowsocks.plugin + +import android.app.Activity +import android.content.Intent +import android.os.Bundle +import android.widget.Toast + +/** + * Activity that's capable of getting EXTRA_OPTIONS input. + * + * @author Mygod + */ +trait OptionsCapableActivity extends Activity { + protected def pluginOptions(intent: Intent = getIntent): PluginOptions = + try new PluginOptions(intent.getStringExtra(PluginInterface.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 new file mode 100644 index 00000000..4eddf6d6 --- /dev/null +++ b/plugin/src/main/scala/com/github/shadowsocks/plugin/PathProvider.scala @@ -0,0 +1,36 @@ +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): PathProvider = { + val stripped = path.stripPrefix("/").stripSuffix("/") + if (stripped.startsWith(basePath)) cursor.newRow().add(PluginInterface.COLUMN_PATH, stripped) + this + } + def addTo(file: File, to: String = ""): PathProvider = { + var sub = to + file.getName + if (basePath.startsWith(sub)) if (file.isDirectory) { + sub += '/' + file.listFiles().foreach(addTo(_, sub)) + } else addPath(sub) + this + } + def addAt(file: File, at: String = ""): PathProvider = { + if (basePath.startsWith(at)) if (file.isDirectory) file.listFiles().foreach(addTo(_, at)) else addPath(at) + this + } +}