From c9094be8f0e4b30120a539ff8799975744d25504 Mon Sep 17 00:00:00 2001 From: segfault-bilibili Date: Mon, 14 Feb 2022 21:12:40 +0800 Subject: [PATCH] merge gui changes --- app/build.gradle | 4 +- app/src/main/AndroidManifest.xml | 72 +- .../shadowsocks/plugin/gost/Base64.java | 106 +++ .../plugin/gost/BinaryProvider.java | 1 - .../plugin/gost/ConfigActivity.java | 677 ++++++++++++++++++ .../shadowsocks/plugin/gost/RunnableEx.java | 5 + app/src/main/res/layout/cmdarg.xml | 42 ++ app/src/main/res/layout/config_activity.xml | 214 ++++++ app/src/main/res/layout/fileentry.xml | 38 + app/src/main/res/values-zh/strings.xml | 51 ++ app/src/main/res/values/strings.xml | 53 ++ gradle.properties | 2 + 12 files changed, 1235 insertions(+), 30 deletions(-) create mode 100644 app/src/main/java/com/github/shadowsocks/plugin/gost/Base64.java create mode 100644 app/src/main/java/com/github/shadowsocks/plugin/gost/ConfigActivity.java create mode 100644 app/src/main/java/com/github/shadowsocks/plugin/gost/RunnableEx.java create mode 100644 app/src/main/res/layout/cmdarg.xml create mode 100644 app/src/main/res/layout/config_activity.xml create mode 100644 app/src/main/res/layout/fileentry.xml create mode 100644 app/src/main/res/values-zh/strings.xml diff --git a/app/build.gradle b/app/build.gradle index 30eceb7..aed0908 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -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..55de935 --- /dev/null +++ b/app/src/main/java/com/github/shadowsocks/plugin/gost/ConfigActivity.java @@ -0,0 +1,677 @@ +package com.github.shadowsocks.plugin.gost; + +import android.annotation.SuppressLint; +import android.app.AlertDialog; +import android.content.Context; +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.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", "promptSaveAndApply 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); + arg = arg.replaceAll(Matcher.quoteReplacement("\\\""), "\""); + arg = arg.replaceAll("\"", Matcher.quoteReplacement("\\\"")); + oneOrTwoArgs.put("\"" + arg + "\""); + } + allArgs.put(oneOrTwoArgs); + } + 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"); + } + } + 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 @@ + + + +