Unverified Commit 1eb08420 authored by Max Lv's avatar Max Lv Committed by GitHub

Merge pull request #2431 from shadowsocks/plugin-1.3.4

Plugin 1.3.4
parents d14a0732 4c3574f7
......@@ -52,10 +52,10 @@ androidExtensions {
def coroutinesVersion = '1.3.3'
def roomVersion = '2.2.3'
def workVersion = '2.3.0'
def workVersion = '2.3.1'
dependencies {
api project(':plugin')
api 'androidx.fragment:fragment-ktx:1.2.0'
api 'androidx.fragment:fragment-ktx:1.2.1'
api "androidx.lifecycle:lifecycle-common-java8:$lifecycleVersion"
api "androidx.lifecycle:lifecycle-livedata-core-ktx:$lifecycleVersion"
api 'androidx.preference:preference:1.1.0'
......
......@@ -242,7 +242,7 @@ object BaseService {
File(Core.deviceStorage.noBackupFilesDir, "stat_main"),
File(configRoot, CONFIG_FILE),
if (udpFallback == null) "-u" else null)
check(udpFallback?.pluginPath == null) { "UDP fallback cannot have plugins" }
check(udpFallback?.plugin == null) { "UDP fallback cannot have plugins" }
udpFallback?.start(this,
File(Core.deviceStorage.noBackupFilesDir, "stat_udp"),
File(configRoot, CONFIG_FILE_UDP),
......
......@@ -47,8 +47,7 @@ import java.security.MessageDigest
class ProxyInstance(val profile: Profile, private val route: String = profile.route) {
private var configFile: File? = null
var trafficMonitor: TrafficMonitor? = null
private val plugin = PluginConfiguration(profile.plugin ?: "").selectedOptions
val pluginPath by lazy { PluginManager.init(plugin) }
val plugin by lazy { PluginManager.init(PluginConfiguration(profile.plugin ?: "")) }
private var scheduleConfigUpdate = false
suspend fun init(service: BaseService.Interface, hosts: HostsFile) {
......@@ -115,7 +114,7 @@ class ProxyInstance(val profile: Profile, private val route: String = profile.ro
this.configFile = configFile
val config = profile.toJson()
if (pluginPath != null) config.put("plugin", pluginPath).put("plugin_opts", plugin.toString())
plugin?.let { (path, opts) -> config.put("plugin", path).put("plugin_opts", opts.toString()) }
configFile.writeText(config.toString())
val cmd = service.buildAdditionalArguments(arrayListOf(
......
......@@ -301,7 +301,7 @@ data class Profile(
.encodedAuthority("$auth@$wrappedHost:$remotePort")
val configuration = PluginConfiguration(plugin ?: "")
if (configuration.selected.isNotEmpty()) {
builder.appendQueryParameter(Key.plugin, configuration.selectedOptions.toString(false))
builder.appendQueryParameter(Key.plugin, configuration.getOptions().toString(false))
}
if (!name.isNullOrEmpty()) builder.fragment(name)
return builder.build()
......@@ -315,7 +315,7 @@ data class Profile(
put("password", password)
put("method", method)
if (profiles == null) return@apply
PluginConfiguration(plugin ?: "").selectedOptions.also {
PluginConfiguration(plugin ?: "").getOptions().also {
if (it.id.isNotEmpty()) {
put("plugin", it.id)
put("plugin_opts", it.toString())
......
......@@ -21,13 +21,11 @@
package com.github.shadowsocks.plugin
import android.content.pm.ResolveInfo
import android.os.Bundle
class NativePlugin(resolveInfo: ResolveInfo) : ResolvedPlugin(resolveInfo) {
init {
check(resolveInfo.providerInfo != null)
}
override val metaData: Bundle get() = resolveInfo.providerInfo.metaData
override val packageName: String get() = resolveInfo.providerInfo.packageName
override val componentInfo get() = resolveInfo.providerInfo!!
}
......@@ -24,9 +24,18 @@ 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
open val directBootAware: 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()
}
......@@ -50,14 +50,15 @@ class PluginConfiguration(val pluginsOptions: Map<String, PluginOptions>, val se
} else PluginOptions(line)
})
fun getOptions(id: String): PluginOptions = if (id.isEmpty()) PluginOptions() else
pluginsOptions[id] ?: PluginOptions(id, PluginManager.fetchPlugins()[id]?.defaultConfig)
val selectedOptions: PluginOptions get() = getOptions(selected)
fun getOptions(
id: String = selected,
defaultConfig: () -> String? = { PluginManager.fetchPlugins().lookup[id]?.defaultConfig }
) = if (id.isEmpty()) PluginOptions() else pluginsOptions[id] ?: PluginOptions(id, defaultConfig())
override fun toString(): String {
val result = LinkedList<PluginOptions>()
for ((id, opt) in pluginsOptions) if (id == this.selected) result.addFirst(opt) else result.addLast(opt)
if (!pluginsOptions.contains(selected)) result.addFirst(selectedOptions)
if (!pluginsOptions.contains(selected)) result.addFirst(getOptions())
return result.joinToString("\n") { it.toString(false) }
}
}
/*******************************************************************************
* *
* 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 = mutableMapOf<String, Plugin>().apply {
for (plugin in this@PluginList) {
fun check(old: Plugin?) = check(old == null || old === plugin)
check(put(plugin.id, plugin))
for (alias in plugin.idAliases) check(put(alias, plugin))
}
}
}
......@@ -24,6 +24,7 @@ import android.annotation.SuppressLint
import android.content.BroadcastReceiver
import android.content.ContentResolver
import android.content.Intent
import android.content.pm.ComponentInfo
import android.content.pm.PackageManager
import android.content.pm.ProviderInfo
import android.content.pm.Signature
......@@ -100,19 +101,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!!
}
......@@ -125,30 +122,34 @@ object PluginManager {
// the following parts are meant to be used by :bg
@Throws(Throwable::class)
fun init(options: PluginOptions): String? {
if (options.id.isEmpty()) return null
fun init(configuration: PluginConfiguration): Pair<String, PluginOptions>? {
if (configuration.selected.isEmpty()) return null
var throwable: Throwable? = null
try {
val path = initNative(options)
if (path != null) return path
val result = initNative(configuration)
if (result != null) return result
} catch (t: Throwable) {
if (throwable == null) throwable = t else printLog(t)
}
// add other plugin types here
throw throwable ?: PluginNotFoundException(options.id)
throw throwable ?: PluginNotFoundException(configuration.selected)
}
private fun initNative(options: PluginOptions): String? {
private fun initNative(configuration: PluginConfiguration): Pair<String, PluginOptions>? {
val providers = app.packageManager.queryIntentContentProviders(
Intent(PluginContract.ACTION_NATIVE_PLUGIN, buildUri(options.id)), PackageManager.GET_META_DATA)
Intent(PluginContract.ACTION_NATIVE_PLUGIN, buildUri(configuration.selected)),
PackageManager.GET_META_DATA or
PackageManager.MATCH_DIRECT_BOOT_UNAWARE or PackageManager.MATCH_DIRECT_BOOT_AWARE
)
if (providers.isEmpty()) return null
val provider = providers.single().providerInfo
val options = configuration.getOptions { provider.loadString(PluginContract.METADATA_KEY_DEFAULT_CONFIG) }
var failure: Throwable? = null
try {
initNativeFaster(provider)?.also { return it }
initNativeFaster(provider)?.also { return it to options }
} catch (t: Throwable) {
Crashlytics.log(Log.WARN, TAG, "Initializing native plugin faster mode failed")
failure = t
......@@ -158,9 +159,8 @@ object PluginManager {
scheme(ContentResolver.SCHEME_CONTENT)
authority(provider.authority)
}.build()
val cr = app.contentResolver
try {
return initNativeFast(cr, options, uri)
return initNativeFast(app.contentResolver, options, uri)?.let { it to options }
} catch (t: Throwable) {
Crashlytics.log(Log.WARN, TAG, "Initializing native plugin fast mode failed")
failure?.also { t.addSuppressed(it) }
......@@ -168,7 +168,7 @@ object PluginManager {
}
try {
return initNativeSlow(cr, options, uri)
return initNativeSlow(app.contentResolver, options, uri)?.let { it to options }
} catch (t: Throwable) {
failure?.also { t.addSuppressed(it) }
throw t
......@@ -176,7 +176,7 @@ object PluginManager {
}
private fun initNativeFaster(provider: ProviderInfo): String? {
return provider.metaData.getString(PluginContract.METADATA_KEY_EXECUTABLE_PATH)?.let { relativePath ->
return provider.loadString(PluginContract.METADATA_KEY_EXECUTABLE_PATH)?.let { relativePath ->
File(provider.applicationInfo.nativeLibraryDir).resolve(relativePath).apply {
check(canExecute())
}.absolutePath
......@@ -219,4 +219,11 @@ object PluginManager {
if (!initialized) entryNotFound()
return File(pluginDir, options.id).absolutePath
}
fun ComponentInfo.loadString(key: String) = when (val value = metaData.get(key)) {
is String -> value
is Int -> app.packageManager.getResourcesForApplication(applicationInfo).getString(value)
null -> null
else -> error("meta-data $key has invalid type ${value.javaClass}")
}
}
......@@ -20,22 +20,38 @@
package com.github.shadowsocks.plugin
import android.content.pm.ComponentInfo
import android.content.pm.ResolveInfo
import android.graphics.drawable.Drawable
import android.os.Bundle
import android.os.Build
import com.github.shadowsocks.Core
import com.github.shadowsocks.Core.app
import com.github.shadowsocks.plugin.PluginManager.loadString
import com.github.shadowsocks.utils.signaturesCompat
abstract class ResolvedPlugin(protected val resolveInfo: ResolveInfo) : Plugin() {
protected abstract val metaData: Bundle
protected abstract val componentInfo: ComponentInfo
override val id: String by lazy { metaData.getString(PluginContract.METADATA_KEY_ID)!! }
override val label: CharSequence by lazy { resolveInfo.loadLabel(app.packageManager) }
override val icon: Drawable by lazy { resolveInfo.loadIcon(app.packageManager) }
override val defaultConfig: String? by lazy { metaData.getString(PluginContract.METADATA_KEY_DEFAULT_CONFIG) }
override val packageName: String get() = resolveInfo.resolvePackageName
override val id by lazy { componentInfo.loadString(PluginContract.METADATA_KEY_ID)!! }
override val idAliases: Array<String> by lazy {
when (val value = componentInfo.metaData.get(PluginContract.METADATA_KEY_ID_ALIASES)) {
is String -> arrayOf(value)
is Int -> app.packageManager.getResourcesForApplication(componentInfo.applicationInfo).run {
when (getResourceTypeName(value)) {
"string" -> arrayOf(getString(value))
else -> getStringArray(value)
}
}
null -> emptyArray()
else -> error("unknown type for plugin meta-data idAliases")
}
}
override val label: CharSequence get() = resolveInfo.loadLabel(app.packageManager)
override val icon: Drawable get() = resolveInfo.loadIcon(app.packageManager)
override val defaultConfig by lazy { componentInfo.loadString(PluginContract.METADATA_KEY_DEFAULT_CONFIG) }
override val packageName: String get() = componentInfo.packageName
override val trusted by lazy {
Core.getPackageInfo(packageName).signaturesCompat.any(PluginManager.trustedSignatures::contains)
}
override val directBootAware get() = Build.VERSION.SDK_INT < 24 || componentInfo.directBootAware
}
......@@ -160,4 +160,5 @@
<string name="plugin_unknown">Unknown plugin %s</string>
<string name="plugin_untrusted">Warning: This plugin does not seem to come from a known trusted source.</string>
<string name="profile_plugin">Plugin: %s</string>
<string name="plugin_auto_connect_unlock_only">This plugin might not work with Auto Connect</string>
</resources>
......@@ -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,20 +103,9 @@ 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
pluginConfiguration = PluginConfiguration(DataStore.plugin)
initPlugins()
udpFallback = findPreference(Key.udpFallback)!!
DataStore.privateStore.registerChangeListener(this)
......@@ -139,16 +129,10 @@ 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()
pluginConfigure.isEnabled = pluginConfiguration.selected.isNotEmpty()
pluginConfigure.text = pluginConfiguration.selectedOptions.toString()
pluginConfigure.text = pluginConfiguration.getOptions().toString()
}
private fun showPluginEditor() {
......@@ -203,15 +187,16 @@ 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()),
.putExtra(PluginContract.EXTRA_OPTIONS, pluginConfiguration.getOptions().toString()),
REQUEST_CODE_PLUGIN_CONFIGURE)
}
}
......@@ -221,6 +206,20 @@ 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
plugin.value = pluginConfiguration.selected
pluginConfigure.isEnabled = selected !is NoPlugin
pluginConfigure.text = pluginConfiguration.getOptions().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,20 @@ 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) =
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 +50,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
......@@ -32,68 +33,76 @@ 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.preference.PreferenceDialogFragmentCompat
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)
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(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
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 = preference.entryValues[i].isNotEmpty() &&
preference.entries[i] != preference.entryValues[i]
icon.setImageDrawable(preference.entryIcons?.get(i))
index = i
text2.isVisible = plugin.id.isNotEmpty() && label != plugin.id
icon.setImageDrawable(plugin.icon)
unlock.isGone = plugin.directBootAware || !DataStore.persistAcrossReboot
}
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 +111,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 +133,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)
}
}
<vector android:autoMirrored="true" android:height="24dp"
android:tint="?attr/colorControlNormal"
android:viewportHeight="24.0" android:viewportWidth="24.0"
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="@android:color/white" android:pathData="M12,17c1.1,0 2,-0.9 2,-2s-0.9,-2 -2,-2 -2,0.9 -2,2 0.9,2 2,2zM18,8h-1L17,6c0,-2.76 -2.24,-5 -5,-5S7,3.24 7,6h1.9c0,-1.71 1.39,-3.1 3.1,-3.1 1.71,0 3.1,1.39 3.1,3.1v2L6,8c-1.1,0 -2,0.9 -2,2v10c0,1.1 0.9,2 2,2h12c1.1,0 2,-0.9 2,-2L20,10c0,-1.1 -0.9,-2 -2,-2zM18,20L6,20L6,10h12v10z"/>
</vector>
<?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"
......@@ -30,4 +31,14 @@
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>
......@@ -2,4 +2,5 @@
<resources>
<color name="background_selected">@color/material_primary_100</color>
<color name="background_stat">@color/material_primary_300</color>
<color name="material_amber_a700">#ffab00</color>
</resources>
......@@ -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.
* Added missing documentation regarding direct boot support.
Please add `android:directBootAware="true"` with proper support for your `provider` if possible.
* You can now use `android:resources` on `meta-data` tags. (main/host app update required, however, you should never use dynamic resources)
* Fix occasional crash in `AlertDialogFragment`.
* Translation updates.
* Dependency updates:
- `androidx.core:core-ktx:1.2.0`;
- `com.google.android.material:material:1.1.0`.
* 1.3.3:
* Fix a build script issue.
* 1.3.2:
......
......@@ -10,13 +10,12 @@ Support library for easier development on [shadowsocks
These are some plugins ready to use on shadowsocks-android.
* [simple-obfs](https://github.com/shadowsocks/simple-obfs-android/releases)
* [v2ray](https://github.com/shadowsocks/v2ray-plugin-android)
* [kcptun](https://github.com/shadowsocks/kcptun-android/releases)
* [simple-obfs](https://github.com/shadowsocks/simple-obfs-android/releases)
## Developer's guide
WARNING: This library is still in beta (0.x) and its content is subject to massive changes.
This library is designed with Java interoperability in mind so theoretically you can use this
library with other languages and/or build tools but there isn't documentation for that yet. This
guide is written for Scala + SBT. Contributions are welcome.
......@@ -32,11 +31,11 @@ There are no arbitrary restrictions/requirements on package name, component name
### Add dependency
First you need to add this library to your dependencies. This library is written mostly in Scala
and it's most convenient to use it with SBT:
First you need to add this library to your dependencies.
This library is written mostly in Kotlin but can also work with Java-only projects:
```scala
libraryDependencies += "com.github.shadowsocks" %% "plugin" % "0.0.4"
```gradle
implementation 'com.github.shadowsocks:plugin:$LATEST_VERSION'
```
### Native binary configuration
......@@ -44,7 +43,7 @@ libraryDependencies += "com.github.shadowsocks" %% "plugin" % "0.0.4"
First you need to get your native binary compiling on Android platform.
* [Sample project for C](https://github.com/shadowsocks/simple-obfs-android/tree/4f82c4a4e415d666e70a7e2e60955cb0d85c1615);
* [Sample project for Go](https://github.com/shadowsocks/kcptun-android/tree/41f42077e177618553417c16559784a51e9d8c4c).
* [Sample project for Go](https://github.com/shadowsocks/v2ray-plugin-android/tree/172bd4cec0276112828614482fb646b79dbf1540).
In addition to functionalities of a normal plugin, it has to support these additional flags that
may get passed through arguments:
......@@ -56,24 +55,22 @@ In addition to functionalities of a normal plugin, it has to support these addit
### Implement a binary provider
It's super easy. You just need to implement two or three methods. For example for `obfs-local`:
You just need to implement two or three methods. For example for `v2ray`:
```scala
final class BinaryProvider extends NativePluginProvider {
override protected def populateFiles(provider: PathProvider) {
provider.addPath("obfs-local", "755")
```kotlin
class BinaryProvider : NativePluginProvider() {
override fun populateFiles(provider: PathProvider) {
provider.addPath("v2ray", 0b111101101)
// add additional files here
}
// remove this method to disable fast mode, read more in the documentation
override def getExecutable: String =
getContext.getApplicationInfo.nativeLibraryDir + "/libobfs-local.so"
override fun getExecutable() = context!!.applicationInfo.nativeLibraryDir + "/libv2ray.so"
override def openFile(uri: Uri): ParcelFileDescriptor = uri.getPath match {
case "/obfs-local" =>
ParcelFileDescriptor.open(new File(getExecutable), ParcelFileDescriptor.MODE_READ_ONLY)
override fun openFile(uri: Uri): ParcelFileDescriptor = when (uri.path) {
"/v2ray" -> ParcelFileDescriptor.open(File(getExecutable()), ParcelFileDescriptor.MODE_READ_ONLY)
// handle additional files here
case _ => throw new FileNotFoundException()
else -> throw FileNotFoundException()
}
}
```
......@@ -87,6 +84,7 @@ Then add it to your manifest:
...
<provider android:name=".BinaryProvider"
android:exported="true"
android:directBootAware="true"
android:authorities="$FULLY_QUALIFIED_NAME_OF_YOUR_CONTENTPROVIDER">
<intent-filter>
<action android:name="com.github.shadowsocks.plugin.ACTION_NATIVE_PLUGIN"/>
......@@ -95,12 +93,16 @@ Then add it to your manifest:
<action android:name="com.github.shadowsocks.plugin.ACTION_NATIVE_PLUGIN"/>
<data android:scheme="plugin"
android:host="com.github.shadowsocks"
android:pathPrefix="/$PLUGIN_ID"/>
android:path="/$PLUGIN_ID"/>
</intent-filter>
<meta-data android:name="com.github.shadowsocks.plugin.id"
android:value="$PLUGIN_ID"/>
<!-- Optional: default is empty -->
<meta-data android:name="com.github.shadowsocks.plugin.default_config"
android:value="dummy=default;plugin=options"/>
<!-- Optional: remove to disable faster mode, read more in the documentation -->
<meta-data android:name="com.github.shadowsocks.plugin.executable_path"
android:value="$PATH_TO_EXECUTABLE_RELATIVE_TO_NATIVE_LIB_DIR"/>
</provider>
...
</application>
......
......@@ -46,9 +46,9 @@ mavenPublish {
}
dependencies {
api 'androidx.core:core-ktx:1.1.0'
api 'androidx.core:core-ktx:1.2.0'
api 'androidx.drawerlayout:drawerlayout:1.1.0-alpha03' // https://android-developers.googleblog.com/2019/07/android-q-beta-5-update.html
api 'com.google.android.material:material:1.1.0-rc02'
api 'com.google.android.material:material:1.1.0'
api "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlinVersion"
testImplementation "junit:junit:$junitVersion"
androidTestImplementation "androidx.test:runner:$androidTestVersion"
......
......@@ -125,14 +125,14 @@ In native mode, plugins are provided as native executables and `shadowsocks-libe
Every native mode plugin MUST have a content provider to provide the native executables (since they
can exceed 1M which is the limit of Intent size) that:
* MUST have `android:label` and `android:icon`; (may be configured by its parent `application`)
* MUST have `android:label` and `android:icon`; (may be inherited from parent `application`)
* SHOULD have `android:directBootAware="true"` with proper support if possible;
* MUST have an intent filter with action `com.github.shadowsocks.plugin.ACTION_NATIVE_PLUGIN`;
(used for discovering plugins)
* MUST have meta-data `com.github.shadowsocks.plugin.id` with string value `$PLUGIN_ID`;
* MUST have meta-data `com.github.shadowsocks.plugin.id` with string value `$PLUGIN_ID` or a string resource;
* MUST have an intent filter with action `com.github.shadowsocks.plugin.ACTION_NATIVE_PLUGIN` and
data `plugin://com.github.shadowsocks/$PLUGIN_ID`; (used for configuring plugin)
* CAN have meta-data `com.github.shadowsocks.plugin.default_config` with string value, default is
empty;
* CAN have meta-data `com.github.shadowsocks.plugin.default_config` with string value or a string resource, default is empty;
* MUST implement `query` that returns the file list which MUST include `$PLUGIN_ID` when having
these as arguments:
- `uri = "content://$authority_of_your_provider`;
......@@ -155,6 +155,7 @@ This corresponds to `com.github.shadowsocks.plugin.NativePluginProvider` in the
...
<provider android:name=".BinaryProvider"
android:exported="true"
android:directBootAware="true"
android:authorities="$FULLY_QUALIFIED_NAME_OF_YOUR_CONTENTPROVIDER"
tools:ignore="ExportedContentProvider">
<intent-filter>
......@@ -164,7 +165,7 @@ This corresponds to `com.github.shadowsocks.plugin.NativePluginProvider` in the
<action android:name="com.github.shadowsocks.plugin.ACTION_NATIVE_PLUGIN"/>
<data android:scheme="plugin"
android:host="com.github.shadowsocks"
android:pathPrefix="/$PLUGIN_ID"/>
android:path="/$PLUGIN_ID"/>
</intent-filter>
<meta-data android:name="com.github.shadowsocks.plugin.id"
android:value="$PLUGIN_ID"/>
......@@ -202,6 +203,7 @@ This allows the host app to launch your plugin without ever launching your app.
## JVM mode
This feature hasn't been implemented yet.
Please open an issue if you need this.
# Plugin security
......@@ -241,6 +243,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.
GROUP=com.github.shadowsocks
VERSION_NAME=1.3.3
VERSION_CODE=13
VERSION_NAME=1.3.4
VERSION_CODE=14
POM_ARTIFACT_ID=plugin
POM_NAME=Shadowsocks Plugin
......
......@@ -3,6 +3,6 @@
<application
android:theme="@style/Theme.Shadowsocks">
<meta-data android:name="com.github.shadowsocks.plugin.version"
android:value="1.3.2"/>
android:value="1.3.4"/>
</application>
</manifest>
......@@ -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