Commit 14e19656 authored by Mygod's avatar Mygod

Add preliminary support for plugin aliases

parent 14bdafd0
......@@ -24,9 +24,17 @@ import android.graphics.drawable.Drawable
abstract class Plugin {
abstract val id: String
open val idAliases get() = emptyArray<String>()
abstract val label: CharSequence
open val icon: Drawable? get() = null
open val defaultConfig: String? get() = null
open val packageName: String get() = ""
open val trusted: Boolean get() = true
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
return id == (other as Plugin).id
}
override fun hashCode() = id.hashCode()
}
......@@ -51,7 +51,7 @@ class PluginConfiguration(val pluginsOptions: Map<String, PluginOptions>, val se
})
fun getOptions(id: String): PluginOptions = if (id.isEmpty()) PluginOptions() else
pluginsOptions[id] ?: PluginOptions(id, PluginManager.fetchPlugins()[id]?.defaultConfig)
pluginsOptions[id] ?: PluginOptions(id, PluginManager.fetchPlugins().lookup[id]?.defaultConfig)
val selectedOptions: PluginOptions get() = getOptions(selected)
override fun toString(): String {
......
/*******************************************************************************
* *
* Copyright (C) 2020 by Max Lv <max.c.lv@gmail.com> *
* Copyright (C) 2020 by Mygod Studio <contact-shadowsocks-android@mygod.be> *
* *
* 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.plugin
import android.content.Intent
import android.content.pm.PackageManager
import com.github.shadowsocks.Core.app
class PluginList : ArrayList<Plugin>() {
init {
add(NoPlugin)
addAll(app.packageManager.queryIntentContentProviders(
Intent(PluginContract.ACTION_NATIVE_PLUGIN), PackageManager.GET_META_DATA).map { NativePlugin(it) })
}
val lookup = flatMap { plugin ->
sequence {
yield(plugin.id to plugin)
for (alias in plugin.idAliases) yield(alias to plugin)
}.asIterable()
}.associate { it }
}
......@@ -102,19 +102,15 @@ object PluginManager {
}
private var receiver: BroadcastReceiver? = null
private var cachedPlugins: Map<String, Plugin>? = null
fun fetchPlugins(): Map<String, Plugin> = synchronized(this) {
private var cachedPlugins: PluginList? = null
fun fetchPlugins() = synchronized(this) {
if (receiver == null) receiver = app.listenForPackageChanges {
synchronized(this) {
receiver = null
cachedPlugins = null
}
}
if (cachedPlugins == null) {
val pm = app.packageManager
cachedPlugins = (pm.queryIntentContentProviders(Intent(PluginContract.ACTION_NATIVE_PLUGIN),
PackageManager.GET_META_DATA).map { NativePlugin(it) } + NoPlugin).associateBy { it.id }
}
if (cachedPlugins == null) cachedPlugins = PluginList()
cachedPlugins!!
}
......
......@@ -33,6 +33,17 @@ abstract class ResolvedPlugin(protected val resolveInfo: ResolveInfo) : Plugin()
private val resources by lazy { app.packageManager.getResourcesForApplication(componentInfo.applicationInfo) }
override val id by lazy { componentInfo.metaData.loadString(PluginContract.METADATA_KEY_ID) { resources }!! }
override val idAliases: Array<String> by lazy {
when (val value = componentInfo.metaData.get(PluginContract.METADATA_KEY_ID_ALIASES)) {
is String -> arrayOf(value)
is Int -> when (resources.getResourceTypeName(value)) {
"string" -> arrayOf(resources.getString(value))
else -> resources.getStringArray(value)
}
null -> emptyArray()
else -> error("unknown type for plugin meta-data idAliases")
}
}
override val label: CharSequence by lazy { resolveInfo.loadLabel(app.packageManager) }
override val icon: Drawable by lazy { resolveInfo.loadIcon(app.packageManager) }
override val defaultConfig by lazy {
......
......@@ -52,6 +52,7 @@ class ProfileConfigFragment : PreferenceFragmentCompat(),
private const val REQUEST_CODE_PLUGIN_CONFIGURE = 1
const val REQUEST_UNSAVED_CHANGES = 2
private const val REQUEST_PICK_PLUGIN = 3
}
@Parcelize
......@@ -69,7 +70,7 @@ class ProfileConfigFragment : PreferenceFragmentCompat(),
private var profileId = -1L
private lateinit var isProxyApps: SwitchPreference
private lateinit var plugin: IconListPreference
private lateinit var plugin: PluginPreference
private lateinit var pluginConfigure: EditTextPreference
private lateinit var pluginConfiguration: PluginConfiguration
private lateinit var receiver: BroadcastReceiver
......@@ -102,18 +103,6 @@ class ProfileConfigFragment : PreferenceFragmentCompat(),
findPreference<Preference>(Key.udpdns)!!.isEnabled = serviceMode != Key.modeProxy
plugin = findPreference(Key.plugin)!!
pluginConfigure = findPreference(Key.pluginConfigure)!!
plugin.unknownValueSummary = getString(R.string.plugin_unknown)
plugin.setOnPreferenceChangeListener { _, newValue ->
pluginConfiguration = PluginConfiguration(pluginConfiguration.pluginsOptions, newValue as String)
DataStore.plugin = pluginConfiguration.toString()
DataStore.dirty = true
pluginConfigure.isEnabled = newValue.isNotEmpty()
pluginConfigure.text = pluginConfiguration.selectedOptions.toString()
if (PluginManager.fetchPlugins()[newValue]?.trusted == false) {
Snackbar.make(view!!, R.string.plugin_untrusted, Snackbar.LENGTH_LONG).show()
}
true
}
pluginConfigure.setOnBindEditTextListener(EditTextPreferenceModifiers.Monospace)
pluginConfigure.onPreferenceChangeListener = this
initPlugins()
......@@ -139,11 +128,6 @@ class ProfileConfigFragment : PreferenceFragmentCompat(),
}
private fun initPlugins() {
val plugins = PluginManager.fetchPlugins()
plugin.entries = plugins.map { it.value.label }.toTypedArray()
plugin.entryValues = plugins.map { it.value.id }.toTypedArray()
plugin.entryIcons = plugins.map { it.value.icon }.toTypedArray()
plugin.entryPackageNames = plugins.map { it.value.packageName }.toTypedArray()
pluginConfiguration = PluginConfiguration(DataStore.plugin)
plugin.value = pluginConfiguration.selected
plugin.init()
......@@ -203,12 +187,13 @@ class ProfileConfigFragment : PreferenceFragmentCompat(),
override fun onDisplayPreferenceDialog(preference: Preference) {
when (preference.key) {
Key.plugin -> BottomSheetPreferenceDialogFragment().apply {
Key.plugin -> PluginPreferenceDialogFragment().apply {
setArg(Key.plugin)
setTargetFragment(this@ProfileConfigFragment, 0)
setTargetFragment(this@ProfileConfigFragment, REQUEST_PICK_PLUGIN)
}.showAllowingStateLoss(parentFragmentManager, Key.plugin)
Key.pluginConfigure -> {
val intent = PluginManager.buildIntent(pluginConfiguration.selected, PluginContract.ACTION_CONFIGURE)
val intent = PluginManager.buildIntent(plugin.plugins.lookup.getValue(pluginConfiguration.selected).id,
PluginContract.ACTION_CONFIGURE)
if (intent.resolveActivity(requireContext().packageManager) == null) showPluginEditor() else {
startActivityForResult(intent
.putExtra(PluginContract.EXTRA_OPTIONS, pluginConfiguration.selectedOptions.toString()),
......@@ -221,6 +206,19 @@ class ProfileConfigFragment : PreferenceFragmentCompat(),
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
when (requestCode) {
REQUEST_PICK_PLUGIN -> if (resultCode == Activity.RESULT_OK) {
val selected = plugin.plugins.lookup.getValue(
data?.getStringExtra(PluginPreferenceDialogFragment.KEY_SELECTED_ID)!!)
val override = pluginConfiguration.pluginsOptions.keys.firstOrNull {
plugin.plugins.lookup[it] == selected
}
pluginConfiguration = PluginConfiguration(pluginConfiguration.pluginsOptions, override ?: selected.id)
DataStore.plugin = pluginConfiguration.toString()
DataStore.dirty = true
pluginConfigure.isEnabled = selected !is NoPlugin
pluginConfigure.text = pluginConfiguration.selectedOptions.toString()
if (!selected.trusted) Snackbar.make(view!!, R.string.plugin_untrusted, Snackbar.LENGTH_LONG).show()
}
REQUEST_CODE_PLUGIN_CONFIGURE -> when (resultCode) {
Activity.RESULT_OK -> {
val options = data?.getStringExtra(PluginContract.EXTRA_OPTIONS)
......
......@@ -24,27 +24,21 @@ import android.content.Context
import android.graphics.drawable.Drawable
import android.util.AttributeSet
import androidx.preference.ListPreference
import com.github.shadowsocks.R
import com.github.shadowsocks.plugin.PluginList
import com.github.shadowsocks.plugin.PluginManager
class IconListPreference(context: Context, attrs: AttributeSet? = null) : ListPreference(context, attrs) {
companion object FallbackProvider : SummaryProvider<IconListPreference> {
override fun provideSummary(preference: IconListPreference?): CharSequence? {
val i = preference?.selectedEntry
return if (i != null && i < 0) preference.unknownValueSummary?.format(preference.value) else
preference?.entry
class PluginPreference(context: Context, attrs: AttributeSet? = null) : ListPreference(context, attrs) {
companion object FallbackProvider : SummaryProvider<PluginPreference> {
override fun provideSummary(preference: PluginPreference): CharSequence? {
return preference.selectedEntry?.label ?: preference.unknownValueSummary.format(preference.value)
}
}
var entryIcons: Array<Drawable?>? = null
val selectedEntry: Int get() = entryValues.indexOf(value)
val entryIcon: Drawable? get() = entryIcons?.getOrNull(selectedEntry)
// fun setEntryIcons(@ArrayRes entryIconsResId: Int) {
// val array = getContext().getResources().obtainTypedArray(entryIconsResId)
// entryIcons = Array(array.length(), { i -> array.getDrawable(i) })
// array.recycle()
// }
var unknownValueSummary: String? = null
var entryPackageNames: Array<String>? = null
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
......@@ -57,17 +51,15 @@ class IconListPreference(context: Context, attrs: AttributeSet? = null) : ListPr
val listener = listener
if (listener == null || listener.onPreferenceChange(preference, newValue)) {
value = newValue.toString()
if (entryIcons != null) icon = entryIcon
icon = entryIcon
true
} else false
}
// val a = context.obtainStyledAttributes(attrs, R.styleable.IconListPreference)
// val entryIconsResId: Int = a.getResourceId(R.styleable.IconListPreference_entryIcons, -1)
// if (entryIconsResId != -1) entryIcons = entryIconsResId
// a.recycle()
}
fun init() {
plugins = PluginManager.fetchPlugins()
entryValues = plugins.lookup.map { it.key }.toTypedArray()
icon = entryIcon
summaryProvider = FallbackProvider
}
......
......@@ -20,6 +20,7 @@
package com.github.shadowsocks.preference
import android.app.Activity
import android.app.Dialog
import android.content.ActivityNotFoundException
import android.content.Intent
......@@ -39,12 +40,17 @@ import androidx.recyclerview.widget.DefaultItemAnimator
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.github.shadowsocks.R
import com.github.shadowsocks.plugin.Plugin
import com.google.android.material.bottomsheet.BottomSheetDialog
class BottomSheetPreferenceDialogFragment : PreferenceDialogFragmentCompat() {
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 var index = 0
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)
......@@ -54,46 +60,42 @@ class BottomSheetPreferenceDialogFragment : PreferenceDialogFragmentCompat() {
view.setOnLongClickListener(this)
}
fun bind(i: Int, selected: Boolean = false) {
text1.text = preference.entries[i]
text2.text = preference.entryValues[i]
fun bind(plugin: Plugin, selected: Boolean = false) {
this.plugin = plugin
text1.text = plugin.label
text2.text = plugin.packageName
val typeface = if (selected) Typeface.BOLD else Typeface.NORMAL
text1.setTypeface(null, typeface)
text2.setTypeface(null, typeface)
text2.isVisible = preference.entryValues[i].isNotEmpty() &&
preference.entries[i] != preference.entryValues[i]
icon.setImageDrawable(preference.entryIcons?.get(i))
index = i
text2.isVisible = plugin.packageName.isNotEmpty() && plugin.label != plugin.packageName
icon.setImageDrawable(plugin.icon)
}
override fun onClick(p0: View?) {
clickedIndex = index
override fun onClick(v: View?) {
clicked = plugin
dialog.dismiss()
}
override fun onLongClick(p0: View?): Boolean {
val pn = preference.entryPackageNames?.get(index) ?: return false
return try {
override fun onLongClick(v: View?) = try {
startActivity(Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, Uri.Builder()
.scheme("package")
.opaquePart(pn)
.opaquePart(plugin.packageName)
.build()))
true
} catch (_: ActivityNotFoundException) {
false
}
}
}
private inner class IconListAdapter(private val dialog: BottomSheetDialog) :
RecyclerView.Adapter<IconListViewHolder>() {
override fun getItemCount(): Int = preference.entries.size
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 (preference.selectedEntry < 0) holder.bind(position) else when (position) {
0 -> holder.bind(preference.selectedEntry, true)
in preference.selectedEntry + 1..Int.MAX_VALUE -> holder.bind(position)
else -> holder.bind(position - 1)
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])
}
}
}
......@@ -102,8 +104,9 @@ class BottomSheetPreferenceDialogFragment : PreferenceDialogFragmentCompat() {
arguments = bundleOf(ARG_KEY to key)
}
private val preference by lazy { getPreference() as IconListPreference }
private var clickedIndex = -1
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()
......@@ -123,9 +126,10 @@ class BottomSheetPreferenceDialogFragment : PreferenceDialogFragmentCompat() {
}
override fun onDialogClosed(positiveResult: Boolean) {
if (clickedIndex >= 0 && clickedIndex != preference.selectedEntry) {
val value = preference.entryValues[clickedIndex].toString()
if (preference.callChangeListener(value)) preference.value = value
}
val clicked = clicked
if (clicked != null && clicked != preference.selectedEntry) {
targetFragment!!.onActivityResult(targetRequestCode, Activity.RESULT_OK,
Intent().putExtra(KEY_SELECTED_ID, clicked.id))
} else targetFragment!!.onActivityResult(targetRequestCode, Activity.RESULT_CANCELED, null)
}
}
......@@ -77,7 +77,7 @@
<PreferenceCategory
app:title="@string/plugin">
<com.github.shadowsocks.preference.IconListPreference
<com.github.shadowsocks.preference.PluginPreference
app:key="plugin"
app:persistent="false"
app:title="@string/plugin"
......
* 1.3.4:
* Optional new metadata `com.github.shadowsocks.plugin.id.aliases` for plugin ID aliases;
(see doc for `PluginContract.METADATA_KEY_ID_ALIASES` and main documentation "Plugin ID Aliasing" for more information)
* Please use `android:path` instead of `android:pathPrefix`, sample code in documentations have been updated to reflect this recommendation.
* Fix occasional crash in `AlertDialogFragment`.
* Translation updates.
......
......@@ -242,6 +242,49 @@ Plugin app must include this in their application tag: (which should be automati
android:value="1.0.0"/>
```
# Plugin ID Aliasing
To implement plugin ID aliasing, you:
* MUST define meta-data `com.github.shadowsocks.plugin.id.aliases` in your plugin content provider with `android:value="alias"`,
or use `android:resources` to specify a string resource or string array resource for multiple aliases.
* MUST be able to be matched by `com.github.shadowsocks.plugin.ACTION_NATIVE_PLUGIN` when invoked on alias.
To do this, you SHOULD use multiple `intent-filter` and use a different `android:path` for each alias.
Alternatively, you MAY also use a single `intent-filter` and use `android:pathPattern` to match all your aliases at once.
You MUST NOT use `android:pathPrefix` or allow `android:pathPattern` to match undeclared plugin ID/alias as it might create a conflict with other plugins.
* SHOULD NOT add or change `intent-filter` for activities to include your aliases -- your plugin ID will always be used.
For example:
```xml
<manifest>
...
<application>
...
<provider>
...
<intent-filter>
<action android:name="com.github.shadowsocks.plugin.ACTION_NATIVE_PLUGIN"/>
<data android:scheme="plugin"
android:host="com.github.shadowsocks"
android:path="/$PLUGIN_ID"/>
</intent-filter>
<intent-filter>
<action android:name="com.github.shadowsocks.plugin.ACTION_NATIVE_PLUGIN"/>
<data android:scheme="plugin"
android:host="com.github.shadowsocks"
android:path="/$PLUGIN_ALIAS"/>
</intent-filter>
<meta-data android:name="com.github.shadowsocks.plugin.id"
android:value="$PLUGIN_ID"/>
<meta-data android:name="com.github.shadowsocks.plugin.aliases"
android:value="$PLUGIN_ALIAS"/>
...
</provider>
...
</application>
</manifest>
```
# Android TV
Android TV client does not invoke configuration activities. Therefore your plugins should automatically work with them.
......@@ -78,6 +78,13 @@ object PluginContract {
* Constant Value: "com.github.shadowsocks.plugin.id"
*/
const val METADATA_KEY_ID = "com.github.shadowsocks.plugin.id"
/**
* The metadata key to retrieve plugin id aliases.
* Can be a string (representing one alias) or a resource to a string or string array.
*
* Constant Value: "com.github.shadowsocks.plugin.id.aliases"
*/
const val METADATA_KEY_ID_ALIASES = "com.github.shadowsocks.plugin.id.aliases"
/**
* The metadata key to retrieve default configuration. Default value is empty.
*
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment