From ac2b668a4b3f646a46db06d717a348307a2d7985 Mon Sep 17 00:00:00 2001 From: Mygod Date: Fri, 6 Jan 2017 16:20:26 +0800 Subject: [PATCH] Implement custom rules Fix #1009 finally! --- .../drawable-v21/background_selectable.xml | 11 + .../res/drawable/background_selectable.xml | 8 + .../res/drawable/ic_action_assignment.xml | 9 + src/main/res/drawable/ic_av_playlist_add.xml | 9 + src/main/res/layout/dialog_acl_rule.xml | 14 + src/main/res/layout/layout_apps.xml | 1 + .../{layout_profiles.xml => layout_list.xml} | 3 +- src/main/res/layout/layout_tasker.xml | 5 +- src/main/res/menu/custom_rules_menu.xml | 54 ++++ src/main/res/values-ja/strings.xml | 4 +- src/main/res/values-ru/strings.xml | 4 +- src/main/res/values-zh-rCN/strings.xml | 14 +- src/main/res/values-zh-rTW/strings.xml | 14 +- src/main/res/values/arrays.xml | 1 + src/main/res/values/colors.xml | 2 + src/main/res/values/strings.xml | 14 +- .../com/github/shadowsocks/AppManager.scala | 2 +- .../com/github/shadowsocks/BaseService.scala | 62 ++-- .../com/github/shadowsocks/MainActivity.scala | 9 + .../github/shadowsocks/ProfilesFragment.scala | 19 +- .../com/github/shadowsocks/QRCodeDialog.scala | 13 +- .../shadowsocks/ShadowsocksApplication.scala | 2 +- .../shadowsocks/ShadowsocksNatService.scala | 12 +- .../shadowsocks/ShadowsocksVpnService.scala | 14 +- .../github/shadowsocks/TaskerActivity.scala | 6 +- .../com/github/shadowsocks/acl/Acl.scala | 143 ++++++++++ .../shadowsocks/{job => acl}/AclSyncJob.scala | 11 +- .../shadowsocks/acl/CustomRulesFragment.scala | 268 ++++++++++++++++++ .../{job => acl}/DonaldTrump.scala | 2 +- .../com/github/shadowsocks/acl/Subnet.scala | 51 ++++ .../github/shadowsocks/utils/Constants.scala | 9 - .../github/shadowsocks/utils/IOUtils.scala | 10 - .../widget/UndoSnackbarManager.scala | 4 +- .../scala/collection/mutable/SortedList.scala | 152 ++++++++++ 34 files changed, 862 insertions(+), 94 deletions(-) create mode 100644 src/main/res/drawable-v21/background_selectable.xml create mode 100644 src/main/res/drawable/background_selectable.xml create mode 100644 src/main/res/drawable/ic_action_assignment.xml create mode 100644 src/main/res/drawable/ic_av_playlist_add.xml create mode 100644 src/main/res/layout/dialog_acl_rule.xml rename src/main/res/layout/{layout_profiles.xml => layout_list.xml} (86%) create mode 100644 src/main/res/menu/custom_rules_menu.xml create mode 100644 src/main/scala/com/github/shadowsocks/acl/Acl.scala rename src/main/scala/com/github/shadowsocks/{job => acl}/AclSyncJob.scala (91%) create mode 100644 src/main/scala/com/github/shadowsocks/acl/CustomRulesFragment.scala rename src/main/scala/com/github/shadowsocks/{job => acl}/DonaldTrump.scala (98%) create mode 100644 src/main/scala/com/github/shadowsocks/acl/Subnet.scala create mode 100644 src/main/scala/scala/collection/mutable/SortedList.scala diff --git a/src/main/res/drawable-v21/background_selectable.xml b/src/main/res/drawable-v21/background_selectable.xml new file mode 100644 index 00000000..caca653d --- /dev/null +++ b/src/main/res/drawable-v21/background_selectable.xml @@ -0,0 +1,11 @@ + + + + + + + + + + diff --git a/src/main/res/drawable/background_selectable.xml b/src/main/res/drawable/background_selectable.xml new file mode 100644 index 00000000..e4b7a428 --- /dev/null +++ b/src/main/res/drawable/background_selectable.xml @@ -0,0 +1,8 @@ + + + + + + diff --git a/src/main/res/drawable/ic_action_assignment.xml b/src/main/res/drawable/ic_action_assignment.xml new file mode 100644 index 00000000..4a3eb4fe --- /dev/null +++ b/src/main/res/drawable/ic_action_assignment.xml @@ -0,0 +1,9 @@ + + + diff --git a/src/main/res/drawable/ic_av_playlist_add.xml b/src/main/res/drawable/ic_av_playlist_add.xml new file mode 100644 index 00000000..37a0ca08 --- /dev/null +++ b/src/main/res/drawable/ic_av_playlist_add.xml @@ -0,0 +1,9 @@ + + + diff --git a/src/main/res/layout/dialog_acl_rule.xml b/src/main/res/layout/dialog_acl_rule.xml new file mode 100644 index 00000000..68e30199 --- /dev/null +++ b/src/main/res/layout/dialog_acl_rule.xml @@ -0,0 +1,14 @@ + + + + + diff --git a/src/main/res/layout/layout_apps.xml b/src/main/res/layout/layout_apps.xml index 7b89ca48..ec8d350e 100644 --- a/src/main/res/layout/layout_apps.xml +++ b/src/main/res/layout/layout_apps.xml @@ -47,6 +47,7 @@ android:layout_width="fill_parent" android:layout_height="fill_parent" android:id="@+id/applistview" + android:scrollbars="vertical" android:visibility="gone"/> diff --git a/src/main/res/layout/layout_profiles.xml b/src/main/res/layout/layout_list.xml similarity index 86% rename from src/main/res/layout/layout_profiles.xml rename to src/main/res/layout/layout_list.xml index 4e90d3bb..bfe42dca 100644 --- a/src/main/res/layout/layout_profiles.xml +++ b/src/main/res/layout/layout_list.xml @@ -5,11 +5,12 @@ android:orientation="vertical"> diff --git a/src/main/res/layout/layout_tasker.xml b/src/main/res/layout/layout_tasker.xml index c33cedeb..02662ed8 100644 --- a/src/main/res/layout/layout_tasker.xml +++ b/src/main/res/layout/layout_tasker.xml @@ -15,7 +15,8 @@ android:textSize="18sp" android:padding="16dp"/> - + android:layout_height="match_parent" + android:scrollbars="vertical"/> diff --git a/src/main/res/menu/custom_rules_menu.xml b/src/main/res/menu/custom_rules_menu.xml new file mode 100644 index 00000000..380c254f --- /dev/null +++ b/src/main/res/menu/custom_rules_menu.xml @@ -0,0 +1,54 @@ + + + + + + + + + + + + + + + + + + diff --git a/src/main/res/values-ja/strings.xml b/src/main/res/values-ja/strings.xml index 2ac3360a..e43c0af1 100644 --- a/src/main/res/values-ja/strings.xml +++ b/src/main/res/values-ja/strings.xml @@ -88,13 +88,15 @@ QRコードを読み取る 手動でセットアップ + 中国本土からアクセス不可なアドレス以外を迂回する すべて LANアドレスを迂回する 中国本土のアドレスを迂回する LAN及び中国本土のアドレスを迂回する - 中国本土からアクセス不可なアドレス以外を迂回する + @string/route_entry_gfwlist 中国本土のアドレス以外を迂回する + @string/custom_rules 設定をすべてのプロファイルに適応する diff --git a/src/main/res/values-ru/strings.xml b/src/main/res/values-ru/strings.xml index 1c63b9b5..be04a638 100644 --- a/src/main/res/values-ru/strings.xml +++ b/src/main/res/values-ru/strings.xml @@ -41,13 +41,15 @@ CHN Маршрут Игнорировать все сайты находящиеся в Китае Маршрут + Список GFW Все Обход LAN Обходной Китай Обход LAN и Китай - Список GFW + @string/route_entry_gfwlist Список Китай + @string/custom_rules Прокси для выбранных приложений Установить прокси для выбранных приложений, требует режим NAT под Android 4.x diff --git a/src/main/res/values-zh-rCN/strings.xml b/src/main/res/values-zh-rCN/strings.xml index 5047b896..171b0eea 100644 --- a/src/main/res/values-zh-rCN/strings.xml +++ b/src/main/res/values-zh-rCN/strings.xml @@ -100,13 +100,15 @@ 手动设置 请安装任意兼容 ZXing 的二维码扫描应用。 + 仅代理中国大陆无法访问的地址 全局 绕过局域网地址 绕过中国大陆地址 绕过局域网及中国大陆地址 - 仅代理中国大陆无法访问的地址 + @string/route_entry_gfwlist 仅代理中国大陆地址 + @string/custom_rules 应用设置到所有配置文件 @@ -142,4 +144,14 @@ %d(远端 KCPTUN 服务器的端口号) KCP 参数 + + 自定义规则 + 选择… + 添加规则… + + 子网/域名 PCRE 正则表达式 + 域名及其子域名 + + 编辑规则 + diff --git a/src/main/res/values-zh-rTW/strings.xml b/src/main/res/values-zh-rTW/strings.xml index 9d97cb2c..4a5e8614 100644 --- a/src/main/res/values-zh-rTW/strings.xml +++ b/src/main/res/values-zh-rTW/strings.xml @@ -101,13 +101,15 @@ 手動設定 請安裝任何兼容 ZXing 的 QR 碼掃描應用程式。 + GFW List 全域 略過區域網路位址 略過中國大陸位址 略過區域網路及中國大陸位址 - 僅代理大陸屏蔽位址 + @string/route_entry_gfwlist 僅代理大陸位址 + @string/custom_rules 應用設置到所有配置文件 @@ -143,4 +145,14 @@ %d(遠端 KCPTUN 伺服器的連接埠號碼) KCP 參數 + + 自定義規則 + 選擇… + 添加規則… + + 子網/主機名 PCRE 正則表達式 + 域名及其所有子域名 + + 編輯規則 + diff --git a/src/main/res/values/arrays.xml b/src/main/res/values/arrays.xml index 7d2b4273..7fd25301 100644 --- a/src/main/res/values/arrays.xml +++ b/src/main/res/values/arrays.xml @@ -45,6 +45,7 @@ bypass-lan-china gfwlist china-list + custom-rules diff --git a/src/main/res/values/colors.xml b/src/main/res/values/colors.xml index d0374a8a..549c9a08 100644 --- a/src/main/res/values/colors.xml +++ b/src/main/res/values/colors.xml @@ -4,9 +4,11 @@ #388E3C #00C853 + #CFD8DC #90A4AE #607D8B #455A64 + @color/material_blue_grey_100 @color/material_blue_grey_300 @color/material_blue_grey_500 @color/material_blue_grey_700 diff --git a/src/main/res/values/strings.xml b/src/main/res/values/strings.xml index 3688f22e..e0310463 100644 --- a/src/main/res/values/strings.xml +++ b/src/main/res/values/strings.xml @@ -48,13 +48,15 @@ Bypass all sites located in China Route + GFW List All Bypass LAN Bypass China Bypass LAN & China - GFW List + @string/route_entry_gfwlist China List + @string/custom_rules Per-App Proxy Set proxy for selected apps, requires NAT mode under Android 4.x @@ -156,4 +158,14 @@ KCP Port %d (port number of the remote KCPTUN server) KCP Parameters (CLI) + + + Custom rules + Selection… + Add rule(s)… + + Subnet/Hostname PCRE pattern + Domain name and all its subdomain names + + Edit rule diff --git a/src/main/scala/com/github/shadowsocks/AppManager.scala b/src/main/scala/com/github/shadowsocks/AppManager.scala index e2cf7bb7..c3818e17 100644 --- a/src/main/scala/com/github/shadowsocks/AppManager.scala +++ b/src/main/scala/com/github/shadowsocks/AppManager.scala @@ -221,7 +221,7 @@ class AppManager extends AppCompatActivity with OnMenuItemClickListener { initProxiedApps() loadingView = findViewById(R.id.loading) appListView = findViewById(R.id.applistview).asInstanceOf[RecyclerView] - appListView.setLayoutManager(new LinearLayoutManager(this)) + appListView.setLayoutManager(new LinearLayoutManager(this, LinearLayoutManager.VERTICAL, false)) appListView.setItemAnimator(new DefaultItemAnimator) instance = this diff --git a/src/main/scala/com/github/shadowsocks/BaseService.scala b/src/main/scala/com/github/shadowsocks/BaseService.scala index f4ccc635..204c3df1 100644 --- a/src/main/scala/com/github/shadowsocks/BaseService.scala +++ b/src/main/scala/com/github/shadowsocks/BaseService.scala @@ -34,6 +34,7 @@ import android.text.TextUtils import android.util.Log import android.widget.Toast import com.github.shadowsocks.ShadowsocksApplication.app +import com.github.shadowsocks.acl.Acl import com.github.shadowsocks.aidl.{IShadowsocksService, IShadowsocksServiceCallback} import com.github.shadowsocks.database.Profile import com.github.shadowsocks.utils._ @@ -132,38 +133,39 @@ trait BaseService extends Service { false } else true - def connect(): Unit = if (profile.host == "198.199.101.152") { - val holder = app.containerHolder - val container = holder.getContainer - val url = container.getString("proxy_url") - val sig = Utils.getSignature(this) + def connect() { + if (profile.host == "198.199.101.152") { + val holder = app.containerHolder + val container = holder.getContainer + val url = container.getString("proxy_url") + val sig = Utils.getSignature(this) - val client = new OkHttpClient.Builder() - .dns(hostname => Utils.resolve(hostname, enableIPv6 = false) match { - case Some(ip) => util.Arrays.asList(InetAddress.getByName(ip)) - case _ => Dns.SYSTEM.lookup(hostname) - }) - .connectTimeout(10, TimeUnit.SECONDS) - .writeTimeout(10, TimeUnit.SECONDS) - .readTimeout(30, TimeUnit.SECONDS) - .build() - val requestBody = new FormBody.Builder() - .add("sig", sig) - .build() - val request = new Request.Builder() - .url(url) - .post(requestBody) - .build() + val client = new OkHttpClient.Builder() + .dns(hostname => Utils.resolve(hostname, enableIPv6 = false) match { + case Some(ip) => util.Arrays.asList(InetAddress.getByName(ip)) + case _ => Dns.SYSTEM.lookup(hostname) + }) + .connectTimeout(10, TimeUnit.SECONDS) + .writeTimeout(10, TimeUnit.SECONDS) + .readTimeout(30, TimeUnit.SECONDS) + .build() + val requestBody = new FormBody.Builder() + .add("sig", sig) + .build() + val request = new Request.Builder() + .url(url) + .post(requestBody) + .build() - val resposne = client.newCall(request).execute() - val list = resposne.body.string - - val proxies = Random.shuffle(list.split('|').toSeq) - val proxy = proxies.head.split(':') - profile.host = proxy(0).trim - profile.remotePort = proxy(1).trim.toInt - profile.password = proxy(2).trim - profile.method = proxy(3).trim + val proxies = Random.shuffle(client.newCall(request).execute().body.string.split('|').toSeq) + val proxy = proxies.head.split(':') + profile.host = proxy(0).trim + profile.remotePort = proxy(1).trim.toInt + profile.password = proxy(2).trim + profile.method = proxy(3).trim + } + if (profile.route == Acl.CUSTOM_RULES) // rationalize custom rules + Acl.save(Acl.CUSTOM_RULES, new Acl().fromId(Acl.CUSTOM_RULES)) } def startRunner(profile: Profile) { diff --git a/src/main/scala/com/github/shadowsocks/MainActivity.scala b/src/main/scala/com/github/shadowsocks/MainActivity.scala index 76222fe1..8aba2462 100644 --- a/src/main/scala/com/github/shadowsocks/MainActivity.scala +++ b/src/main/scala/com/github/shadowsocks/MainActivity.scala @@ -43,6 +43,7 @@ import android.view.View import android.widget.{TextView, Toast} import com.github.jorgecastilloprz.FABProgressCircle import com.github.shadowsocks.ShadowsocksApplication.app +import com.github.shadowsocks.acl.CustomRulesFragment import com.github.shadowsocks.aidl.IShadowsocksServiceCallback import com.github.shadowsocks.utils.CloseUtils.autoDisconnect import com.github.shadowsocks.utils._ @@ -62,6 +63,7 @@ object MainActivity { private final val DRAWER_RECOVERY = 2L private final val DRAWER_ABOUT = 3L private final val DRAWER_FAQ = 4L + private final val DRAWER_CUSTOM_RULES = 5L } class MainActivity extends Activity with ServiceBoundContext with Drawer.OnDrawerItemClickListener @@ -84,6 +86,7 @@ class MainActivity extends Activity with ServiceBoundContext with Drawer.OnDrawe private var currentFragment: ToolbarFragment = _ private lazy val profilesFragment = new ProfilesFragment() + private lazy val customRulesFragment = new CustomRulesFragment() private lazy val globalSettingsFragment = new GlobalSettingsFragment() private lazy val aboutFragment = new AboutFragment() private lazy val customTabsIntent = new CustomTabsIntent.Builder() @@ -193,6 +196,11 @@ class MainActivity extends Activity with ServiceBoundContext with Drawer.OnDrawe .withName(R.string.profiles) .withIcon(AppCompatResources.getDrawable(this, R.drawable.ic_action_description)) .withIconTintingEnabled(true), + new PrimaryDrawerItem() + .withIdentifier(DRAWER_CUSTOM_RULES) + .withName(R.string.custom_rules) + .withIcon(AppCompatResources.getDrawable(this, R.drawable.ic_action_assignment)) + .withIconTintingEnabled(true), new PrimaryDrawerItem() .withIdentifier(DRAWER_GLOBAL_SETTINGS) .withName(R.string.settings) @@ -377,6 +385,7 @@ class MainActivity extends Activity with ServiceBoundContext with Drawer.OnDrawe app.track(TAG, "about") displayFragment(aboutFragment) case DRAWER_FAQ => launchUrl(getString(R.string.faq_url)) + case DRAWER_CUSTOM_RULES => displayFragment(customRulesFragment) } true // unexpected cases will throw exception } diff --git a/src/main/scala/com/github/shadowsocks/ProfilesFragment.scala b/src/main/scala/com/github/shadowsocks/ProfilesFragment.scala index 87c467eb..6a706f8b 100644 --- a/src/main/scala/com/github/shadowsocks/ProfilesFragment.scala +++ b/src/main/scala/com/github/shadowsocks/ProfilesFragment.scala @@ -71,7 +71,7 @@ final class ProfilesFragment extends ToolbarFragment with Toolbar.OnMenuItemClic case _ => false } - final class ProfileViewHolder(val view: View) extends RecyclerView.ViewHolder(view) + final class ProfileViewHolder(view: View) extends RecyclerView.ViewHolder(view) with View.OnClickListener with PopupMenu.OnMenuItemClickListener { var item: Profile = _ @@ -256,7 +256,7 @@ final class ProfilesFragment extends ToolbarFragment with Toolbar.OnMenuItemClic .putExtra(Action.EXTRA_PROFILE_ID, id)) override def onCreateView(inflater: LayoutInflater, container: ViewGroup, savedInstanceState: Bundle): View = - inflater.inflate(R.layout.layout_profiles, container, false) + inflater.inflate(R.layout.layout_list, container, false) override def onViewCreated(view: View, savedInstanceState: Bundle) { super.onViewCreated(view, savedInstanceState) @@ -265,18 +265,19 @@ final class ProfilesFragment extends ToolbarFragment with Toolbar.OnMenuItemClic toolbar.setOnMenuItemClickListener(this) if (app.profileManager.getFirstProfile.isEmpty) app.profileId(app.profileManager.createProfile().id) - val profilesList = view.findViewById(R.id.profilesList).asInstanceOf[RecyclerView] - val layoutManager = new LinearLayoutManager(getActivity) + val profilesList = view.findViewById(R.id.list).asInstanceOf[RecyclerView] + val layoutManager = new LinearLayoutManager(getActivity, LinearLayoutManager.VERTICAL, false) profilesList.setLayoutManager(layoutManager) + layoutManager.scrollToPosition(profilesAdapter.profiles.zipWithIndex.collectFirst { + case (profile, i) if profile.id == app.profileId => i + }.getOrElse(-1)) val animator = new DefaultItemAnimator() animator.setSupportsChangeAnimations(false) // prevent fading-in/out when rebinding profilesList.setItemAnimator(animator) profilesList.setAdapter(profilesAdapter) instance = this - layoutManager.scrollToPosition(profilesAdapter.profiles.zipWithIndex.collectFirst { - case (profile, i) if profile.id == app.profileId => i - }.getOrElse(-1)) - undoManager = new UndoSnackbarManager[Profile](getActivity.findViewById(R.id.snackbar), profilesAdapter.undo, profilesAdapter.commit) + undoManager = new UndoSnackbarManager[Profile](getActivity.findViewById(R.id.snackbar), + profilesAdapter.undo, profilesAdapter.commit) new ItemTouchHelper(new SimpleCallback(ItemTouchHelper.UP | ItemTouchHelper.DOWN, ItemTouchHelper.START | ItemTouchHelper.END) { override def getSwipeDirs(recyclerView: RecyclerView, viewHolder: ViewHolder): Int = @@ -288,7 +289,7 @@ final class ProfilesFragment extends ToolbarFragment with Toolbar.OnMenuItemClic def onSwiped(viewHolder: ViewHolder, direction: Int) { val index = viewHolder.getAdapterPosition profilesAdapter.remove(index) - undoManager.remove(index, viewHolder.asInstanceOf[ProfileViewHolder].item) + undoManager.remove((index, viewHolder.asInstanceOf[ProfileViewHolder].item)) } def onMove(recyclerView: RecyclerView, viewHolder: ViewHolder, target: ViewHolder): Boolean = { profilesAdapter.move(viewHolder.getAdapterPosition, target.getAdapterPosition) diff --git a/src/main/scala/com/github/shadowsocks/QRCodeDialog.scala b/src/main/scala/com/github/shadowsocks/QRCodeDialog.scala index a6da1fd0..fe5f6101 100644 --- a/src/main/scala/com/github/shadowsocks/QRCodeDialog.scala +++ b/src/main/scala/com/github/shadowsocks/QRCodeDialog.scala @@ -30,7 +30,18 @@ import android.widget.{ImageView, LinearLayout} import com.github.shadowsocks.utils.Utils import net.glxn.qrgen.android.QRCode -final class QRCodeDialog(url: String) extends DialogFragment { +object QRCodeDialog { + private final val KEY_URL = "com.github.shadowsocks.QRCodeDialog.KEY_URL" +} + +final class QRCodeDialog extends DialogFragment { + import QRCodeDialog._ + def this(url: String) { + this() + getArguments.putString(KEY_URL, url) + } + private def url = getArguments.getString(KEY_URL) + private lazy val nfcShareItem = url.getBytes(Charset.forName("UTF-8")) private var adapter: NfcAdapter = _ diff --git a/src/main/scala/com/github/shadowsocks/ShadowsocksApplication.scala b/src/main/scala/com/github/shadowsocks/ShadowsocksApplication.scala index 2bd45095..4933b3a7 100644 --- a/src/main/scala/com/github/shadowsocks/ShadowsocksApplication.scala +++ b/src/main/scala/com/github/shadowsocks/ShadowsocksApplication.scala @@ -34,8 +34,8 @@ import android.preference.PreferenceManager import android.support.v7.app.AppCompatDelegate import android.util.Log import com.evernote.android.job.JobManager +import com.github.shadowsocks.acl.DonaldTrump import com.github.shadowsocks.database.{DBHelper, Profile, ProfileManager} -import com.github.shadowsocks.job.DonaldTrump import com.github.shadowsocks.utils.CloseUtils._ import com.github.shadowsocks.utils._ import com.google.android.gms.analytics.{GoogleAnalytics, HitBuilders, StandardExceptionParser, Tracker} diff --git a/src/main/scala/com/github/shadowsocks/ShadowsocksNatService.scala b/src/main/scala/com/github/shadowsocks/ShadowsocksNatService.scala index dc554c00..1f62ff1e 100644 --- a/src/main/scala/com/github/shadowsocks/ShadowsocksNatService.scala +++ b/src/main/scala/com/github/shadowsocks/ShadowsocksNatService.scala @@ -28,8 +28,8 @@ import android.content._ import android.os._ import android.util.Log import com.github.shadowsocks.ShadowsocksApplication.app +import com.github.shadowsocks.acl.{AclSyncJob, Acl} import com.github.shadowsocks.database.Profile -import com.github.shadowsocks.job.AclSyncJob import com.github.shadowsocks.utils._ import eu.chainfire.libsuperuser.Shell @@ -76,9 +76,9 @@ class ShadowsocksNatService extends BaseService { if (TcpFastOpen.sendEnabled) cmd += "--fast-open" - if (profile.route != Route.ALL) { + if (profile.route != Acl.ALL) { cmd += "--acl" - cmd += getApplicationInfo.dataDir + '/' + profile.route + ".acl" + cmd += Acl.getPath(profile.route) } if (BuildConfig.DEBUG) Log.d(TAG, cmd.mkString(" ")) @@ -169,11 +169,11 @@ class ShadowsocksNatService extends BaseService { val reject = if (profile.ipv6) "224.0.0.0/3" else "224.0.0.0/3, ::/0" val conf = profile.route match { - case Route.BYPASS_CHN | Route.BYPASS_LAN_CHN | Route.GFWLIST => + case Acl.BYPASS_CHN | Acl.BYPASS_LAN_CHN | Acl.GFWLIST | Acl.CUSTOM_RULES => ConfigUtils.PDNSD_DIRECT.formatLocal(Locale.ENGLISH, "", getApplicationInfo.dataDir, "127.0.0.1", profile.localPort + 53, "114.114.114.114, 223.5.5.5, 1.2.4.8", getBlackList, reject, profile.localPort + 63, reject) - case Route.CHINALIST => + case Acl.CHINALIST => ConfigUtils.PDNSD_DIRECT.formatLocal(Locale.ENGLISH, "", getApplicationInfo.dataDir, "127.0.0.1", profile.localPort + 53, "8.8.8.8, 8.8.4.4, 208.67.222.222", "", reject, profile.localPort + 63, reject) @@ -311,7 +311,7 @@ class ShadowsocksNatService extends BaseService { handleConnection() - if (profile.route != Route.ALL) + if (profile.route != Acl.ALL) AclSyncJob.schedule(profile.route) changeState(State.CONNECTED) diff --git a/src/main/scala/com/github/shadowsocks/ShadowsocksVpnService.scala b/src/main/scala/com/github/shadowsocks/ShadowsocksVpnService.scala index db3b65fd..08c5cd64 100644 --- a/src/main/scala/com/github/shadowsocks/ShadowsocksVpnService.scala +++ b/src/main/scala/com/github/shadowsocks/ShadowsocksVpnService.scala @@ -30,8 +30,8 @@ import android.net.VpnService import android.os._ import android.util.Log import com.github.shadowsocks.ShadowsocksApplication.app +import com.github.shadowsocks.acl.{AclSyncJob, Acl} import com.github.shadowsocks.database.Profile -import com.github.shadowsocks.job.AclSyncJob import com.github.shadowsocks.utils._ import scala.collection.mutable.ArrayBuffer @@ -145,7 +145,7 @@ class ShadowsocksVpnService extends VpnService with BaseService { handleConnection() changeState(State.CONNECTED) - if (profile.route != Route.ALL) + if (profile.route != Acl.ALL) AclSyncJob.schedule(profile.route) notification = new ShadowsocksNotification(this, profile.getName) @@ -241,9 +241,9 @@ class ShadowsocksVpnService extends VpnService with BaseService { if (profile.udpdns && !profile.kcp) cmd += "-u" - if (profile.route != Route.ALL) { + if (profile.route != Acl.ALL) { cmd += "--acl" - cmd += getApplicationInfo.dataDir + '/' + profile.route + ".acl" + cmd += Acl.getPath(profile.route) } if (TcpFastOpen.sendEnabled) cmd += "--fast-open" @@ -291,11 +291,11 @@ class ShadowsocksVpnService extends VpnService with BaseService { val reject = if (profile.ipv6) "224.0.0.0/3" else "224.0.0.0/3, ::/0" val protect = "protect = \"" + protectPath +"\";" val conf = profile.route match { - case Route.BYPASS_CHN | Route.BYPASS_LAN_CHN | Route.GFWLIST => + case Acl.BYPASS_CHN | Acl.BYPASS_LAN_CHN | Acl.GFWLIST | Acl.CUSTOM_RULES => ConfigUtils.PDNSD_DIRECT.formatLocal(Locale.ENGLISH, protect, getApplicationInfo.dataDir, "0.0.0.0", profile.localPort + 53, "114.114.114.114, 223.5.5.5, 1.2.4.8", getBlackList, reject, profile.localPort + 63, reject) - case Route.CHINALIST => + case Acl.CHINALIST => ConfigUtils.PDNSD_DIRECT.formatLocal(Locale.ENGLISH, protect, getApplicationInfo.dataDir, "0.0.0.0", profile.localPort + 53, "8.8.8.8, 8.8.4.4, 208.67.222.222", "", reject, profile.localPort + 63, reject) @@ -347,7 +347,7 @@ class ShadowsocksVpnService extends VpnService with BaseService { } } - if (profile.route == Route.ALL || profile.route == Route.BYPASS_CHN) { + if (profile.route == Acl.ALL || profile.route == Acl.BYPASS_CHN) { builder.addRoute("0.0.0.0", 0) } else { val privateList = getResources.getStringArray(R.array.bypass_private_route) diff --git a/src/main/scala/com/github/shadowsocks/TaskerActivity.scala b/src/main/scala/com/github/shadowsocks/TaskerActivity.scala index c976287c..cb16cc48 100644 --- a/src/main/scala/com/github/shadowsocks/TaskerActivity.scala +++ b/src/main/scala/com/github/shadowsocks/TaskerActivity.scala @@ -35,7 +35,7 @@ import com.github.shadowsocks.ShadowsocksApplication.app * @author CzBiX */ class TaskerActivity extends AppCompatActivity { - private class ProfileViewHolder(val view: View) extends RecyclerView.ViewHolder(view) with View.OnClickListener { + private class ProfileViewHolder(view: View) extends RecyclerView.ViewHolder(view) with View.OnClickListener { { val typedArray = obtainStyledAttributes(Array(android.R.attr.selectableItemBackground)) view.setBackgroundResource(typedArray.getResourceId(0, 0)) @@ -92,8 +92,8 @@ class TaskerActivity extends AppCompatActivity { taskerOption = TaskerSettings.fromIntent(getIntent) switch = findViewById(R.id.serviceSwitch).asInstanceOf[Switch] switch.setChecked(taskerOption.switchOn) - val profilesList = findViewById(R.id.profilesList).asInstanceOf[RecyclerView] - val lm = new LinearLayoutManager(this) + val profilesList = findViewById(R.id.list).asInstanceOf[RecyclerView] + val lm = new LinearLayoutManager(this, LinearLayoutManager.VERTICAL, false) profilesList.setLayoutManager(lm) profilesList.setItemAnimator(new DefaultItemAnimator) profilesList.setAdapter(profilesAdapter) diff --git a/src/main/scala/com/github/shadowsocks/acl/Acl.scala b/src/main/scala/com/github/shadowsocks/acl/Acl.scala new file mode 100644 index 00000000..6868f449 --- /dev/null +++ b/src/main/scala/com/github/shadowsocks/acl/Acl.scala @@ -0,0 +1,143 @@ +package com.github.shadowsocks.acl + +import java.io.FileNotFoundException + +import com.github.shadowsocks.ShadowsocksApplication.app +import com.github.shadowsocks.utils.IOUtils +import com.j256.ormlite.field.DatabaseField + +import scala.collection.mutable +import scala.io.Source + +/** + * ACL handler compliant with: src/main/jni/shadowsocks-libev/src/acl.c + * + * OrmLite integration is unused for now. + * + * @author Mygod + */ +class Acl { + @DatabaseField(generatedId = true) + var id: Int = _ + val bypassHostnames = new mutable.SortedList[String]() + val proxyHostnames = new mutable.SortedList[String]() + val subnets = new mutable.SortedList[Subnet]() + @DatabaseField + var bypass: Boolean = _ + + def getBypassHostnamesString: String = bypassHostnames.mkString("\n") + def getProxyHostnamesString: String = proxyHostnames.mkString("\n") + def getSubnetsString: String = subnets.mkString("\n") + def setBypassHostnamesString(value: String) { + bypassHostnames.clear() + bypassHostnames ++= value.split("\n") + } + def setProxyHostnamesString(value: String) { + proxyHostnames.clear() + proxyHostnames ++= value.split("\n") + } + def setSubnetsString(value: String) { + subnets.clear() + subnets ++= value.split("\n").map(Subnet.fromString) + } + + def fromAcl(other: Acl): Acl = { + bypassHostnames.clear() + bypassHostnames ++= other.bypassHostnames + proxyHostnames.clear() + proxyHostnames ++= other.proxyHostnames + subnets.clear() + subnets ++= other.subnets + bypass = other.bypass + this + } + def fromSource(value: Source): Acl = { + bypassHostnames.clear() + proxyHostnames.clear() + this.subnets.clear() + bypass = false + lazy val bypassSubnets = new mutable.SortedList[Subnet]() + lazy val proxySubnets = new mutable.SortedList[Subnet]() + var hostnames: mutable.SortedList[String] = proxyHostnames + var subnets: mutable.SortedList[Subnet] = proxySubnets + for (line <- value.getLines()) (line.indexOf('#') match { + case -1 => line + case index => line.substring(0, index) // trim comments + }).trim match { + case "[outbound_block_list]" => + hostnames = null + subnets = null + case "[black_list]" | "[bypass_list]" => + hostnames = bypassHostnames + subnets = bypassSubnets + case "[white_list]" | "[proxy_list]" => + hostnames = proxyHostnames + subnets = proxySubnets + case "[reject_all]" | "[bypass_all]" => bypass = true + case "[accept_all]" | "[proxy_all]" => bypass = false + case input if subnets != null && input.nonEmpty => try subnets += Subnet.fromString(input) catch { + case _: IllegalArgumentException => hostnames += input + } + case _ => + } + this.subnets ++= (if (bypass) bypassSubnets else proxySubnets) + this + } + final def fromId(id: String): Acl = fromSource(Source.fromFile(Acl.getPath(id))) + + override def toString: String = { + val result = new StringBuilder() + result.append(if (bypass) "[bypass_all]\n" else "[proxy_all]\n") + var bypassList = bypassHostnames.toStream + var proxyList = proxyHostnames.toStream + if (bypass) bypassList = subnets.toStream.map(_.toString) #::: bypassList + else proxyList = subnets.toStream.map(_.toString) #::: proxyList + if (bypassList.nonEmpty) { + result.append("[bypass_list]\n") + result.append(bypassList.mkString("\n")) + result.append('\n') + } + if (proxyList.nonEmpty) { + result.append("[proxy_list]\n") + result.append(proxyList.mkString("\n")) + result.append('\n') + } + result.toString + } + + def isValidCustomRules: Boolean = bypass && bypassHostnames.isEmpty + + // Don't change: dummy fields for OrmLite interaction + + // noinspection ScalaUnusedSymbol + @DatabaseField(useGetSet = true) + private val bypassHostnamesString: String = null + // noinspection ScalaUnusedSymbol + @DatabaseField(useGetSet = true) + private val proxyHostnamesString: String = null + // noinspection ScalaUnusedSymbol + @DatabaseField(useGetSet = true) + private val subnetsString: String = null +} + +object Acl { + final val ALL = "all" + final val BYPASS_LAN = "bypass-lan" + final val BYPASS_CHN = "bypass-china" + final val BYPASS_LAN_CHN = "bypass-lan-china" + final val GFWLIST = "gfwlist" + final val CHINALIST = "china-list" + final val CUSTOM_RULES = "custom-rules" + + def getPath(id: String): String = app.getApplicationInfo.dataDir + '/' + id + ".acl" + def customRules: Acl = { + val acl = new Acl() + try acl.fromId(CUSTOM_RULES) catch { + case _: FileNotFoundException => + } + acl.bypass = true + acl.bypassHostnames.clear() // everything is bypassed + acl + } + def save(id: String, acl: Acl): Unit = IOUtils.writeString(getPath(id), acl.toString) +} diff --git a/src/main/scala/com/github/shadowsocks/job/AclSyncJob.scala b/src/main/scala/com/github/shadowsocks/acl/AclSyncJob.scala similarity index 91% rename from src/main/scala/com/github/shadowsocks/job/AclSyncJob.scala rename to src/main/scala/com/github/shadowsocks/acl/AclSyncJob.scala index 3af59e8a..9fa08255 100644 --- a/src/main/scala/com/github/shadowsocks/job/AclSyncJob.scala +++ b/src/main/scala/com/github/shadowsocks/acl/AclSyncJob.scala @@ -18,18 +18,18 @@ /* */ /*******************************************************************************/ -package com.github.shadowsocks.job +package com.github.shadowsocks.acl import java.io.IOException -import java.net.URL import java.util.concurrent.TimeUnit import com.evernote.android.job.Job.{Params, Result} import com.evernote.android.job.{Job, JobRequest} import com.github.shadowsocks.ShadowsocksApplication.app -import com.github.shadowsocks.utils.CloseUtils._ import com.github.shadowsocks.utils.IOUtils +import scala.io.Source + /** * @author Mygod */ @@ -50,9 +50,8 @@ class AclSyncJob(route: String) extends Job { val filename = route + ".acl" try { //noinspection JavaAccessorMethodCalledAsEmptyParen - IOUtils.writeString(app.getApplicationInfo.dataDir + '/' + filename, autoClose( - new URL("https://shadowsocks.org/acl/android/v1/" + - filename).openConnection().getInputStream())(IOUtils.readString)) + IOUtils.writeString(app.getApplicationInfo.dataDir + '/' + filename, + Source.fromURL("https://shadowsocks.org/acl/android/v1/" + filename).mkString) Result.SUCCESS } catch { case e: IOException => diff --git a/src/main/scala/com/github/shadowsocks/acl/CustomRulesFragment.scala b/src/main/scala/com/github/shadowsocks/acl/CustomRulesFragment.scala new file mode 100644 index 00000000..01cf8588 --- /dev/null +++ b/src/main/scala/com/github/shadowsocks/acl/CustomRulesFragment.scala @@ -0,0 +1,268 @@ +package com.github.shadowsocks.acl + +import java.net.IDN +import java.util.Locale + +import android.content.{ClipData, ClipboardManager, Context, DialogInterface} +import android.os.{Bundle, Handler} +import android.support.v7.app.AlertDialog +import android.support.v7.widget.RecyclerView.ViewHolder +import android.support.v7.widget.helper.ItemTouchHelper +import android.support.v7.widget.helper.ItemTouchHelper.SimpleCallback +import android.support.v7.widget.{DefaultItemAnimator, LinearLayoutManager, RecyclerView, Toolbar} +import android.view._ +import android.widget.{EditText, Spinner, TextView, Toast} +import com.github.shadowsocks.ShadowsocksApplication.app +import com.github.shadowsocks.utils.State +import com.github.shadowsocks.widget.UndoSnackbarManager +import com.github.shadowsocks.{MainActivity, R, ToolbarFragment} + +import scala.collection.mutable + +/** + * @author Mygod + */ +object CustomRulesFragment { + private final val GENERIC = 0 + private final val DOMAIN = 1 + private val PATTERN_DOMAIN = """(?<=^\(\^\|\\\.\)).*(?=\$$)""".r + private final val TEMPLATE_DOMAIN = "(^|\\.)%s$" + + private final val SELECTED_SUBNETS = "com.github.shadowsocks.acl.CustomRulesFragment.SELECTED_SUBNETS" + private final val SELECTED_HOSTNAMES = "com.github.shadowsocks.acl.CustomRulesFragment.SELECTED_HOSTNAMES" +} + +class CustomRulesFragment extends ToolbarFragment with Toolbar.OnMenuItemClickListener { + import CustomRulesFragment._ + + private def isEnabled = getActivity.asInstanceOf[MainActivity].state match { + case State.CONNECTED => app.currentProfile.get.route != Acl.CUSTOM_RULES + case State.STOPPED => true + case _ => false + } + + private def createAclRuleDialog(text: CharSequence = "") = { + val view = getActivity.getLayoutInflater.inflate(R.layout.dialog_acl_rule, null) + val templateSelector = view.findViewById(R.id.template_selector).asInstanceOf[Spinner] + val editText = view.findViewById(R.id.content).asInstanceOf[EditText] + PATTERN_DOMAIN.findFirstMatchIn(text) match { + case Some(m) => + templateSelector.setSelection(DOMAIN) + editText.setText(IDN.toUnicode(m.matched.replaceAll("\\\\.", "."), + IDN.ALLOW_UNASSIGNED | IDN.USE_STD3_ASCII_RULES)) + case None => + templateSelector.setSelection(GENERIC) + editText.setText(text) + } + (templateSelector, editText, new AlertDialog.Builder(getActivity) + .setTitle(R.string.edit_rule) + .setNegativeButton(android.R.string.cancel, null) + .setView(view)) + } + + private val handler = new Handler() + private val selectedItems = new mutable.HashSet[AnyRef] + private def onSelectedItemsUpdated(): Unit = selectionItem.setVisible(selectedItems.nonEmpty) + + private final class AclRuleViewHolder(view: View) extends RecyclerView.ViewHolder(view) + with View.OnClickListener with View.OnLongClickListener { + var item: AnyRef = _ + private val text = itemView.findViewById(android.R.id.text1).asInstanceOf[TextView] + itemView.setOnClickListener(this) + itemView.setOnLongClickListener(this) + itemView.setBackgroundResource(R.drawable.background_selectable) + + def bind(hostname: String) { + item = hostname + text.setText(hostname) + itemView.setSelected(selectedItems.contains(hostname)) + } + def bind(subnet: Subnet) { + item = subnet + text.setText(subnet.toString) + itemView.setSelected(selectedItems.contains(subnet)) + } + + def onClick(v: View): Unit = if (selectedItems.nonEmpty) onLongClick(v) else { + val (templateSelector, editText, dialog) = createAclRuleDialog(item.toString) + dialog + .setNeutralButton(R.string.delete, ((_, _) => { + adapter.remove(item) + undoManager.remove((-1, item)) + }): DialogInterface.OnClickListener) + .setPositiveButton(android.R.string.ok, ((_, _) => + if (adapter.addFromTemplate(templateSelector.getSelectedItemPosition, editText.getText)) adapter.remove(item)) + : DialogInterface.OnClickListener) + .create().show() + } + def onLongClick(v: View): Boolean = { + if (!selectedItems.add(item)) selectedItems.remove(item) // toggle + onSelectedItemsUpdated() + itemView.setSelected(!itemView.isSelected) + true + } + } + + private final class AclRulesAdapter extends RecyclerView.Adapter[AclRuleViewHolder] { + private val acl = Acl.customRules + private var savePending: Boolean = _ + private def apply() = if (!savePending) { + savePending = true + handler.post(() => { + Acl.save(Acl.CUSTOM_RULES, acl) + savePending = false + }) + } + + def getItemCount: Int = acl.subnets.size + acl.proxyHostnames.size + def onBindViewHolder(vh: AclRuleViewHolder, i: Int): Unit = { + val j = i - acl.subnets.size + if (j < 0) vh.bind(acl.subnets(i)) else vh.bind(acl.proxyHostnames(j)) + } + def onCreateViewHolder(vg: ViewGroup, i: Int) = new AclRuleViewHolder(LayoutInflater.from(vg.getContext) + .inflate(android.R.layout.simple_list_item_1, vg, false)) + + def addSubnet(subnet: Subnet): Boolean = if (acl.subnets.add(subnet)) { + notifyItemInserted(acl.subnets.indexOf(subnet)) + apply() + true + } else false + def addHostname(hostname: String): Boolean = if (acl.proxyHostnames.add(hostname)) { + notifyItemInserted(acl.proxyHostnames.indexOf(hostname) + acl.subnets.size) + apply() + true + } else false + def addToProxy(input: String): Boolean = try addSubnet(Subnet.fromString(input)) catch { + case _: IllegalArgumentException => addHostname(input) + } + def addFromTemplate(template: Int, text: CharSequence): Boolean = template match { + case GENERIC => addToProxy(text.toString) + case DOMAIN => try addHostname(TEMPLATE_DOMAIN.formatLocal(Locale.ENGLISH, + IDN.toASCII(text.toString, IDN.ALLOW_UNASSIGNED | IDN.USE_STD3_ASCII_RULES).replaceAll("\\.", "\\\\."))) catch { + case exc: IllegalArgumentException => + Toast.makeText(getActivity, exc.getMessage, Toast.LENGTH_SHORT).show() + false + } + case _ => false + } + + def remove(i: Int) { + val j = i - acl.subnets.size + if (j < 0) acl.subnets.remove(i) else acl.proxyHostnames.remove(j) + notifyItemRemoved(i) + apply() + } + def remove(item: AnyRef): Unit = item match { + case subnet: Subnet => + notifyItemRemoved(acl.subnets.indexOf(subnet)) + acl.subnets.remove(subnet) + apply() + case hostname: String => + notifyItemRemoved(acl.proxyHostnames.indexOf(hostname)) + acl.proxyHostnames.remove(hostname) + apply() + } + def removeSelected() { + undoManager.remove(selectedItems.map((-1, _)).toSeq: _*) + selectedItems.foreach(remove) + selectedItems.clear() + onSelectedItemsUpdated() + } + def undo(actions: Iterator[(Int, AnyRef)]): Unit = for ((_, item) <- actions) item match { + case hostname: String => if (acl.proxyHostnames.insert(hostname)) { + notifyItemInserted(acl.proxyHostnames.indexOf(hostname) + acl.subnets.size) + apply() + } + case subnet: Subnet => if (acl.subnets.insert(subnet)) { + notifyItemInserted(acl.subnets.indexOf(subnet)) + apply() + } + } + + def selectAll() { + selectedItems.clear() + selectedItems ++= acl.subnets + selectedItems ++= acl.proxyHostnames + onSelectedItemsUpdated() + notifyDataSetChanged() + } + } + + private lazy val adapter = new AclRulesAdapter() + private var list: RecyclerView = _ + private var selectionItem: MenuItem = _ + private var undoManager: UndoSnackbarManager[AnyRef] = _ + private lazy val clipboard = getActivity.getSystemService(Context.CLIPBOARD_SERVICE).asInstanceOf[ClipboardManager] + + override def onCreateView(inflater: LayoutInflater, container: ViewGroup, savedInstanceState: Bundle): View = + inflater.inflate(R.layout.layout_list, container, false) + override def onViewCreated(view: View, savedInstanceState: Bundle) { + super.onViewCreated(view, savedInstanceState) + if (savedInstanceState != null) { + savedInstanceState.getStringArray(SELECTED_SUBNETS) match { + case null => + case arr => selectedItems ++= arr.map(Subnet.fromString) + } + savedInstanceState.getStringArray(SELECTED_HOSTNAMES) match { + case null => + case arr => selectedItems ++= arr + } + onSelectedItemsUpdated() + } + toolbar.setTitle(R.string.custom_rules) + toolbar.inflateMenu(R.menu.custom_rules_menu) + toolbar.setOnMenuItemClickListener(this) + selectionItem = toolbar.getMenu.findItem(R.id.selection) + app.updateAssets() + list = view.findViewById(R.id.list).asInstanceOf[RecyclerView] + list.setLayoutManager(new LinearLayoutManager(getActivity, LinearLayoutManager.VERTICAL, false)) + list.setItemAnimator(new DefaultItemAnimator) + list.setAdapter(adapter) + undoManager = new UndoSnackbarManager[AnyRef](getActivity.findViewById(R.id.snackbar), adapter.undo) + new ItemTouchHelper(new SimpleCallback(0, ItemTouchHelper.START | ItemTouchHelper.END) { + override def getSwipeDirs(recyclerView: RecyclerView, viewHolder: ViewHolder): Int = + if (isEnabled) super.getSwipeDirs(recyclerView, viewHolder) else 0 + def onSwiped(viewHolder: ViewHolder, direction: Int): Unit = adapter.remove(viewHolder.getAdapterPosition) + def onMove(recyclerView: RecyclerView, viewHolder: ViewHolder, target: ViewHolder): Boolean = false + }).attachToRecyclerView(list) + } + + override def onSaveInstanceState(outState: Bundle): Unit = { + super.onSaveInstanceState(outState) + outState.putStringArray(SELECTED_SUBNETS, selectedItems.filter(_.isInstanceOf[Subnet]).map(_.toString).toArray) + outState.putStringArray(SELECTED_HOSTNAMES, selectedItems.filter(_.isInstanceOf[String]).map(_.toString).toArray) + } + + override def onMenuItemClick(menuItem: MenuItem): Boolean = menuItem.getItemId match { + case R.id.action_select_all => + adapter.selectAll() + true + case R.id.action_cut => + clipboard.setPrimaryClip(ClipData.newPlainText(null, selectedItems.mkString("\n"))) + adapter.removeSelected() + true + case R.id.action_copy => + clipboard.setPrimaryClip(ClipData.newPlainText(null, selectedItems.mkString("\n"))) + true + case R.id.action_delete => + adapter.removeSelected() + true + + case R.id.action_manual_settings => + val (templateSelector, editText, dialog) = createAclRuleDialog() + dialog + .setPositiveButton(android.R.string.ok, ((_, _) => adapter.addFromTemplate( + templateSelector.getSelectedItemPosition, editText.getText)): DialogInterface.OnClickListener) + .create().show() + true + case R.id.action_import => + clipboard.getPrimaryClip.getItemAt(0).getText.toString.split("\n").foreach(adapter.addToProxy) + true + case R.id.action_import_gfwlist => + val acl = new Acl().fromId(Acl.GFWLIST) + acl.subnets.foreach(adapter.addSubnet) + acl.proxyHostnames.foreach(adapter.addHostname) + true + case _ => false + } +} diff --git a/src/main/scala/com/github/shadowsocks/job/DonaldTrump.scala b/src/main/scala/com/github/shadowsocks/acl/DonaldTrump.scala similarity index 98% rename from src/main/scala/com/github/shadowsocks/job/DonaldTrump.scala rename to src/main/scala/com/github/shadowsocks/acl/DonaldTrump.scala index c710ad3c..30730a8e 100644 --- a/src/main/scala/com/github/shadowsocks/job/DonaldTrump.scala +++ b/src/main/scala/com/github/shadowsocks/acl/DonaldTrump.scala @@ -18,7 +18,7 @@ /* */ /*******************************************************************************/ -package com.github.shadowsocks.job +package com.github.shadowsocks.acl import android.util.Log import com.evernote.android.job.JobCreator diff --git a/src/main/scala/com/github/shadowsocks/acl/Subnet.scala b/src/main/scala/com/github/shadowsocks/acl/Subnet.scala new file mode 100644 index 00000000..41e7aed3 --- /dev/null +++ b/src/main/scala/com/github/shadowsocks/acl/Subnet.scala @@ -0,0 +1,51 @@ +package com.github.shadowsocks.acl + +import java.net.{Inet4Address, Inet6Address, InetAddress} + +import com.github.shadowsocks.utils.Utils + +/** + * @author Mygod + */ +@throws[IllegalArgumentException] +class Subnet(val address: InetAddress, val prefixSize: Int) extends Comparable[Subnet] { + if (prefixSize < 0) throw new IllegalArgumentException + address match { + case _: Inet4Address => if (prefixSize > 32) throw new IllegalArgumentException + case _: Inet6Address => if (prefixSize > 128) throw new IllegalArgumentException + } + + override def toString: String = if (address match { + case _: Inet4Address => prefixSize == 32 + case _: Inet6Address => prefixSize == 128 + }) address.toString else address.toString + '/' + prefixSize + + override def compareTo(that: Subnet): Int = { + val addrThis = address.getAddress + val addrThat = that.address.getAddress + var result = addrThis lengthCompare addrThat.length // IPv4 address goes first + if (result != 0) return result + for ((x, y) <- addrThis zip addrThat) { + result = x compare y + if (result != 0) return result + } + prefixSize compare that.prefixSize + } +} + +object Subnet { + @throws[IllegalArgumentException] + def fromString(value: String): Subnet = { + val parts = value.split("/") + if (!Utils.isNumeric(parts(0))) throw new IllegalArgumentException() + val addr = InetAddress.getByName(parts(0)) + parts.length match { + case 1 => new Subnet(addr, addr match { + case _: Inet4Address => 32 + case _: Inet6Address => 128 + }) + case 2 => new Subnet(addr, parts(1).toInt) + case _ => throw new IllegalArgumentException() + } + } +} diff --git a/src/main/scala/com/github/shadowsocks/utils/Constants.scala b/src/main/scala/com/github/shadowsocks/utils/Constants.scala index 703d2629..6058fd0f 100644 --- a/src/main/scala/com/github/shadowsocks/utils/Constants.scala +++ b/src/main/scala/com/github/shadowsocks/utils/Constants.scala @@ -179,12 +179,3 @@ object Action { final val EXTRA_PROFILE_ID = "com.github.shadowsocks.EXTRA_PROFILE_ID" } - -object Route { - val ALL = "all" - val BYPASS_LAN = "bypass-lan" - val BYPASS_CHN = "bypass-china" - val BYPASS_LAN_CHN = "bypass-lan-china" - val GFWLIST = "gfwlist" - val CHINALIST = "china-list" -} diff --git a/src/main/scala/com/github/shadowsocks/utils/IOUtils.scala b/src/main/scala/com/github/shadowsocks/utils/IOUtils.scala index 9fc0f8fd..fc97c355 100644 --- a/src/main/scala/com/github/shadowsocks/utils/IOUtils.scala +++ b/src/main/scala/com/github/shadowsocks/utils/IOUtils.scala @@ -37,16 +37,6 @@ object IOUtils { } } - def readString(in: InputStream): String = { - val builder = new StringBuilder() - val buffer = new Array[Byte](BUFFER_SIZE) - while (true) { - val count = in.read(buffer) - if (count >= 0) builder.append(new String(buffer, 0, count)) else return builder.toString() - } - null - } - def writeString(file: String, content: String): Unit = autoClose(new FileWriter(file))(writer => writer.write(content)) } diff --git a/src/main/scala/com/github/shadowsocks/widget/UndoSnackbarManager.scala b/src/main/scala/com/github/shadowsocks/widget/UndoSnackbarManager.scala index 64ce7fc4..8c748a81 100644 --- a/src/main/scala/com/github/shadowsocks/widget/UndoSnackbarManager.scala +++ b/src/main/scala/com/github/shadowsocks/widget/UndoSnackbarManager.scala @@ -51,8 +51,8 @@ class UndoSnackbarManager[T](view: View, undo: Iterator[(Int, T)] => Unit, } private var last: Snackbar = _ - def remove(index: Int, item: T) { - recycleBin.append((index, item)) + def remove(items: (Int, T)*) { + recycleBin.appendAll(items) val count = recycleBin.length last = Snackbar .make(view, view.getResources.getQuantityString(R.plurals.removed, count, count: Integer), Snackbar.LENGTH_LONG) diff --git a/src/main/scala/scala/collection/mutable/SortedList.scala b/src/main/scala/scala/collection/mutable/SortedList.scala new file mode 100644 index 00000000..a55eefe3 --- /dev/null +++ b/src/main/scala/scala/collection/mutable/SortedList.scala @@ -0,0 +1,152 @@ +package scala.collection.mutable + +import scala.collection.SortedSetLike +import scala.collection.generic.MutableSortedSetFactory +import scala.collection.immutable.RedBlackTree.Tree +import scala.collection.immutable.{RedBlackTree => RB} +import scala.runtime.ObjectRef + +/** + * @define Coll `mutable.SortedList` + * @define coll mutable sorted list + * @factoryInfo + * Companion object of SortedList providing factory related utilities. + * @author Mygod + */ +//noinspection ReferenceMustBePrefixed,ScalaDocUnknownTag +object SortedList extends MutableSortedSetFactory[SortedList] { + /** + * The empty list of this type + */ + def empty[A](implicit ordering: Ordering[A]) = new SortedList[A]() +} + +/** + * A mutable SortedList heavily based on SortedSet which "is not designed to enable meaningful subclassing". + * + * Based on: https://github.com/scala/scala/blob/3cc99d7/src/library/scala/collection/mutable/TreeSet.scala + * + * @author Mygod + */ +//noinspection ReferenceMustBePrefixed +class SortedList[A] private(treeRef: ObjectRef[RB.Tree[A, Null]], from: Option[A], until: Option[A]) + (implicit val ordering: Ordering[A]) + extends SortedSet[A] with SetLike[A, SortedList[A]] with SortedSetLike[A, SortedList[A]] with Set[A] with Serializable { + def insert(elem: A): Boolean = { + val old = size + this += elem + old < size + } + + // AbstractSeq + def apply(i: Int): A = RB.nth(treeRef.elem, i).key // Should i out of range result in NullReferenceException? + def length: Int = size + + // Buffer + def +=:(elem: A): SortedList.this.type = +=(elem) // prepending is the same as the list is sorted + def remove(n: Int): A = { + val result = apply(n) + remove(result) + result + } + def update(idx: Int, elem: A) { + val x = apply(idx) + if (!ordering.equiv(x, elem)) { + remove(x) + this.add(elem) + } + } + + // Optimized methods + override def clear(): Unit = treeRef.elem = null + def indexOf[B >: A](elem: B, from: Int = 0): Int = lookupIndex(treeRef.elem, elem.asInstanceOf[A]) match { + case result if result < from => -1 + case result => result + } + override def last: A = RB.greatest(treeRef.elem).key + + // Tree helper methods, TODO: expand to non-recursive form? + private def lookupIndex(tree: Tree[A, Null], x: A): Int = + if (tree eq null) -1 else ordering.compare(x, tree.key) match { + case 0 => RB.count(tree.left) + case cmp if cmp < 0 => lookupIndex(tree.left, x) + case _ => lookupIndex(tree.right, x) match { + case i if i < 0 => i + case i => RB.count(tree.left) + 1 + i + } + } + + // The following is copied from TreeSet + + if (ordering eq null) + throw new NullPointerException("ordering must not be null") + + def this()(implicit ordering: Ordering[A]) = this(new ObjectRef(null), None, None) + + override def size: Int = RB.countInRange(treeRef.elem, from, until) + + override def stringPrefix = "SortedList" + + override def empty: SortedList[A] = SortedList.empty + + private def pickBound(comparison: (A, A) => A, oldBound: Option[A], newBound: Option[A]) = (newBound, oldBound) match { + case (Some(newB), Some(oldB)) => Some(comparison(newB, oldB)) + case (None, _) => oldBound + case _ => newBound + } + + override def rangeImpl(fromArg: Option[A], untilArg: Option[A]): SortedList[A] = { + val newFrom = pickBound(ordering.max, fromArg, from) + val newUntil = pickBound(ordering.min, untilArg, until) + + new SortedList(treeRef, newFrom, newUntil) + } + + override def -=(elem: A): this.type = { + treeRef.elem = RB.delete(treeRef.elem, elem) + this + } + + override def +=(elem: A): this.type = { + treeRef.elem = RB.update(treeRef.elem, elem, null, overwrite = false) + this + } + + /** + * Thanks to the immutable nature of the + * underlying Tree, we can share it with + * the clone. So clone complexity in time is O(1). + * + */ + override def clone(): SortedList[A] = + new SortedList[A](new ObjectRef(treeRef.elem), from, until) + + private val notProjection = !(from.isDefined || until.isDefined) + + override def contains(elem: A): Boolean = { + def leftAcceptable: Boolean = from match { + case Some(lb) => ordering.gteq(elem, lb) + case _ => true + } + + def rightAcceptable: Boolean = until match { + case Some(ub) => ordering.lt(elem, ub) + case _ => true + } + + (notProjection || (leftAcceptable && rightAcceptable)) && + RB.contains(treeRef.elem, elem) + } + + override def iterator: Iterator[A] = iteratorFrom(None) + + override def keysIteratorFrom(start: A): Iterator[A] = iteratorFrom(Some(start)) + + private def iteratorFrom(start: Option[A]) = { + val it = RB.keysIterator(treeRef.elem, pickBound(ordering.max, from, start)) + until match { + case None => it + case Some(ub) => it takeWhile (k => ordering.lt(k, ub)) + } + } +}