diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml
index b1c0202..9cfbced 100644
--- a/.github/workflows/android.yml
+++ b/.github/workflows/android.yml
@@ -18,6 +18,11 @@ jobs:
run: ./gradlew assembleRelease
env:
KEY_PASS: ${{secrets.KEY_PASS}}
+ - name: Upload artifacts
+ uses: actions/upload-artifact@v2
+ with:
+ name: ShadowsocksGostPlugin.apk
+ path: app/build/outputs/apk/release/app-release.apk
- name: Release Build
uses: meeDamian/github-release@1.0
with:
diff --git a/.gitmodules b/.gitmodules
new file mode 100644
index 0000000..a922e13
--- /dev/null
+++ b/.gitmodules
@@ -0,0 +1,3 @@
+[submodule "gost"]
+ path = gost
+ url = https://github.com/segfault-bilibili/gost.git
diff --git a/README.md b/README.md
index 443d707..a4c1c84 100644
--- a/README.md
+++ b/README.md
@@ -9,30 +9,45 @@
> [Gost](https://github.com/ginuerzh/gost) 的 [Shadowsocks Android](https://github.com/shadowsocks/shadowsocks-android) 插件,可以直接在Shadowsocks安卓客户端上连接 Gost 服务器
-## 🚀 安装
+> [Gost](https://github.com/ginuerzh/gost) Plugin for [Shadowsocks Android](https://github.com/shadowsocks/shadowsocks-android), which allows directly connecting to a Gost server from Shadowsocks-Android client
+
+## 🚀 安装 Install
下载 [Release](https://github.com/xausky/ShadowsocksGostPlugin/releases) 内预编译好的APK安装到设备,同时也要安装 [Shadowsocks Android](https://github.com/shadowsocks/shadowsocks-android)
-## 🔧 使用
+Download prebuilt APK here [Release](https://github.com/xausky/ShadowsocksGostPlugin/releases) and then install it to the device, at same time [Shadowsocks Android](https://github.com/shadowsocks/shadowsocks-android) is required to be installed as well
+
+## 🔧 使用 Usage
* 在 Shadowsocks 客户端选中本插件,即可在参数配置除了 `-L` 参数之外的 Gost 参数以连接远程服务器或者多段代理
-* `-L` 参数会自动添加,只需要在 Shadowsocks 界面配置 `rc4-md5` 加密和 `gost` 密码即可
-* Gost 的参数配置里面可以用`#SS_HOST`和`#SS_PORT`代替 Shadowsocks 配置的主机和端口
+* `-L` 参数会自动添加,只需要在 Shadowsocks 界面配置 `none` 无加密和空密码即可
+* Gost 的命令行参数配置里面可以用`#SS_HOST`和`#SS_PORT`代替 Shadowsocks 配置的主机和端口
+
+* Pick this plugin in Shadowsocks client, then Gost can be configured (except `-L` parameter) to connect to a remote server, or multi-hop proxies
+* `-L` will be automatically added, just configure Shadowsocks to use `none` encryption and empty password
+* In Gost's command-line parameters, `#SS_HOST`和`#SS_PORT` represents host and port configured in Shadowsocks
-## ❗ 注意
+## ❗ 注意 Notices
* 使用#SS_HOST参数会先对填写的主机名进行DNS解析后才传递
* 如果是与主机名相关的远程协议比如ws协议必须直接在参数里配置域名
* 在参数里面配置的域名会忽略手机系统的DNS配置固定使用 Public DNS+
-* 如果插件参数里面使用 `-F=` 形式的参数传递则后续参数不能含有 `=` 号,推荐使用 `-F ` 形式代替
+* ~如果插件参数里面使用 `-F=` 形式的参数传递则后续参数不能含有 `=` 号,推荐使用 `-F ` 形式代替~ 使用新版配置格式(CFGBLOB)即可避开这个问题
-## ❤ 关注我
+* Host specified by #SS_HOST will be firstly resolved with DNS before being passed on
+* If the hostname is tied to the protocol, like WebSocket (ws), you must directly use domain name in configuration parameters
+* The domain name(s) appeared in configuration parameters is/are hard-coded to be resolved with Public DNS+, in other words, ignoring the OS's DNS configurations
+* ~If you configure a parameter in the form of `-F=`, then the subsequent parameters can no longer contain `=`, so it's recommended to use the form of `-F ` instead~ This issue can be avoided simply by using new config format (CFGBLOB)
+
+## ❤ 关注我 Follow me
* Github: [@xausky](https://github.com/xausky)
* BiliBili: [@xausky](https://space.bilibili.com/8419077)
-## 🤝 贡献
+## 🤝 贡献 Contribution
欢迎各种问题,需求,BUG报告和代码PR!
提交到这里就可以 [问题页面](https://github.com/xausky/ShadowsocksGostPlugin/issues).
-### ⭐ 如果这个项目帮到你的话欢迎点个星
+Any kind of questions, feature requests, bug reports or pull requests are welcomed!
Simply submit it here [issues](https://github.com/xausky/ShadowsocksGostPlugin/issues).
+
+### ⭐ 如果这个项目帮到你的话欢迎点个星 If you feel this project can help you, you are welcomed to tick a star
diff --git a/app/build.gradle b/app/build.gradle
index 6bf03f3..aed0908 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -6,8 +6,8 @@ android {
applicationId "com.github.shadowsocks.plugin.gost"
minSdkVersion 21
targetSdkVersion 29
- versionCode 2110
- versionName "2.11.0-1"
+ versionCode 2111
+ versionName "2.11.1"
}
signingConfigs {
releaseConfig {
@@ -28,6 +28,8 @@ android {
dependencies {
implementation 'com.github.shadowsocks:plugin:1.2.0'
+ implementation 'androidx.appcompat:appcompat:1.0.0'
+ implementation 'androidx.constraintlayout:constraintlayout:1.1.3'
}
task buildGoLibrary(type: Exec) {
@@ -41,4 +43,4 @@ tasks.whenTaskAdded { theTask ->
if (theTask.name.equals("preDebugBuild") || theTask.name.equals("preReleaseBuild")) {
theTask.dependsOn "buildGoLibrary"
}
-}
+}
\ No newline at end of file
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 3bdd557..827dd46 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -1,28 +1,44 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/java/com/github/shadowsocks/plugin/gost/Base64.java b/app/src/main/java/com/github/shadowsocks/plugin/gost/Base64.java
new file mode 100644
index 0000000..c61967e
--- /dev/null
+++ b/app/src/main/java/com/github/shadowsocks/plugin/gost/Base64.java
@@ -0,0 +1,106 @@
+package com.github.shadowsocks.plugin.gost;
+
+public class Base64 {
+ private char paddingChar = '=';
+
+ public void setPaddingChar(char c) throws Base64Exception {
+ if (
+ (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') ||
+ (c >= '0' && c <= '9') ||
+ c == '+' || c == '/'
+ ) throw new Base64Exception("INVALID_PADDING_CHAR");
+ this.paddingChar = c;
+ }
+
+ public static class Base64Exception extends Exception {
+ String msg;
+ @Override
+ public String getMessage() {
+ return this.msg;
+ }
+ Base64Exception(String msg) {
+ this.msg = msg;
+ }
+ }
+
+ public byte[] decode(String encoded) throws Base64Exception {
+ char[] input = new char[encoded.length()];
+ encoded.getChars(0, encoded.length(), input, 0);
+ return this.decode(input);
+ }
+ public byte[] decode(char[] input) throws Base64Exception {
+ if (input.length % 4 != 0)
+ throw new Base64Exception("BASE64_DECODE_INVALID_LENGTH");
+ if (input.length == 0) return new byte[0];
+ int endPos = 0;
+ for (int i = input.length - 1; i >= input.length - 4; i--) {
+ char c = input[i];
+ if (
+ (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') ||
+ (c >= '0' && c <= '9') ||
+ c == '+' || c == '/'
+ ) {
+ endPos = i + 1;
+ break;
+ } else if (c != this.paddingChar) {
+ throw new Base64Exception("BASE64_DECODE_INVALID_CHAR");
+ } else if (i == input.length - 4) {
+ throw new Base64Exception("BASE64_DECODE_INVALID_PADDING");
+ }
+ }
+ int resultLen = (endPos * 6 + 8 - 1) / 8;
+ byte[] result = new byte[resultLen];
+ for (int i = 0, o = 0, buf = 0; i < endPos; i++) {
+ char c = input[i];
+ if (c >= 'A' && c <= 'Z') {
+ c -= 'A';
+ } else if (c >= 'a' && c <= 'z') {
+ c += 26 - 'a';
+ } else if (c >= '0' && c <= '9') {
+ c += 26 + 26 - '0';
+ } else if (c == '+') {
+ c = 26 + 26 + 10;
+ } else if (c == '/') {
+ c = 26 + 26 + 10 + 1;
+ } else throw new Base64Exception("BASE64_DECODE_INVALID_CHAR");
+ buf |= (((int) c) & 0xFF) << (3 - (i % 4)) * 6;
+ if ((i + 1) % 4 == 0 || i == endPos - 1) {
+ for (int j = 0; j < 3 && o < resultLen; j++, o++) {
+ result[o] = (byte) ((buf >> (2 - j) * 8) & 0xFF);
+ }
+ buf = 0;
+ }
+ }
+ return result;
+ }
+ public String encode(byte[] bin) {
+ int resultLen = (bin.length * 8 + 6 - 1) / 6;
+ int outputLen = (resultLen + 4 - 1) / 4;
+ outputLen *= 4;
+ char[] output = new char[outputLen];
+ for (int i = 0, o = 0, buf = 0; i < bin.length; i++) {
+ buf |= (((int) bin[i]) & 0xFF) << (2 - (i % 3)) * 8;
+ if ((i + 1) % 3 == 0 || i == bin.length - 1) {
+ for (int j = 0; j < 4 && o < resultLen; j++, o++) {
+ int c = (buf >> (3 - j) * 6) & 0x3F;
+ if (c < 26) {
+ output[o] = (char) ('A' + c);
+ } else if (c < 26 + 26) {
+ output[o] = (char) ('a' + c - 26);
+ } else if (c < 26 + 26 + 10) {
+ output[o] = (char) ('0' + c - (26 + 26));
+ } else if (c == 26 + 26 + 10) {
+ output[o] = '+';
+ } else { // always (c == 26 + 26 + 10 + 1)
+ output[o] = '/';
+ }
+ }
+ buf = 0;
+ }
+ }
+ for (int i = resultLen; i < outputLen; i++) {
+ output[i] = this.paddingChar;
+ }
+ return new String(output);
+ }
+}
diff --git a/app/src/main/java/com/github/shadowsocks/plugin/gost/BinaryProvider.java b/app/src/main/java/com/github/shadowsocks/plugin/gost/BinaryProvider.java
index 4cc89fe..02550be 100644
--- a/app/src/main/java/com/github/shadowsocks/plugin/gost/BinaryProvider.java
+++ b/app/src/main/java/com/github/shadowsocks/plugin/gost/BinaryProvider.java
@@ -1,6 +1,5 @@
package com.github.shadowsocks.plugin.gost;
-import android.content.pm.Signature;
import android.net.Uri;
import android.os.ParcelFileDescriptor;
import com.github.shadowsocks.plugin.NativePluginProvider;
diff --git a/app/src/main/java/com/github/shadowsocks/plugin/gost/ConfigActivity.java b/app/src/main/java/com/github/shadowsocks/plugin/gost/ConfigActivity.java
new file mode 100644
index 0000000..ff3f66f
--- /dev/null
+++ b/app/src/main/java/com/github/shadowsocks/plugin/gost/ConfigActivity.java
@@ -0,0 +1,687 @@
+package com.github.shadowsocks.plugin.gost;
+
+import android.annotation.SuppressLint;
+import android.app.AlertDialog;
+import android.content.Context;
+import android.content.ContextWrapper;
+import android.content.DialogInterface;
+import android.content.res.ColorStateList;
+import android.graphics.Color;
+import android.os.Bundle;
+import android.os.Handler;
+import android.text.Editable;
+import android.util.Log;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.ArrayAdapter;
+import android.widget.Button;
+import android.widget.EditText;
+import android.widget.LinearLayout;
+import android.widget.Spinner;
+import android.widget.TextView;
+import android.widget.Toast;
+
+import androidx.annotation.NonNull;
+
+import com.github.shadowsocks.plugin.ConfigurationActivity;
+import com.github.shadowsocks.plugin.PluginOptions;
+
+import org.json.JSONArray;
+import org.json.JSONException;
+import org.json.JSONObject;
+
+import java.io.File;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.Iterator;
+import java.util.Map;
+import java.util.Set;
+import java.util.regex.Matcher;
+
+public class ConfigActivity extends ConfigurationActivity {
+ private LinearLayout linearlayout_cmdargs;
+ private LinearLayout linearlayout_files;
+ private Spinner argumentCountSpinner;
+ private Editable newFileNameEditable;
+
+ private Toast toast;
+
+ private PluginOptions pluginOptions;
+ private JSONObject decodedPluginOptions;
+
+ @Override
+ public void onSaveInstanceState(Bundle savedInstanceState) {
+ try {
+ savedInstanceState.putString("pluginOptions", this.pluginOptions.toString());
+ this.saveUI();
+ savedInstanceState.putString("decodedPluginOptions", this.decodedPluginOptions.toString());
+ } catch (JSONException e) {
+ e.printStackTrace();
+ }
+ savedInstanceState.putBoolean("onceAskedForConfigMigration", this.onceAskedForConfigMigration);
+ savedInstanceState.putBoolean("onceAnsweredConfigMigrationPrompt", this.onceAnsweredConfigMigrationPrompt);
+ super.onSaveInstanceState(savedInstanceState);
+ }
+ @Override
+ public void onRestoreInstanceState(@NonNull Bundle savedInstanceState) {
+ super.onRestoreInstanceState(savedInstanceState);
+ EditText editText_new_file_name = findViewById(R.id.editText_new_file_name);
+ this.newFileNameEditable = editText_new_file_name.getText();
+ this.onceAskedForConfigMigration = savedInstanceState.getBoolean("onceAskedForConfigMigration");
+ this.onceAnsweredConfigMigrationPrompt = savedInstanceState.getBoolean("onceAnsweredConfigMigrationPrompt");
+ String pluginOptions = savedInstanceState.getString("pluginOptions");
+ if (pluginOptions != null) {
+ this.pluginOptions = new PluginOptions(pluginOptions);
+ }
+ String json = savedInstanceState.getString("decodedPluginOptions");
+ if (json != null) {
+ try {
+ this.decodedPluginOptions = new JSONObject(json);
+ populateUI();
+ } catch (JSONException e) {
+ e.printStackTrace();
+ }
+ }
+ if (this.onceAskedForConfigMigration && !this.onceAnsweredConfigMigrationPrompt) {
+ // dialog will disappear after rotation, so pop it up again
+ promptConfigMigration();
+ }
+ }
+
+ private void showToast(int resID) {
+ toast.cancel();
+ toast.setText(resID);
+ // toast.show(); // unexpectedly not shown. workaround below
+ handler.post(new Runnable() {
+ @Override
+ public void run() {
+ toast.show();
+ }
+ });
+ }
+ @Override
+ protected void onInitializePluginOptions(@NonNull PluginOptions pluginOptions) {
+ this.pluginOptions = pluginOptions;
+
+ String encodedPluginOptions = pluginOptions.get("CFGBLOB");
+ if (encodedPluginOptions == null || encodedPluginOptions.length() == 0) {
+ // no CFGBLOB
+ this.decodedPluginOptions = new JSONObject();
+
+ // populate things to UI
+ // and then they will be saved by saveUI() in onSaveInstanceState()
+
+ // initial -L command argument
+ String arg1 = getString(R.string.example_cmdarg1);
+ String arg2 = getString(R.string.example_cmdarg2);
+ addCmdArg(arg1, arg2, false, false);
+
+ // initial 4 empty file entries
+ for (int i = 0; i < fileNameList.length; i++) {
+ String fileName = fileNameList[i];
+ String fileData = "";
+ String fileHint = getString(fileHintList[i]);
+ addFileEntry(fileName, fileData, fileHint, false);
+ }
+
+ if (pluginOptions.toString().length() == 0) {
+ // nothing here, just empty
+ showToast(R.string.empty_config);
+ } else {
+ // found old style cmdline options, prompt to migrate to CFGBLOB
+ promptConfigMigration();
+ }
+ } else {
+ // has CFGBLOB, ignoring other keys
+ Base64 base64 = new Base64();
+ try {
+ base64.setPaddingChar('_');
+ String json = new String(base64.decode(encodedPluginOptions), StandardCharsets.UTF_8);
+ this.decodedPluginOptions = new JSONObject(json);
+
+ populateUI();
+
+ showToast(R.string.loaded_cfgblob);
+ } catch (Exception e) {
+ e.printStackTrace();
+ this.decodedPluginOptions = new JSONObject();
+ showToast(R.string.err_loading_cfgblob);
+ fallbackToManualEditor();
+ }
+ }
+ }
+ private AlertDialog configMigrationDialog;
+ private boolean onceAskedForConfigMigration = false;
+ private boolean onceAnsweredConfigMigrationPrompt = false;
+ private void promptConfigMigration() {
+ onceAskedForConfigMigration = true;
+ if (configMigrationDialog == null) {
+ AlertDialog.Builder builder = new AlertDialog.Builder(this);
+ builder.setTitle(R.string.prompt_config_mig_title);
+ builder.setMessage(R.string.prompt_config_mig_msg);
+ builder.setPositiveButton(R.string.ok, new DialogInterface.OnClickListener() {
+ @Override
+ public void onClick(DialogInterface dialog, int which) {
+ onceAnsweredConfigMigrationPrompt = true;
+ // do migration
+ try {
+ // populate things to UI
+ // and then they will be saved by saveUI() in onSaveInstanceState()
+
+ // populate original plugin options string to UI
+ final String legacyCfg = pluginOptions.toString();
+ populateLegacyCfg(legacyCfg);
+
+ // populate command line arguments to UI
+ ArrayList substrings = new ArrayList<>();
+ for (String s : legacyCfg.split(" ")) {
+ if (s.length() == 0)
+ continue;
+ substrings.add(s);
+ }
+ for (int i = 0; i < substrings.size(); i++) {
+ // "-L" should already be added
+ boolean allowDelete = cmdArgIdx.size() > 0;
+ String s = substrings.get(i);
+ String next = null;
+ if (i + 1 < substrings.size())
+ next = substrings.get(i + 1);
+ if (
+ s.matches("^-[A-Za-z0-1]$")
+ && next != null
+ && !next.matches("^-[A-Za-z0-1]$")
+ )
+ {
+ addCmdArg(s, next, allowDelete, false);
+ i++;
+ } else {
+ addCmdArg("", s, allowDelete, true);
+ }
+ }
+
+ toast.setText(R.string.config_mig_done);
+ } catch (Exception e) {
+ e.printStackTrace();
+ toast.setText(R.string.config_mig_err);
+ fallbackToManualEditor();
+ }
+ }
+ });
+ builder.setNegativeButton(R.string.cancel, new DialogInterface.OnClickListener() {
+ @Override
+ public void onClick(DialogInterface dialog, int which) {
+ onceAnsweredConfigMigrationPrompt = true;
+ toast.setText(R.string.cancelled);
+ fallbackToManualEditor();
+ }
+ });
+ builder.setOnDismissListener(new DialogInterface.OnDismissListener() {
+ @Override
+ public void onDismiss(DialogInterface dialog) {
+ if (!onceAnsweredConfigMigrationPrompt) {
+ toast.cancel();
+ configMigrationDialog.show(); // didn't click cancel button, so ask again
+ } else {
+ toast.show();
+ }
+ }
+ });
+ configMigrationDialog = builder.create();
+ }
+ toast.cancel();
+ configMigrationDialog.show();
+ }
+
+
+ private HashMap cmdArgMap;
+ private ArrayList cmdArgIdx;
+ private long cmdArgCtr = 0;
+
+ private HashMap fileDataMap;
+
+ private void regenerateIDs(View v) {
+ // regenerate new resIDs recursively for every children
+ // otherwise later newly generated objects are "tied" to older one
+ // like, after rotation change
+ if (v instanceof ViewGroup) {
+ ViewGroup vg = (ViewGroup) v;
+ for (int i = 0; i < vg.getChildCount(); i++) {
+ regenerateIDs(vg.getChildAt(i));
+ }
+ }
+ v.setId(View.generateViewId());
+ }
+
+ private void confirmDelCmdArg(long index, final View child) {
+ final long currentIndex = index;
+ final LinearLayout parent = this.linearlayout_cmdargs;
+ AlertDialog.Builder builder = new AlertDialog.Builder(this);
+ builder.setTitle(R.string.confirm_del_arg_title);
+ StringBuilder msg = new StringBuilder(getString(R.string.confirm_del_arg_msg) + "\n");
+ Editable[] array = cmdArgMap.get(currentIndex);
+ if (array != null) {
+ for (Editable e : array) {
+ msg.append("\"").append(e.toString()).append("\" ");
+ }
+ msg.deleteCharAt(msg.length() - 1);
+ } else Log.d("ConfigActivity", "confirmDelCmdArg cmdArgMap.get(currentIndex) == null");
+ builder.setMessage(msg.toString());
+ builder.setPositiveButton(R.string.ok, new DialogInterface.OnClickListener() {
+ @Override
+ public void onClick(DialogInterface dialog, int which) {
+ cmdArgMap.remove(currentIndex);
+ cmdArgIdx.remove(currentIndex);
+ parent.removeView(child);
+ }
+ });
+ builder.setNegativeButton(R.string.cancel, new DialogInterface.OnClickListener() {
+ @Override
+ public void onClick(DialogInterface dialog, int which) {
+ }
+ });
+ builder.create().show();
+ }
+ private void addCmdArg(String arg1, String arg2, boolean allowDelete, boolean hideFirstArg) {
+ final ViewGroup parent = this.linearlayout_cmdargs;
+ LayoutInflater inflater = (LayoutInflater) getSystemService(Context.LAYOUT_INFLATER_SERVICE);
+ @SuppressLint("InflateParams") final View child = inflater.inflate(R.layout.cmdarg, null);
+
+ EditText cmdarg1 = child.findViewById(R.id.editText_cmdarg1);
+ EditText cmdarg2 = child.findViewById(R.id.editText_cmdarg2);
+ Button button_del = child.findViewById(R.id.button_del);
+
+ regenerateIDs(child);
+
+ button_del.setEnabled(allowDelete);
+ if (!allowDelete) {
+ button_del.setBackgroundTintList(ColorStateList.valueOf(Color.parseColor("#C0C0C0")));
+ }
+ if (hideFirstArg) {
+ cmdarg1.setVisibility(View.GONE);
+ cmdarg2.setHint("");
+ } else {
+ cmdarg1.setText(arg1);
+ }
+ cmdarg2.setText(arg2);
+
+ Editable[] twoArgs = {cmdarg1.getText(), cmdarg2.getText()};
+ Editable[] oneArg = {twoArgs[1]};
+ final Editable[] array = hideFirstArg ? oneArg : twoArgs;
+ final long currentIndex = ++cmdArgCtr;
+ cmdArgMap.put(currentIndex, array);
+ cmdArgIdx.add(currentIndex);
+
+ button_del.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ confirmDelCmdArg(currentIndex, child);
+ }
+ });
+
+ parent.addView(child);
+ }
+
+ private void confirmDelFile(final String fileName, final View child) {
+ final LinearLayout parent = this.linearlayout_files;
+ AlertDialog.Builder builder = new AlertDialog.Builder(this);
+ builder.setTitle(R.string.confirm_del_file_title);
+ builder.setMessage(getString(R.string.confirm_del_file_msg) + fileName);
+ builder.setPositiveButton(R.string.ok, new DialogInterface.OnClickListener() {
+ @Override
+ public void onClick(DialogInterface dialog, int which) {
+ fileDataMap.remove(fileName);
+ parent.removeView(child);
+ }
+ });
+ builder.setNegativeButton(R.string.cancel, new DialogInterface.OnClickListener() {
+ @Override
+ public void onClick(DialogInterface dialog, int which) {
+ }
+ });
+ builder.create().show();
+ }
+ private void addFileEntry(final String fileName, final String fileData, String hint, boolean isDeletable) {
+ final ViewGroup parent = this.linearlayout_files;
+ LayoutInflater inflater = (LayoutInflater) getSystemService(Context.LAYOUT_INFLATER_SERVICE);
+ @SuppressLint("InflateParams") final View child = inflater.inflate(R.layout.fileentry, null);
+
+ TextView fileNameLabel = child.findViewById(R.id.text_file_name);
+ Button button_del_file = child.findViewById(R.id.button_del_file);
+ EditText fileDataEditText = child.findViewById(R.id.editText_file_data);
+
+ regenerateIDs(child);
+
+ fileNameLabel.setText(fileName);
+ if (!isDeletable) {
+ button_del_file.setEnabled(false);
+ button_del_file.setVisibility(View.GONE);
+ }
+ fileDataEditText.setHint(hint);
+ fileDataEditText.setText(fileData);
+
+ fileDataMap.put(fileName, fileDataEditText.getText());
+
+ button_del_file.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ confirmDelFile(fileName, child);
+ }
+ });
+
+ parent.addView(child);
+ }
+
+ private final String[] fileNameList = {
+ "config.json",
+ "cacert.pem",
+ "clientcert.pem",
+ "clientcertkey.pem",
+ };
+ private final int[] fileHintList = {
+ R.string.example_cfgjson,
+ R.string.example_cacert,
+ R.string.example_clientcert,
+ R.string.example_clientcertkey,
+ };
+
+ private void saveUI() throws NullPointerException, JSONException {
+ if (this.decodedPluginOptions == null)
+ this.decodedPluginOptions = new JSONObject();
+
+ // save linearlayout_cmdargs
+ JSONArray allArgs = new JSONArray();
+ for (Long index : cmdArgIdx) {
+ Editable[] oneOrTwoArgsEditable = cmdArgMap.get(index);
+ if (oneOrTwoArgsEditable == null) {
+ Log.e("ConfigActivity", "saveUI encountered oneOrTwoArgs == null");
+ throw new NullPointerException();
+ }
+ JSONArray oneOrTwoArgs = new JSONArray();
+ for (Editable oneOfArgs : oneOrTwoArgsEditable) {
+ String arg = oneOfArgs.toString();
+ if (arg.startsWith("\"") && arg.endsWith("\""))
+ arg = arg.substring(1, arg.length() - 1);
+ // oneOrTwoArgs.put("\"" + arg + "\""); // adding quotes leads to crash
+ oneOrTwoArgs.put(arg);
+ }
+ allArgs.put(oneOrTwoArgs);
+ }
+ // Note: Golang requires exported identifiers to begin with upper-case alphabet
+ // The same to below
+ this.decodedPluginOptions.put("CmdArgs", allArgs);
+
+ // save files
+ JSONObject files = new JSONObject();
+ for (Map.Entry entry : fileDataMap.entrySet()) {
+ files.put(entry.getKey(), entry.getValue().toString());
+ }
+ this.decodedPluginOptions.put("Files", files);
+
+ // save legacyCfg, if there's one
+ String legacyCfg = "";
+ EditText editText_legacyCfg = findViewById(R.id.editText_legacyCfg);
+ Editable editable_legacyCfg = editText_legacyCfg.getText();
+ if (editable_legacyCfg != null) {
+ legacyCfg = editable_legacyCfg.toString();
+ }
+ if (legacyCfg.length() > 0) {
+ this.decodedPluginOptions.put("LegacyCfg", legacyCfg);
+ } else {
+ this.decodedPluginOptions.remove("LegacyCfg");
+ }
+
+ // (not UI, but also saved here) save app data directory path
+ File dataDir = new ContextWrapper(getApplicationContext()).getFilesDir();
+ if (!dataDir.exists() && !dataDir.mkdirs()) {
+ Log.e("ConfigActivity", "dataDir.mkdirs() failed");
+ }
+ this.decodedPluginOptions.put("DataDir", dataDir.getAbsolutePath());
+ }
+ private void populateUI() throws JSONException {
+ // populate linearlayout_cmdargs
+ JSONArray array = this.decodedPluginOptions.getJSONArray("CmdArgs");
+ for (int i = 0; i < array.length(); i++) {
+ JSONArray oneOrTwoArgs = array.getJSONArray(i);
+ // remove quotes at the beginning and the end
+ String[] arg = new String[oneOrTwoArgs.length()];
+ for (int j = 0; j < oneOrTwoArgs.length(); j++) {
+ String s = oneOrTwoArgs.getString(j);
+ if (s.matches("^\".*\"$"))
+ s = s.substring(1, s.length() - 1);
+ arg[j] = s;
+ }
+ // the first argument should be "-L", generally considered necessary
+ boolean allowDelete = this.cmdArgIdx.size() > 0;
+ // sometimes it's more convenient to use only one edit box instead of two
+ if (oneOrTwoArgs.length() == 1) {
+ this.addCmdArg("", arg[0], allowDelete, true);
+ } else {
+ this.addCmdArg(arg[0], arg[1], allowDelete, false);
+ }
+ }
+
+ // populate linearlayout_files
+ // read from decodedPluginOptions, if fails, use empty jsonObject
+ JSONObject jsonObject = new JSONObject();
+ try {
+ jsonObject = this.decodedPluginOptions.getJSONObject("Files");
+ } catch (JSONException ignored) {}
+ // ensure that every file name in fileNameList exist in jsonObject
+ for (String fileName : fileNameList) {
+ if (jsonObject.has(fileName)) {
+ continue;
+ } else try {
+ jsonObject.getString(fileName);
+ continue;
+ } catch (JSONException ignored) {}
+ jsonObject.put(fileName, "");
+ }
+ // add files in fileNameList first
+ Set fixedFiles = new HashSet<>();
+ for (int i = 0; i < fileNameList.length; i++) {
+ String fileName = fileNameList[i];
+ String fileData = jsonObject.getString(fileName);
+ String fileHint = getString(fileHintList[i]);
+ fixedFiles.add(fileName);
+ addFileEntry(fileName, fileData, fileHint, false);
+ }
+ // add remaining files, if any
+ for (Iterator it = jsonObject.keys(); it.hasNext(); ) {
+ String fileName = it.next();
+ if (fixedFiles.contains(fileName))
+ continue;
+ addFileEntry(fileName, jsonObject.getString(fileName), "", true);
+ }
+
+ // populate legacyCfg, if there's one
+ String legacyCfg = "";
+ try {
+ legacyCfg = this.decodedPluginOptions.getString("LegacyCfg");
+ } catch (JSONException ignored) {}
+ this.populateLegacyCfg(legacyCfg);
+ }
+ private void populateLegacyCfg(String legacyCfg) {
+ boolean hasLegacyCfg = legacyCfg != null && legacyCfg.length() > 0;
+ Button button_revert_to_legacy_config = findViewById(R.id.button_revert_to_legacy_config);
+ button_revert_to_legacy_config.setClickable(hasLegacyCfg);
+ button_revert_to_legacy_config.setEnabled(hasLegacyCfg);
+ EditText editText_legacyCfg = findViewById(R.id.editText_legacyCfg);
+ editText_legacyCfg.setEnabled(hasLegacyCfg);
+ editText_legacyCfg.setText(hasLegacyCfg ? legacyCfg : "");
+ LinearLayout linearlayout_legacyCfg = findViewById(R.id.linearlayout_legacyCfg);
+ linearlayout_legacyCfg.setVisibility(hasLegacyCfg ? View.VISIBLE : View.GONE);
+ }
+
+ private Handler handler;
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ setContentView(R.layout.config_activity);
+
+ toast = Toast.makeText(this, "", Toast.LENGTH_SHORT);
+ handler = new Handler(); // workaround toast unexpectedly not showing problem
+
+ cmdArgMap = new HashMap<>();
+ cmdArgIdx = new ArrayList<>();
+
+ fileDataMap = new HashMap<>();
+
+ argumentCountSpinner = findViewById(R.id.spinner_add_one_or_two_args);
+ ArrayAdapter adapter = ArrayAdapter.createFromResource(this,
+ R.array.string_add_one_or_two_args, android.R.layout.simple_spinner_item);
+ adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item);
+ argumentCountSpinner.setAdapter(adapter);
+ argumentCountSpinner.setSelection(1, false);
+
+ linearlayout_cmdargs = findViewById(R.id.linearlayout_cmdargs);
+ Button button_add = findViewById(R.id.button_add);
+
+ linearlayout_files = findViewById(R.id.linearlayout_files);
+ EditText editText_new_file_name = findViewById(R.id.editText_new_file_name);
+ Button button_add_file = findViewById(R.id.button_add_file);
+
+ button_add.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ boolean hideFirstArg = argumentCountSpinner.getSelectedItemPosition() == 0;
+ String arg2 = hideFirstArg ? "" : getString(R.string.example_cmdarg4);
+ addCmdArg(getString(R.string.example_cmdarg3), arg2, true, hideFirstArg);
+ }
+ });
+
+ this.newFileNameEditable = editText_new_file_name.getText();
+ button_add_file.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ String fileName = newFileNameEditable.toString();
+ if (fileName.length() == 0) {
+ showToast(R.string.err_file_name_empty);
+ return;
+ }
+ if (fileName.contains("/")) {
+ showToast(R.string.err_file_name_contains_slash);
+ return;
+ }
+ if (fileDataMap.containsKey(fileName)) {
+ showToast(R.string.err_file_already_exists);
+ return;
+ }
+ addFileEntry(fileName, "", "", true);
+ }
+ });
+
+ Button button_revert_to_legacy_config = findViewById(R.id.button_revert_to_legacy_config);
+ button_revert_to_legacy_config.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ String title = getString(R.string.confirm_revert_to_legacy_config_title);
+ String msg = getString(R.string.confirm_revert_to_legacy_config_msg);
+ String positiveButton = getString(R.string.ok);
+ String negativeButton = getString(R.string.cancel);
+ RunnableEx positive = new RunnableEx() {
+ @Override
+ public void run() {
+ EditText editText_legacyCfg = findViewById(R.id.editText_legacyCfg);
+ String legacyCfg = editText_legacyCfg.getText().toString();
+ saveChanges(new PluginOptions(legacyCfg));
+ finish();
+ }
+ };
+ Runnable negative = new Runnable() {
+ @Override
+ public void run() {
+ }
+ };
+ String toastMsgOnSuccess = getString(R.string.reverted_to_legacy_config);
+ String toastMsgOnFail = getString(R.string.error_reverting_to_legacy_config);
+ String toastMsgOnCancel = getString(R.string.cancelled);
+ askForConsent(title, msg, positiveButton, negativeButton, positive, negative, toastMsgOnSuccess, toastMsgOnFail, toastMsgOnCancel);
+ }
+ });
+ }
+
+ @Override
+ public void onBackPressed() {
+ // ask for save & apply
+ String title = getString(R.string.confirm_save_apply_title);
+ String msg = getString(R.string.confirm_save_apply_msg);
+ String positiveButton = getString(R.string.ok);
+ String negativeButton = getString(R.string.discard_changes);
+ RunnableEx positive = new RunnableEx() {
+ @Override
+ public void run() throws JSONException, Base64.Base64Exception {
+ saveUI();
+
+ String json = decodedPluginOptions.toString();
+ Base64 base64 = new Base64();
+ base64.setPaddingChar('_');
+ String encodedPluginOptions = base64.encode(json.getBytes(StandardCharsets.UTF_8));
+ pluginOptions.clear(); // discard keys other than CFGBLOB
+ pluginOptions.put("CFGBLOB", encodedPluginOptions);
+ saveChanges(pluginOptions);
+ finish();
+ }
+ };
+ Runnable negative = new Runnable() {
+ @Override
+ public void run() {
+ finish();
+ }
+ };
+ String toastMsgOnSuccess = getString(R.string.saved_cfgblob);
+ String toastMsgOnFail = getString(R.string.error_saving_cfgblob);
+ String toastMsgOnCancel = getString(R.string.cancelled);
+ askForConsent(title, msg, positiveButton, negativeButton, positive, negative, toastMsgOnSuccess, toastMsgOnFail, toastMsgOnCancel);
+ }
+
+ private boolean dismissedConsent = false;
+ private void askForConsent(
+ String title, String msg,
+ String positiveButton, String negativeButton,
+ final RunnableEx positive, final Runnable negative,
+ final String toastMsgOnSuccess, final String toastMsgOnFail, final String toastMsgOnCancel)
+ {
+ AlertDialog.Builder builder = new AlertDialog.Builder(this);
+ builder.setTitle(title);
+ builder.setMessage(msg);
+ builder.setPositiveButton(positiveButton, new DialogInterface.OnClickListener() {
+ @Override
+ public void onClick(DialogInterface dialog, int which) {
+ dismissedConsent = false;
+ try {
+ positive.run();
+ } catch (Exception e) {
+ e.printStackTrace();
+ toast.setText(toastMsgOnFail);
+ return;
+ }
+ toast.setText(toastMsgOnSuccess);
+ }
+ });
+ builder.setNegativeButton(negativeButton, new DialogInterface.OnClickListener() {
+ @Override
+ public void onClick(DialogInterface dialog, int which) {
+ dismissedConsent = false;
+ negative.run();
+ toast.setText(toastMsgOnCancel);
+ }
+ });
+ builder.setOnDismissListener(new DialogInterface.OnDismissListener() {
+ @Override
+ public void onDismiss(DialogInterface dialog) {
+ if (dismissedConsent)
+ toast.setText(toastMsgOnCancel);
+ toast.show();
+ }
+ });
+ AlertDialog consentDialog = builder.create();
+
+ toast.cancel();
+ dismissedConsent = true;
+ consentDialog.show();
+ }
+
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/github/shadowsocks/plugin/gost/RunnableEx.java b/app/src/main/java/com/github/shadowsocks/plugin/gost/RunnableEx.java
new file mode 100644
index 0000000..f6fc5f4
--- /dev/null
+++ b/app/src/main/java/com/github/shadowsocks/plugin/gost/RunnableEx.java
@@ -0,0 +1,5 @@
+package com.github.shadowsocks.plugin.gost;
+
+public interface RunnableEx {
+ void run() throws Exception;
+}
diff --git a/app/src/main/res/layout/cmdarg.xml b/app/src/main/res/layout/cmdarg.xml
new file mode 100644
index 0000000..a981d1f
--- /dev/null
+++ b/app/src/main/res/layout/cmdarg.xml
@@ -0,0 +1,42 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/config_activity.xml b/app/src/main/res/layout/config_activity.xml
new file mode 100644
index 0000000..978b992
--- /dev/null
+++ b/app/src/main/res/layout/config_activity.xml
@@ -0,0 +1,214 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/fileentry.xml b/app/src/main/res/layout/fileentry.xml
new file mode 100644
index 0000000..6309bdf
--- /dev/null
+++ b/app/src/main/res/layout/fileentry.xml
@@ -0,0 +1,38 @@
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml
new file mode 100644
index 0000000..9a61e86
--- /dev/null
+++ b/app/src/main/res/values-zh/strings.xml
@@ -0,0 +1,51 @@
+
+
+ ShadowsocksGostPlugin
+ SS-Android Gost插件
+ 按返回键再点击“确定”按钮即可保存并应用配置参数。\n需要在Shadowsocks-Android里重新开启一下,配置才会生效。
+ 删除单个命令行参数
+ 你确定要删除这个命令行参数吗?
+ 命令行参数
+
+ - 添加 1 个参数
+ - 添加 2 个参数
+
+ 附加文件
+ 请不要在命令行参数中加入双引号(\"),加了会让Gost无法启动。\n\"#SS_HOST\"与\"#SS_PORT\"将会被分别替换为Shadowsocks-Android中设置的\"服务器\"与\"远程端口\"。
+ Gost会在本地开一个Shadowsocks服务器,Shadowsocks-Android直接连的就是它。为了避免不必要的开销,一般而言,Shadowsocks-Android本身和Gost的\"-L\"参数都被设为\"none\"无加密模式(如果\"none\"不可用,则换用其他加密方法)——无论如何,两者必须相符才能正常工作。然后Gost会按照\"-F\"参数指定的加密方法来跟远程服务器建立真正的加密连接。
+ 必须添加对应的命令行参数(例如: -C config.json)才能让下面的文件内容生效!
+ 删除
+ 删除单个文件
+ 你确定要删除这个文件吗?\n文件名:
+ /* 示例配置,不会被保存生效 */\n{\n\t\t\"Debug\": true,\n\t\t\"Retries\": 0,\n\t\t\"ServeNodes\": [\n\t\t\t\t\":8080\",\n\t\t\t\t\"ss://chacha20:12345678@:8338\"\n\t\t],\n\t\t\"ChainNodes\": [\n\t\t\t\t\"http://192.168.1.1:8080\",\n\t\t\t\t\"https://10.0.2.1:443\"\n\t\t],\n\t\t\"Routes\": [\n\t\t\t\t{\n\t\t\t\t\t\t\"Retries\": 1,\n\t\t\t\t\t\t\"ServeNodes\": [\n\t\t\t\t\t\t\t\t\"ws://:1443\"\n\t\t\t\t\t\t],\n\t\t\t\t\t\t\"ChainNodes\": [\n\t\t\t\t\t\t\t\t\"socks://:192.168.1.1:1080\"\n\t\t\t\t\t\t]\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\t\t\"Retries\": 3,\n\t\t\t\t\t\t\"ServeNodes\": [\n\t\t\t\t\t\t\t\t\"quic://:443\"\n\t\t\t\t\t\t]\n\t\t\t\t}\n\t\t]\n}\n
+ -----BEGIN CERTIFICATE-----\n(你自己的CA证书,Base64 PEM格式)\n-----END CERTIFICATE-----
+ -----BEGIN CERTIFICATE-----\n(你自己的客户端证书,Base64 PEM格式)\n-----END CERTIFICATE-----
+ -----BEGIN RSA PRIVATE KEY-----\n(你自己的客户端证书私钥,Base64 PEM格式)\n-----END RSA PRIVATE KEY-----
+ 新文件名
+ 添加文件
+ 错误!文件名为空
+ 错误!文件名含有斜杠(/)
+ 错误!文件已存在
+ 配置为空
+ 迁移配置参数到新格式
+ 新格式支持更多功能,比如指定双向认证证书。\n你可以随时撤销迁移,但这样会丢失迁移后的所有改动。
+ 成功迁移配置参数到新格式
+ 迁移配置参数到新格式时出错!
+ 已加载配置参数
+ 加载配置参数时出错!
+ 已保存配置参数
+ 保存配置参数时出错
+ 确定
+ 丢弃改动
+ 取消
+ 要保存并应用配置参数吗?
+ 再按一次返回键即可继续编辑。
+ 已取消
+ 不再生效的旧版配置
+ 下列配置信息已经不再生效了。把它放在这里只是出于备份目的。\n你可以点击\"回退到旧版配置\"来让它重新生效,但这么做会永久丢失自旧版迁移以来至今的所有改动。\n不过,你可以在Shadowsocks-Android里导出(复制)一下代理设置条目,这样就能把当前的新配置备份下来了。
+ 回退到旧版配置
+ 已回退到旧版配置
+ 回退到旧版配置时出错
+ 回退到旧版配置?
+ 这么做会永久丢失自旧版迁移以来至今的所有改动。
+
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 7d13578..7527394 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -1,3 +1,56 @@
ShadowsocksGostPlugin
+ Gost for SS-Android
+ Press back button and click \"OK\" to save and apply changes.\nRe-enable my proxy profile in SS-Android to make changes take effect.
+ -
+ Delete an argument
+ Are you sure to delete this argument?
+ Command line arguments
+ Please don\'t include quotes (\") in the command line arguments, they will make Gost fail to start.\n\"#SS_HOST\" and \"#SS_PORT\" will be replaced with \"Server\" and \"Remote port\" set in Shadowsocks-Android respectively.
+ Gost sets up a local Shadowsocks server, which Shadowsocks-Android directly connects to. To avoid unnecessary overhead, generally, both Shadowsocks-Android itself and Gost\'s \"-L\" parameter are set to \"none\" encryption (or other encryption method if \"none\" is not available) - anyway, it won\'t work if mismatch. Then, Gost establishes actual encrypted connection to the remote server, the encryption method depends on \"-F\" parameter(s).
+ -L
+
+ -F
+
+ +
+
+ - Add 1 argument
+ - Add 2 arguments
+
+ Attached files
+ You need to add corresponding command line arguments (like: -C config.json) to make the file content below actually take effect!
+ Delete
+ Delete a file
+ Are you sure to delete this file?\nFile name:
+ /* sample config, won\'t be saved & applied */\n{\n\t\t\"Debug\": true,\n\t\t\"Retries\": 0,\n\t\t\"ServeNodes\": [\n\t\t\t\t\":8080\",\n\t\t\t\t\"ss://chacha20:12345678@:8338\"\n\t\t],\n\t\t\"ChainNodes\": [\n\t\t\t\t\"http://192.168.1.1:8080\",\n\t\t\t\t\"https://10.0.2.1:443\"\n\t\t],\n\t\t\"Routes\": [\n\t\t\t\t{\n\t\t\t\t\t\t\"Retries\": 1,\n\t\t\t\t\t\t\"ServeNodes\": [\n\t\t\t\t\t\t\t\t\"ws://:1443\"\n\t\t\t\t\t\t],\n\t\t\t\t\t\t\"ChainNodes\": [\n\t\t\t\t\t\t\t\t\"socks://:192.168.1.1:1080\"\n\t\t\t\t\t\t]\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\t\t\"Retries\": 3,\n\t\t\t\t\t\t\"ServeNodes\": [\n\t\t\t\t\t\t\t\t\"quic://:443\"\n\t\t\t\t\t\t]\n\t\t\t\t}\n\t\t]\n}
+ -----BEGIN CERTIFICATE-----\n(Your own CA certificate in Base64 PEM format)\n-----END CERTIFICATE-----
+ -----BEGIN CERTIFICATE-----\n(Your own client certificate in Base64 PEM format)\n-----END CERTIFICATE-----
+ -----BEGIN RSA PRIVATE KEY-----\n(Your own client certificate key in Base64 PEM format)\n-----END RSA PRIVATE KEY-----
+ new_file_name
+ Add file
+ Error! File name is empty
+ Error! File name contains slash (/)
+ Error! File already exists
+ Configuration is empty
+ Migrate to new config format
+ New config format enables more functions like setting mutual TLS authentication certificates.\nYou may undo this migration any time later, but doing so will irrecoverably discard any change made after the migration.
+ Successfully migrated to new config format
+ Error migrating to new config format!
+ Loaded configuration
+ Error loading configuration!
+ Saved configuration
+ Error saving configuration
+ OK
+ Discard Changes
+ Cancel
+ Save & apply configurations?
+ Press back button again to continue editing.
+ Cancelled
+ Defunct legacy configuration
+ The following configuration no longer takes effect. It\'s here only for backup purpose.\nYou may click \"Revert to legacy config\" to make it take effect yet again, but doing so will irrecoverably discard all change made after the migrating from legacy config.\nYou may export (duplicate) the proxy profile entry in Shadowsocks-Android to make a backup for the current new config, however.
+ Revert to legacy config
+ Reverted to legacy config
+ Error reverting to legacy config
+ Revert to legacy config?
+ Doing so will irrecoverably discard all change made after the migrating from legacy config.
diff --git a/build.sh b/build.sh
index 69fb24f..58f9ff4 100644
--- a/build.sh
+++ b/build.sh
@@ -1,7 +1,9 @@
set -e
-GOST_VERSION=2.11.0
-GOLANG_VERSION=1.13.8
+NDK_VERSION_IF_MISSING=r23b
+GOST_VERSION=2.11.1
+GOLANG_VERSION=1.17.7
cd $( cd "$( dirname "$0" )" && pwd )
+git submodule update --init --recursive
if [ ! -e build ]
then
mkdir build
@@ -9,8 +11,7 @@ fi
cd build
if [ ! -e go ]
then
-curl "https://dl.google.com/go/go$GOLANG_VERSION.linux-amd64.tar.gz" -L -o go.tar.gz
-tar -zxvf go.tar.gz
+curl "https://dl.google.com/go/go$GOLANG_VERSION.linux-amd64.tar.gz" -L | tar -zx || exit $?
cd go
patch -p1 -r . < ../../go.patch
cd ..
@@ -18,17 +19,29 @@ fi
export PATH=$PWD/go/bin:$PATH
export GOROOT=$PWD/go
go version
-if [ ! -e gost ]
+if [ ! -e gost ] && [ -d ../gost ]
then
-curl "https://github.com/ginuerzh/gost/archive/v$GOST_VERSION.tar.gz" -L -o gost.tar.gz
-tar -zxvf gost.tar.gz
-mv gost-$GOST_VERSION gost
-cd gost
-patch -p1 -r . < ../../gost.patch
+mv -v ../gost .
+fi
+IS_NDK_MISSING=true
+if find $ANDROID_NDK_ROOT | grep clang$
+then
+IS_NDK_MISSING=false
+fi
+echo "IS_NDK_MISSING=$IS_NDK_MISSING"
+if $IS_NDK_MISSING
+then
+mkdir -p ndk
+cd ndk
+curl https://dl.google.com/android/repository/android-ndk-${NDK_VERSION_IF_MISSING}-linux.zip -L -o ndk.zip
+unzip ndk.zip > /dev/null || exit $?
+rm -f ndk.zip
+[ ! -d android-ndk-${NDK_VERSION_IF_MISSING} ] && echo "Missing directory: android-ndk-${NDK_VERSION_IF_MISSING}" && exit 1
+export ANDROID_NDK_ROOT=$PWD/android-ndk-${NDK_VERSION_IF_MISSING}
cd ..
fi
+echo "ANDROID_NDK_ROOT=$ANDROID_NDK_ROOT"
cd gost
-echo $ANDROID_NDK_ROOT
CC=$(find $ANDROID_NDK_ROOT | grep 'armv7a-linux-androideabi21-clang$') \
GOOS="android" GOARCH="arm" CGO_ENABLED="1" \
go build -ldflags "-s -w" -a -o ../../app/src/main/jniLibs/armeabi-v7a/libgost-plugin.so ./cmd/gost
diff --git a/gost b/gost
new file mode 160000
index 0000000..50927e2
--- /dev/null
+++ b/gost
@@ -0,0 +1 @@
+Subproject commit 50927e28d4927230c6eb2ec934d1fe21e95a7629
diff --git a/gost.patch b/gost.patch
deleted file mode 100644
index 2aafa7c..0000000
--- a/gost.patch
+++ /dev/null
@@ -1,212 +0,0 @@
-diff -ruN gost.back/cmd/gost/main.go gost/cmd/gost/main.go
---- gost.back/cmd/gost/main.go 2020-03-14 15:01:10.651935500 +0800
-+++ gost/cmd/gost/main.go 2020-03-14 16:17:45.537385600 +0800
-@@ -8,10 +8,12 @@
- "net/http"
- "os"
- "runtime"
-+ "strings"
-
- _ "net/http/pprof"
-
- "github.com/ginuerzh/gost"
-+ "github.com/ginuerzh/gost/utils"
- "github.com/go-log/log"
- )
-
-@@ -27,13 +29,25 @@
-
- var (
- printVersion bool
-+ fastOpen bool
- )
-+ localHost := os.Getenv("SS_LOCAL_HOST")
-+ localPort := os.Getenv("SS_LOCAL_PORT")
-+ pluginOptions := os.Getenv("SS_PLUGIN_OPTIONS")
-+ pluginOptions = strings.ReplaceAll(pluginOptions, "#SS_HOST", os.Getenv("SS_REMOTE_HOST"))
-+ pluginOptions = strings.ReplaceAll(pluginOptions, "#SS_PORT", os.Getenv("SS_REMOTE_PORT"))
-+
-+ os.Args = append(os.Args, "-L")
-+ os.Args = append(os.Args, fmt.Sprintf("ss+tcp://rc4-md5:gost@[%s]:%s", localHost, localPort))
-+ os.Args = append(os.Args, strings.Split(pluginOptions, " ")...)
-
- flag.Var(&baseCfg.route.ChainNodes, "F", "forward address, can make a forward chain")
- flag.Var(&baseCfg.route.ServeNodes, "L", "listen address, can listen on multiple ports (required)")
- flag.StringVar(&configureFile, "C", "", "configure file")
- flag.BoolVar(&baseCfg.Debug, "D", false, "enable debug log")
-- flag.BoolVar(&printVersion, "V", false, "print version")
-+ flag.BoolVar(&utils.VpnMode, "V", false, "VPN Mode")
-+ flag.BoolVar(&fastOpen, "fast-open", false, "fast Open TCP")
-+ flag.BoolVar(&printVersion, "PV", false, "print version")
- if pprofEnabled {
- flag.StringVar(&pprofAddr, "P", ":6060", "profiling HTTP server address")
- }
-@@ -45,6 +59,12 @@
- os.Exit(0)
- }
-
-+ if localHost == "" || localPort == "" {
-+ fmt.Fprintln(os.Stderr, "Can only be used in the shadowsocks plugin.")
-+ os.Exit(1)
-+ }
-+ utils.Init()
-+
- if configureFile != "" {
- _, err := parseBaseConfig(configureFile)
- if err != nil {
-diff -ruN gost.back/utils/utils.go gost/utils/utils.go
---- gost.back/utils/utils.go 1970-01-01 08:00:00.000000000 +0800
-+++ gost/utils/utils.go 2020-03-14 16:16:52.766185200 +0800
-@@ -0,0 +1,7 @@
-+// +build !android
-+
-+package utils
-+
-+var VpnMode bool
-+
-+func Init() {}
-\ No newline at end of file
-diff -ruN gost.back/utils/utils_android.go gost/utils/utils_android.go
---- gost.back/utils/utils_android.go 1970-01-01 08:00:00.000000000 +0800
-+++ gost/utils/utils_android.go 2020-03-14 16:16:30.964564900 +0800
-@@ -0,0 +1,140 @@
-+// +build android
-+
-+package utils
-+
-+/*
-+#include
-+#include
-+#include
-+#include
-+#include
-+#include
-+#include
-+#include
-+
-+#define ANCIL_FD_BUFFER(n) \
-+ struct { \
-+ struct cmsghdr h; \
-+ int fd[n]; \
-+ }
-+
-+int
-+ancil_send_fds_with_buffer(int sock, const int *fds, unsigned n_fds, void *buffer)
-+{
-+ struct msghdr msghdr;
-+ char nothing = '!';
-+ struct iovec nothing_ptr;
-+ struct cmsghdr *cmsg;
-+ int i;
-+
-+ nothing_ptr.iov_base = ¬hing;
-+ nothing_ptr.iov_len = 1;
-+ msghdr.msg_name = NULL;
-+ msghdr.msg_namelen = 0;
-+ msghdr.msg_iov = ¬hing_ptr;
-+ msghdr.msg_iovlen = 1;
-+ msghdr.msg_flags = 0;
-+ msghdr.msg_control = buffer;
-+ msghdr.msg_controllen = sizeof(struct cmsghdr) + sizeof(int) * n_fds;
-+ cmsg = CMSG_FIRSTHDR(&msghdr);
-+ cmsg->cmsg_len = msghdr.msg_controllen;
-+ cmsg->cmsg_level = SOL_SOCKET;
-+ cmsg->cmsg_type = SCM_RIGHTS;
-+ for(i = 0; i < n_fds; i++)
-+ ((int *)CMSG_DATA(cmsg))[i] = fds[i];
-+ return(sendmsg(sock, &msghdr, 0) >= 0 ? 0 : -1);
-+}
-+
-+int
-+ancil_send_fd(int sock, int fd)
-+{
-+ ANCIL_FD_BUFFER(1) buffer;
-+
-+ return(ancil_send_fds_with_buffer(sock, &fd, 1, &buffer));
-+}
-+
-+void
-+set_timeout(int sock)
-+{
-+ struct timeval tv;
-+ tv.tv_sec = 3;
-+ tv.tv_usec = 0;
-+ setsockopt(sock, SOL_SOCKET, SO_RCVTIMEO, (char *)&tv, sizeof(struct timeval));
-+ setsockopt(sock, SOL_SOCKET, SO_SNDTIMEO, (char *)&tv, sizeof(struct timeval));
-+}
-+
-+*/
-+import "C"
-+
-+import (
-+ "context"
-+ "log"
-+ "net"
-+ "syscall"
-+)
-+
-+var VpnMode bool
-+
-+func ControlOnConnSetup(network string, address string, c syscall.RawConn) error {
-+ if VpnMode {
-+ fn := func(s uintptr) {
-+ fd := int(s)
-+ path := "protect_path"
-+
-+ socket, err := syscall.Socket(syscall.AF_UNIX, syscall.SOCK_STREAM, 0)
-+ if err != nil {
-+ log.Println(err)
-+ return
-+ }
-+
-+ defer syscall.Close(socket)
-+
-+ C.set_timeout(C.int(socket))
-+
-+ err = syscall.Connect(socket, &syscall.SockaddrUnix{Name: path})
-+ if err != nil {
-+ log.Println(err)
-+ return
-+ }
-+
-+ C.ancil_send_fd(C.int(socket), C.int(fd))
-+
-+ dummy := []byte{1}
-+ n, err := syscall.Read(socket, dummy)
-+ if err != nil {
-+ log.Println(err)
-+ return
-+ }
-+ if n != 1 {
-+ log.Println("Failed to protect fd: ", fd)
-+ return
-+ }
-+ }
-+
-+ if err := c.Control(fn); err != nil {
-+ return err
-+ }
-+ }
-+
-+ return nil
-+}
-+
-+func Init() {
-+ log.Printf("Android Utils Init. VpnMode: %v", VpnMode)
-+ net.DefaultResolver = &net.Resolver{Dial: func(ctx context.Context, network, address string) (net.Conn, error) {
-+ log.Printf("DefaultResolver address %v modify to %v", address, "119.29.29.29:53")
-+ d := net.Dialer{}
-+ return d.DialContext(ctx, network, "119.29.29.29:53")
-+ }, PreferGo: true}
-+ if VpnMode {
-+ log.Printf("VpnMode Hook Init.")
-+ net.ListenUDPListenConfigHook = func(c *net.ListenConfig) {
-+ log.Printf("DialContextDialerHook %v", c)
-+ c.Control = ControlOnConnSetup
-+ }
-+ net.DialContextDialerHook = func(d *net.Dialer) {
-+ log.Printf("DialContextDialerHook %v", d)
-+ d.Control = ControlOnConnSetup
-+ }
-+ }
-+}
diff --git a/gradle.properties b/gradle.properties
index 82618ce..d546dea 100644
--- a/gradle.properties
+++ b/gradle.properties
@@ -6,6 +6,8 @@
# 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.
+android.enableJetifier=true
+android.useAndroidX=true
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