Browse Source

Impl full sip003

世界 4 years ago
parent
commit
c58bfd3273

+ 61 - 0
app/src/main/java/com/github/shadowsocks/preference/PluginConfigurationDialogFragment.kt

@@ -0,0 +1,61 @@
+/*******************************************************************************
+ *                                                                             *
+ *  Copyright (C) 2017 by Max Lv <[email protected]>                          *
+ *  Copyright (C) 2017 by Mygod Studio <[email protected]>  *
+ *                                                                             *
+ *  This program is free software: you can redistribute it and/or modify       *
+ *  it under the terms of the GNU General Public License as published by       *
+ *  the Free Software Foundation, either version 3 of the License, or          *
+ *  (at your option) any later version.                                        *
+ *                                                                             *
+ *  This program is distributed in the hope that it will be useful,            *
+ *  but WITHOUT ANY WARRANTY; without even the implied warranty of             *
+ *  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the              *
+ *  GNU General Public License for more details.                               *
+ *                                                                             *
+ *  You should have received a copy of the GNU General Public License          *
+ *  along with this program. If not, see <http://www.gnu.org/licenses/>.       *
+ *                                                                             *
+ *******************************************************************************/
+
+package com.github.shadowsocks.preference
+
+import android.view.View
+import android.widget.EditText
+import androidx.appcompat.app.AlertDialog
+import androidx.core.os.bundleOf
+import androidx.preference.EditTextPreferenceDialogFragmentCompat
+import androidx.preference.PreferenceDialogFragmentCompat
+import com.github.shadowsocks.plugin.PluginContract
+import com.github.shadowsocks.plugin.PluginManager
+import io.nekohasekai.sagernet.fmt.shadowsocks.ShadowsocksBean
+import io.nekohasekai.sagernet.ui.settings.ProfileSettingsActivity
+import io.nekohasekai.sagernet.ui.settings.ShadowsocksSettingsActivity
+
+class PluginConfigurationDialogFragment : EditTextPreferenceDialogFragmentCompat() {
+    companion object {
+        private const val PLUGIN_ID_FRAGMENT_TAG =
+                "com.github.shadowsocks.preference.PluginConfigurationDialogFragment.PLUGIN_ID"
+    }
+
+    fun setArg(key: String, plugin: String) {
+        arguments = bundleOf(PreferenceDialogFragmentCompat.ARG_KEY to key, PLUGIN_ID_FRAGMENT_TAG to plugin)
+    }
+
+    private lateinit var editText: EditText
+
+    override fun onPrepareDialogBuilder(builder: AlertDialog.Builder) {
+        super.onPrepareDialogBuilder(builder)
+        val intent = PluginManager.buildIntent(arguments?.getString(PLUGIN_ID_FRAGMENT_TAG)!!,
+                PluginContract.ACTION_HELP)
+        val activity = activity as ShadowsocksSettingsActivity
+        if (intent.resolveActivity(activity.packageManager) != null) builder.setNeutralButton("?") { _, _ ->
+            activity.pluginHelp.launch(intent.putExtra(PluginContract.EXTRA_OPTIONS, editText.text.toString()))
+        }
+    }
+
+    override fun onBindDialogView(view: View) {
+        super.onBindDialogView(view)
+        editText = view.findViewById(android.R.id.edit)
+    }
+}

+ 69 - 0
app/src/main/java/com/github/shadowsocks/preference/PluginPreference.kt

@@ -0,0 +1,69 @@
+/*******************************************************************************
+ *                                                                             *
+ *  Copyright (C) 2017 by Max Lv <[email protected]>                          *
+ *  Copyright (C) 2017 by Mygod Studio <[email protected]>  *
+ *                                                                             *
+ *  This program is free software: you can redistribute it and/or modify       *
+ *  it under the terms of the GNU General Public License as published by       *
+ *  the Free Software Foundation, either version 3 of the License, or          *
+ *  (at your option) any later version.                                        *
+ *                                                                             *
+ *  This program is distributed in the hope that it will be useful,            *
+ *  but WITHOUT ANY WARRANTY; without even the implied warranty of             *
+ *  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the              *
+ *  GNU General Public License for more details.                               *
+ *                                                                             *
+ *  You should have received a copy of the GNU General Public License          *
+ *  along with this program. If not, see <http://www.gnu.org/licenses/>.       *
+ *                                                                             *
+ *******************************************************************************/
+
+package com.github.shadowsocks.preference
+
+import android.content.Context
+import android.graphics.drawable.Drawable
+import android.util.AttributeSet
+import androidx.preference.ListPreference
+import com.github.shadowsocks.plugin.PluginList
+import com.github.shadowsocks.plugin.PluginManager
+import io.nekohasekai.sagernet.R
+
+class PluginPreference(context: Context, attrs: AttributeSet? = null) : ListPreference(context, attrs) {
+    companion object FallbackProvider : SummaryProvider<PluginPreference> {
+        override fun provideSummary(preference: PluginPreference) =
+                preference.selectedEntry?.label ?: preference.unknownValueSummary.format(preference.value)
+    }
+
+    lateinit var plugins: PluginList
+    val selectedEntry get() = plugins.lookup[value]
+    private val entryIcon: Drawable? get() = selectedEntry?.icon
+    private val unknownValueSummary = context.getString(R.string.plugin_unknown)
+
+    private var listener: OnPreferenceChangeListener? = null
+    override fun getOnPreferenceChangeListener(): OnPreferenceChangeListener? = listener
+    override fun setOnPreferenceChangeListener(listener: OnPreferenceChangeListener?) {
+        this.listener = listener
+    }
+
+    init {
+        super.setOnPreferenceChangeListener { preference, newValue ->
+            val listener = listener
+            if (listener == null || listener.onPreferenceChange(preference, newValue)) {
+                value = newValue.toString()
+                icon = entryIcon
+                true
+            } else false
+        }
+    }
+
+    fun init() {
+        plugins = PluginManager.fetchPlugins()
+        entryValues = plugins.lookup.map { it.key }.toTypedArray()
+        icon = entryIcon
+        summaryProvider = FallbackProvider
+    }
+    override fun onSetInitialValue(defaultValue: Any?) {
+        super.onSetInitialValue(defaultValue)
+        init()
+    }
+}

+ 141 - 0
app/src/main/java/com/github/shadowsocks/preference/PluginPreferenceDialogFragment.kt

@@ -0,0 +1,141 @@
+/*******************************************************************************
+ *                                                                             *
+ *  Copyright (C) 2017 by Max Lv <[email protected]>                          *
+ *  Copyright (C) 2017 by Mygod Studio <[email protected]>  *
+ *                                                                             *
+ *  This program is free software: you can redistribute it and/or modify       *
+ *  it under the terms of the GNU General Public License as published by       *
+ *  the Free Software Foundation, either version 3 of the License, or          *
+ *  (at your option) any later version.                                        *
+ *                                                                             *
+ *  This program is distributed in the hope that it will be useful,            *
+ *  but WITHOUT ANY WARRANTY; without even the implied warranty of             *
+ *  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the              *
+ *  GNU General Public License for more details.                               *
+ *                                                                             *
+ *  You should have received a copy of the GNU General Public License          *
+ *  along with this program. If not, see <http://www.gnu.org/licenses/>.       *
+ *                                                                             *
+ *******************************************************************************/
+
+package com.github.shadowsocks.preference
+
+import android.app.Dialog
+import android.content.ActivityNotFoundException
+import android.content.Intent
+import android.graphics.Typeface
+import android.net.Uri
+import android.os.Bundle
+import android.provider.Settings
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.widget.ImageView
+import android.widget.TextView
+import androidx.appcompat.widget.TooltipCompat
+import androidx.core.os.bundleOf
+import androidx.core.view.isGone
+import androidx.core.view.isVisible
+import androidx.fragment.app.setFragmentResult
+import androidx.preference.PreferenceDialogFragmentCompat
+import androidx.recyclerview.widget.DefaultItemAnimator
+import androidx.recyclerview.widget.LinearLayoutManager
+import androidx.recyclerview.widget.RecyclerView
+import com.github.shadowsocks.plugin.Plugin
+import com.google.android.material.bottomsheet.BottomSheetDialog
+import io.nekohasekai.sagernet.R
+import io.nekohasekai.sagernet.database.DataStore
+
+class PluginPreferenceDialogFragment : PreferenceDialogFragmentCompat() {
+    companion object {
+        const val KEY_SELECTED_ID = "id"
+    }
+
+    private inner class IconListViewHolder(val dialog: BottomSheetDialog, view: View) : RecyclerView.ViewHolder(view),
+            View.OnClickListener, View.OnLongClickListener {
+        private lateinit var plugin: Plugin
+        private val text1 = view.findViewById<TextView>(android.R.id.text1)
+        private val text2 = view.findViewById<TextView>(android.R.id.text2)
+        private val icon = view.findViewById<ImageView>(android.R.id.icon)
+        private val unlock = view.findViewById<View>(R.id.unlock).apply {
+            TooltipCompat.setTooltipText(this, getText(R.string.plugin_auto_connect_unlock_only))
+        }
+
+        init {
+            view.setOnClickListener(this)
+            view.setOnLongClickListener(this)
+        }
+
+        fun bind(plugin: Plugin, selected: Boolean = false) {
+            this.plugin = plugin
+            val label = plugin.label
+            text1.text = label
+            text2.text = plugin.id
+            val typeface = if (selected) Typeface.BOLD else Typeface.NORMAL
+            text1.setTypeface(null, typeface)
+            text2.setTypeface(null, typeface)
+            text2.isVisible = plugin.id.isNotEmpty() && label != plugin.id
+            icon.setImageDrawable(plugin.icon)
+            unlock.isGone = plugin.directBootAware //|| !DataStore.persistAcrossReboot
+        }
+
+        override fun onClick(v: View?) {
+            clicked = plugin
+            dialog.dismiss()
+        }
+
+        override fun onLongClick(v: View?) = try {
+            startActivity(Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, Uri.Builder()
+                    .scheme("package")
+                    .opaquePart(plugin.packageName)
+                    .build()))
+            true
+        } catch (_: ActivityNotFoundException) {
+            false
+        }
+    }
+    private inner class IconListAdapter(private val dialog: BottomSheetDialog) :
+            RecyclerView.Adapter<IconListViewHolder>() {
+        override fun getItemCount(): Int = preference.plugins.size
+        override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = IconListViewHolder(dialog,
+                LayoutInflater.from(parent.context).inflate(R.layout.icon_list_item_2, parent, false))
+        override fun onBindViewHolder(holder: IconListViewHolder, position: Int) {
+            if (selected < 0) holder.bind(preference.plugins[position]) else when (position) {
+                0 -> holder.bind(preference.selectedEntry!!, true)
+                in selected + 1..Int.MAX_VALUE -> holder.bind(preference.plugins[position])
+                else -> holder.bind(preference.plugins[position - 1])
+            }
+        }
+    }
+
+    fun setArg(key: String) {
+        arguments = bundleOf(ARG_KEY to key)
+    }
+
+    private val preference by lazy { getPreference() as PluginPreference }
+    private val selected by lazy { preference.plugins.indexOf(preference.selectedEntry) }
+    private var clicked: Plugin? = null
+
+    override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
+        val activity = requireActivity()
+        val dialog = BottomSheetDialog(activity, theme)
+        val recycler = RecyclerView(activity)
+        val padding = resources.getDimensionPixelOffset(R.dimen.bottom_sheet_padding)
+        recycler.setPadding(0, padding, 0, padding)
+        recycler.setHasFixedSize(true)
+        recycler.layoutManager = LinearLayoutManager(activity)
+        recycler.itemAnimator = DefaultItemAnimator()
+        recycler.adapter = IconListAdapter(dialog)
+        recycler.layoutParams = ViewGroup.LayoutParams(
+                ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)
+        dialog.setContentView(recycler)
+        return dialog
+    }
+
+    override fun onDialogClosed(positiveResult: Boolean) {
+        val clicked = clicked
+        if (clicked != null && clicked != preference.selectedEntry) {
+            setFragmentResult(javaClass.name, bundleOf(KEY_SELECTED_ID to clicked.id))
+        }
+    }
+}

+ 2 - 0
app/src/main/java/io/nekohasekai/sagernet/Constants.kt

@@ -22,6 +22,8 @@ object Key {
     const val SERVER_PASSWORD = "serverPassword"
     const val SERVER_UDP = "serverUdp"
     const val SERVER_METHOD = "serverMethod"
+    const val SERVER_PLUGIN = "serverPlugin"
+    const val SERVER_PLUGIN_CONFIGURE = "serverPluginConfigure"
 
 }
 

+ 7 - 3
app/src/main/java/io/nekohasekai/sagernet/bg/ProxyInstance.kt

@@ -33,8 +33,12 @@ class ProxyInstance(val profile: ProxyEntity) {
         base = service
         v2rayPoint = Libv2ray.newV2RayPoint(SagerSupportClass(if (service is VpnService)
             service else null), false)
-        v2rayPoint.domainName =
-            profile.requireBean().serverAddress + ":" + profile.requireBean().serverPort
+        if (profile.useExternalShadowsocks()) {
+            v2rayPoint.domainName = "127.0.0.1:${DataStore.socks5Port + 10}"
+        } else {
+            v2rayPoint.domainName =
+                profile.requireBean().serverAddress + ":" + profile.requireBean().serverPort
+        }
         config = buildV2rayConfig(profile, bind, DataStore.socks5Port)
         v2rayPoint.configureFileContent = gson.toJson(config).also {
             Logs.d(it)
@@ -64,7 +68,7 @@ class ProxyInstance(val profile: ProxyEntity) {
                 val pluginConfiguration = PluginConfiguration(bean.plugin ?: "")
                 PluginManager.init(pluginConfiguration)?.let { (path, opts, isV2) ->
                     proxyConfig["plugin"] = path
-                    proxyConfig["plugin_args"] = opts.toString()
+                    proxyConfig["plugin-opts"] = opts.toString()
                 }
             }
 

+ 3 - 0
app/src/main/java/io/nekohasekai/sagernet/bg/VpnService.kt

@@ -285,6 +285,9 @@ class VpnService : BaseVpnService(), BaseService.Interface {
 
          }
     */
+
+        builder.addDnsServer("1.1.1.1")
+
         builder.addDisallowedApplication("com.github.shadowsocks")
         builder.addDisallowedApplication(packageName)
 

+ 3 - 1
app/src/main/java/io/nekohasekai/sagernet/database/DataStore.kt

@@ -37,7 +37,7 @@ object DataStore {
     var meteredNetwork by configurationStore.boolean("metered_network")
     var proxyApps by configurationStore.int("proxyApps")
     var individual by configurationStore.string("individual")
-    var forceShadowsocksRust = true//by configurationStore.boolean("forceShadowsocksRust")
+    var forceShadowsocksRust = true// by configurationStore.boolean("forceShadowsocksRust")
 
     // cache
     var dirty by profileCacheStore.boolean(Key.PROFILE_DIRTY)
@@ -50,5 +50,7 @@ object DataStore {
     var serverPassword by profileCacheStore.string(Key.SERVER_PASSWORD)
     var serverUdp by profileCacheStore.boolean(Key.SERVER_UDP)
     var serverMethod by profileCacheStore.string(Key.SERVER_METHOD)
+    var serverPlugin by profileCacheStore.string(Key.SERVER_PLUGIN)
+    var serverPluginConfigure by profileCacheStore.string(Key.SERVER_PLUGIN_CONFIGURE)
 
 }

+ 5 - 1
app/src/main/java/io/nekohasekai/sagernet/database/ProxyEntity.kt

@@ -8,6 +8,7 @@ import io.nekohasekai.sagernet.fmt.shadowsocks.ShadowsocksBean
 import io.nekohasekai.sagernet.fmt.shadowsocks.methodsV2fly
 import io.nekohasekai.sagernet.fmt.socks.SOCKSBean
 import io.nekohasekai.sagernet.fmt.v2ray.VMessBean
+import io.nekohasekai.sagernet.ktx.Logs
 import io.nekohasekai.sagernet.ui.settings.ProfileSettingsActivity
 import io.nekohasekai.sagernet.ui.settings.ShadowsocksSettingsActivity
 import io.nekohasekai.sagernet.ui.settings.SocksSettingsActivity
@@ -57,7 +58,10 @@ class ProxyEntity(
     fun useExternalShadowsocks(): Boolean {
         if (type != "ss") return false
         val bean = requireSS()
-        if (bean.plugin.isNotBlank()) return true
+        if (bean.plugin.isNotBlank()) {
+            Logs.d("Requiring plugin ${bean.plugin}")
+            return true
+        }
         if (bean.method !in methodsV2fly) return true
         if (DataStore.forceShadowsocksRust) return true
         return false

+ 1 - 0
app/src/main/java/io/nekohasekai/sagernet/fmt/shadowsocks/ShadowsocksBean.java

@@ -16,6 +16,7 @@ public class ShadowsocksBean extends AbstractBean {
         serverPort = 1080;
         method = "aes-256-gcm";
         password = "";
+        plugin = "";
     }};
 
     public String method;

+ 19 - 1
app/src/main/java/io/nekohasekai/sagernet/ui/settings/ProfileSettingsActivity.kt

@@ -126,7 +126,7 @@ abstract class ProfileSettingsActivity<T : AbstractBean> : AppCompatActivity(),
 
     }
 
-    private val child by lazy { supportFragmentManager.findFragmentById(R.id.settings) as MyPreferenceFragmentCompat }
+    val child by lazy { supportFragmentManager.findFragmentById(R.id.settings) as MyPreferenceFragmentCompat }
 
     override fun onCreateOptionsMenu(menu: Menu?): Boolean {
         menuInflater.inflate(R.menu.profile_config_menu, menu)
@@ -161,6 +161,13 @@ abstract class ProfileSettingsActivity<T : AbstractBean> : AppCompatActivity(),
         rootKey: String?,
     )
 
+    open fun PreferenceFragmentCompat.viewCreated(view: View, savedInstanceState: Bundle?) {
+    }
+
+    open fun PreferenceFragmentCompat.displayPreferenceDialog(preference: Preference): Boolean {
+        return false
+    }
+
     class MyPreferenceFragmentCompat : PreferenceFragmentCompat() {
 
         lateinit var activity: ProfileSettingsActivity<*>
@@ -176,6 +183,10 @@ abstract class ProfileSettingsActivity<T : AbstractBean> : AppCompatActivity(),
             super.onViewCreated(view, savedInstanceState)
 
             ViewCompat.setOnApplyWindowInsetsListener(listView, ListListener)
+
+            activity.apply {
+                viewCreated(view, savedInstanceState)
+            }
         }
 
         override fun onOptionsItemSelected(item: MenuItem) = when (item.itemId) {
@@ -194,6 +205,13 @@ abstract class ProfileSettingsActivity<T : AbstractBean> : AppCompatActivity(),
             else -> false
         }
 
+        override fun onDisplayPreferenceDialog(preference: Preference) {
+            activity.apply {
+                if (displayPreferenceDialog(preference)) return
+            }
+            super.onDisplayPreferenceDialog(preference)
+        }
+
     }
 
     object PasswordSummaryProvider : Preference.SummaryProvider<EditTextPreference> {

+ 151 - 1
app/src/main/java/io/nekohasekai/sagernet/ui/settings/ShadowsocksSettingsActivity.kt

@@ -1,20 +1,51 @@
 package io.nekohasekai.sagernet.ui.settings
 
+import android.app.Activity
+import android.content.BroadcastReceiver
+import android.content.DialogInterface
 import android.os.Bundle
+import android.view.View
+import androidx.activity.result.component1
+import androidx.activity.result.component2
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.appcompat.app.AlertDialog
+import androidx.fragment.app.setFragmentResultListener
+import androidx.lifecycle.lifecycleScope
+import androidx.lifecycle.whenCreated
 import androidx.preference.EditTextPreference
+import androidx.preference.Preference
 import androidx.preference.PreferenceFragmentCompat
 import cn.hutool.core.util.NumberUtil
+import com.github.shadowsocks.plugin.*
+import com.github.shadowsocks.plugin.fragment.AlertDialogFragment
+import com.github.shadowsocks.plugin.fragment.showAllowingStateLoss
+import com.github.shadowsocks.preference.PluginConfigurationDialogFragment
+import com.github.shadowsocks.preference.PluginPreference
+import com.github.shadowsocks.preference.PluginPreferenceDialogFragment
+import com.google.android.material.snackbar.Snackbar
 import io.nekohasekai.sagernet.Key
 import io.nekohasekai.sagernet.R
 import io.nekohasekai.sagernet.database.DataStore
 import io.nekohasekai.sagernet.database.preference.EditTextPreferenceModifiers
 import io.nekohasekai.sagernet.fmt.shadowsocks.ShadowsocksBean
+import io.nekohasekai.sagernet.ktx.Empty
+import io.nekohasekai.sagernet.ktx.listenForPackageChanges
+import io.nekohasekai.sagernet.ktx.readableMessage
+import io.nekohasekai.sagernet.ktx.runOnDefaultDispatcher
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
 
-class ShadowsocksSettingsActivity : ProfileSettingsActivity<ShadowsocksBean>() {
+class ShadowsocksSettingsActivity : ProfileSettingsActivity<ShadowsocksBean>(),
+    Preference.OnPreferenceChangeListener {
 
     override val type = "ss"
     override fun createEntity() = ShadowsocksBean()
 
+    private lateinit var plugin: PluginPreference
+    private lateinit var pluginConfigure: EditTextPreference
+    private lateinit var pluginConfiguration: PluginConfiguration
+    private lateinit var receiver: BroadcastReceiver
+
     override fun init() {
         init(ShadowsocksBean.DEFAULT_BEAN)
     }
@@ -25,6 +56,7 @@ class ShadowsocksSettingsActivity : ProfileSettingsActivity<ShadowsocksBean>() {
         DataStore.serverPort = "${bean.serverPort}"
         DataStore.serverMethod = bean.method
         DataStore.serverPassword = bean.password
+        DataStore.serverPlugin = bean.plugin
     }
 
     override fun ShadowsocksBean.serialize() {
@@ -36,8 +68,21 @@ class ShadowsocksSettingsActivity : ProfileSettingsActivity<ShadowsocksBean>() {
                 ?: 1080
         method = DataStore.serverMethod
         password = DataStore.serverPassword
+        plugin = DataStore.serverPlugin
+
     }
 
+    override fun onAttachedToWindow() {
+        super.onAttachedToWindow()
+
+        receiver = listenForPackageChanges(false) {
+            lifecycleScope.launch(Dispatchers.Main) {   // wait until changes were flushed
+                whenCreated { initPlugins() }
+            }
+        }
+    }
+
+
     override fun PreferenceFragmentCompat.createPreferences(
         savedInstanceState: Bundle?,
         rootKey: String?,
@@ -49,6 +94,111 @@ class ShadowsocksSettingsActivity : ProfileSettingsActivity<ShadowsocksBean>() {
         findPreference<EditTextPreference>(Key.SERVER_PASSWORD)!!.apply {
             summaryProvider = PasswordSummaryProvider
         }
+
+        plugin = findPreference(Key.SERVER_PLUGIN)!!
+        pluginConfigure = findPreference(Key.SERVER_PLUGIN_CONFIGURE)!!
+        pluginConfigure.setOnBindEditTextListener(EditTextPreferenceModifiers.Monospace)
+        pluginConfigure.onPreferenceChangeListener = this@ShadowsocksSettingsActivity
+        pluginConfiguration = PluginConfiguration(DataStore.serverPlugin ?: "")
+        initPlugins()
+    }
+
+    override fun PreferenceFragmentCompat.viewCreated(view: View, savedInstanceState: Bundle?) {
+        setFragmentResultListener(PluginPreferenceDialogFragment::class.java.name) { _, bundle ->
+            val selected = plugin.plugins.lookup.getValue(
+                bundle.getString(PluginPreferenceDialogFragment.KEY_SELECTED_ID)!!)
+            val override = pluginConfiguration.pluginsOptions.keys.firstOrNull {
+                plugin.plugins.lookup[it] == selected
+            }
+            pluginConfiguration =
+                PluginConfiguration(pluginConfiguration.pluginsOptions, override ?: selected.id)
+            DataStore.serverPlugin = pluginConfiguration.toString()
+            DataStore.dirty = true
+            plugin.value = pluginConfiguration.selected
+            pluginConfigure.isEnabled = selected !is NoPlugin
+            pluginConfigure.text = pluginConfiguration.getOptions().toString()
+            if (!selected.trusted) {
+                Snackbar.make(requireView(), R.string.plugin_untrusted, Snackbar.LENGTH_LONG).show()
+            }
+        }
+        AlertDialogFragment.setResultListener<Empty>(this,
+            UnsavedChangesDialogFragment::class.java.simpleName) { which, _ ->
+            when (which) {
+                DialogInterface.BUTTON_POSITIVE -> {
+                    runOnDefaultDispatcher {
+                        saveAndExit()
+                    }
+                }
+                DialogInterface.BUTTON_NEGATIVE -> requireActivity().finish()
+            }
+        }
+    }
+
+    private fun initPlugins() {
+        plugin.value = pluginConfiguration.selected
+        plugin.init()
+        pluginConfigure.isEnabled = plugin.selectedEntry?.let { it is NoPlugin } == false
+        pluginConfigure.text = pluginConfiguration.getOptions().toString()
+    }
+
+    private fun showPluginEditor() {
+        PluginConfigurationDialogFragment().apply {
+            setArg(Key.SERVER_PLUGIN_CONFIGURE, pluginConfiguration.selected)
+            setTargetFragment(child, 0)
+        }.showAllowingStateLoss(supportFragmentManager, Key.SERVER_PLUGIN_CONFIGURE)
+    }
+
+    override fun onPreferenceChange(preference: Preference?, newValue: Any?): Boolean = try {
+        val selected = pluginConfiguration.selected
+        pluginConfiguration = PluginConfiguration(pluginConfiguration.pluginsOptions +
+                (pluginConfiguration.selected to PluginOptions(selected, newValue as? String?)),
+            selected)
+        DataStore.serverPlugin = pluginConfiguration.toString()
+        DataStore.dirty = true
+        true
+    } catch (exc: RuntimeException) {
+        Snackbar.make(child.requireView(), exc.readableMessage, Snackbar.LENGTH_LONG).show()
+        false
+    }
+
+    private val configurePlugin =
+        registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { (resultCode, data) ->
+            when (resultCode) {
+                Activity.RESULT_OK -> {
+                    val options = data?.getStringExtra(PluginContract.EXTRA_OPTIONS)
+                    pluginConfigure.text = options
+                    onPreferenceChange(null, options)
+                }
+                PluginContract.RESULT_FALLBACK -> showPluginEditor()
+            }
+        }
+
+    override fun PreferenceFragmentCompat.displayPreferenceDialog(preference: Preference): Boolean {
+        when (preference.key) {
+            Key.SERVER_PLUGIN -> PluginPreferenceDialogFragment().apply {
+                setArg(Key.SERVER_PLUGIN)
+                setTargetFragment(child, 0)
+            }.showAllowingStateLoss(supportFragmentManager, Key.SERVER_PLUGIN)
+            Key.SERVER_PLUGIN_CONFIGURE -> {
+                val intent = PluginManager.buildIntent(plugin.selectedEntry!!.id,
+                    PluginContract.ACTION_CONFIGURE)
+                if (intent.resolveActivity(packageManager) == null) showPluginEditor() else {
+                    configurePlugin.launch(intent
+                        .putExtra(PluginContract.EXTRA_OPTIONS,
+                            pluginConfiguration.getOptions().toString()))
+                }
+            }
+            else -> return false
+        }
+        return true
+    }
+
+    val pluginHelp = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
+            (resultCode, data) ->
+        if (resultCode == Activity.RESULT_OK) AlertDialog.Builder(this)
+            .setTitle("?")
+            .setMessage(data?.getCharSequenceExtra(PluginContract.EXTRA_HELP_MESSAGE))
+            .show()
     }
 
 }

+ 44 - 0
app/src/main/res/layout/icon_list_item_2.xml

@@ -0,0 +1,44 @@
+<?xml version="1.0" encoding="utf-8"?>
+<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
+              xmlns:tools="http://schemas.android.com/tools"
+              android:layout_width="match_parent"
+              android:layout_height="wrap_content"
+              android:background="?android:attr/selectableItemBackground"
+              android:paddingStart="12dp"
+              android:paddingEnd="12dp"
+              android:focusable="true">
+    <ImageView android:id="@android:id/icon"
+               android:layout_width="52dp"
+               android:layout_height="52dp"
+               android:importantForAccessibility="no"/>
+    <Space android:layout_width="4dp"
+           android:layout_height="wrap_content"/>
+    <LinearLayout android:orientation="vertical"
+                  android:layout_width="0dp"
+                  android:layout_height="wrap_content"
+                  android:layout_weight="1"
+                  android:layout_gravity="center_vertical">
+        <TextView android:id="@android:id/text1"
+                  android:textAppearance="?android:attr/textAppearance"
+                  android:layout_width="match_parent"
+                  android:layout_height="wrap_content"
+                  android:maxLines="2"
+                  android:ellipsize="end"/>
+        <TextView android:id="@android:id/text2"
+                  android:textAppearance="?android:attr/textAppearanceSmall"
+                  android:layout_width="match_parent"
+                  android:layout_height="wrap_content"
+                  android:maxLines="2"
+                  android:ellipsize="end"/>
+    </LinearLayout>
+    <ImageView
+        android:id="@+id/unlock"
+        android:layout_width="36dp"
+        android:layout_height="36dp"
+        android:layout_gravity="center"
+        android:paddingStart="12dp"
+        android:src="@drawable/ic_action_lock_open"
+        android:tint="@color/material_amber_a700"
+        android:importantForAccessibility="no"
+        tools:ignore="RtlSymmetry" />
+</LinearLayout>

+ 16 - 0
app/src/main/res/xml/shadowsocks_preferences.xml

@@ -32,5 +32,21 @@
             app:title="@string/password" />
     </PreferenceCategory>
 
+    <PreferenceCategory
+        app:title="@string/plugin">
+
+        <com.github.shadowsocks.preference.PluginPreference
+            app:key="serverPlugin"
+            app:persistent="false"
+            app:title="@string/plugin"
+            app:useSimpleSummaryProvider="true"/>
+        <EditTextPreference
+            app:key="serverPluginConfigure"
+            app:icon="@drawable/ic_action_settings"
+            app:persistent="false"
+            app:title="@string/plugin_configure"
+            app:useSimpleSummaryProvider="true"/>
+    </PreferenceCategory>
+
 
 </PreferenceScreen>