feat: 插件系统(BETA) & 查询发言插件

This commit is contained in:
HuanChengFly 2021-08-11 17:10:16 +08:00
parent 0e48bfe809
commit c306846e7e
12 changed files with 352 additions and 17 deletions

View File

@ -252,3 +252,5 @@
-keep class androidx.recyclerview.widget.RecyclerView$LayoutParams { *; } -keep class androidx.recyclerview.widget.RecyclerView$LayoutParams { *; }
-keep class androidx.recyclerview.widget.RecyclerView$ViewHolder { *; } -keep class androidx.recyclerview.widget.RecyclerView$ViewHolder { *; }
-keep class androidx.recyclerview.widget.RecyclerView$LayoutManager { *; } -keep class androidx.recyclerview.widget.RecyclerView$LayoutManager { *; }
-keep class com.huanchengfly.tieba.post.plugins.** { *; }

View File

@ -0,0 +1,9 @@
[
{
"id": "CommentLookup",
"name": "发言查询",
"desc": "使用第三方工具箱查询用户过往发言",
"version": "1.0",
"mainClass": "com.huanchengfly.tieba.post.plugins.PluginCommentLookup"
}
]

View File

@ -19,6 +19,8 @@ import android.widget.TextView
import androidx.appcompat.app.AppCompatDelegate import androidx.appcompat.app.AppCompatDelegate
import com.flurry.android.FlurryAgent import com.flurry.android.FlurryAgent
import com.huanchengfly.tieba.post.api.interfaces.CommonCallback import com.huanchengfly.tieba.post.api.interfaces.CommonCallback
import com.huanchengfly.tieba.post.plugins.PluginManager
import com.huanchengfly.tieba.post.plugins.interfaces.IApp
import com.huanchengfly.tieba.post.ui.theme.interfaces.ThemeSwitcher import com.huanchengfly.tieba.post.ui.theme.interfaces.ThemeSwitcher
import com.huanchengfly.tieba.post.ui.theme.utils.ThemeUtils import com.huanchengfly.tieba.post.ui.theme.utils.ThemeUtils
import com.huanchengfly.tieba.post.utils.* import com.huanchengfly.tieba.post.utils.*
@ -32,13 +34,14 @@ import org.litepal.LitePal
import java.util.* import java.util.*
import java.util.regex.Pattern import java.util.regex.Pattern
class BaseApplication : Application() { class BaseApplication : Application(), IApp {
private val mActivityList: MutableList<Activity> = mutableListOf() private val mActivityList: MutableList<Activity> = mutableListOf()
override fun onCreate() { override fun onCreate() {
instance = this instance = this
super.onCreate() super.onCreate()
ThemeUtils.init(ThemeDelegate) ThemeUtils.init(ThemeDelegate)
PluginManager.init(this)
AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM) AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM)
LitePal.initialize(this) LitePal.initialize(this)
FlurryAgent.Builder() FlurryAgent.Builder()
@ -441,13 +444,33 @@ class BaseApplication : Application() {
R.color.default_color_shadow -> return getColorByAttr(context, R.attr.shadow_color) R.color.default_color_shadow -> return getColorByAttr(context, R.attr.shadow_color)
R.color.default_color_unselected -> return getColorByAttr(context, R.attr.colorUnselected) R.color.default_color_unselected -> return getColorByAttr(context, R.attr.colorUnselected)
R.color.default_color_text -> return getColorByAttr(context, R.attr.colorText) R.color.default_color_text -> return getColorByAttr(context, R.attr.colorText)
R.color.default_color_text_on_primary -> return getColorByAttr(context, R.attr.colorTextOnPrimary) R.color.default_color_text_on_primary -> return getColorByAttr(
R.color.default_color_text_secondary -> return getColorByAttr(context, R.attr.colorTextSecondary) context,
R.color.default_color_text_disabled -> return getColorByAttr(context, R.attr.color_text_disabled) R.attr.colorTextOnPrimary
)
R.color.default_color_text_secondary -> return getColorByAttr(
context,
R.attr.colorTextSecondary
)
R.color.default_color_text_disabled -> return getColorByAttr(
context,
R.attr.color_text_disabled
)
R.color.default_color_divider -> return getColorByAttr(context, R.attr.colorDivider) R.color.default_color_divider -> return getColorByAttr(context, R.attr.colorDivider)
R.color.default_color_swipe_refresh_view_background -> return getColorByAttr(context, R.attr.color_swipe_refresh_layout_background) R.color.default_color_swipe_refresh_view_background -> return getColorByAttr(
context,
R.attr.color_swipe_refresh_layout_background
)
} }
return context.getColorCompat(colorId) return context.getColorCompat(colorId)
} }
} }
override fun getAppContext(): Context {
return this
}
override fun launchUrl(url: String) {
launchUrl(this, url)
}
} }

View File

@ -11,6 +11,7 @@ import androidx.annotation.NonNull;
import com.huanchengfly.tieba.post.R; import com.huanchengfly.tieba.post.R;
import com.huanchengfly.tieba.post.ui.theme.utils.ThemeUtils; import com.huanchengfly.tieba.post.ui.theme.utils.ThemeUtils;
import com.huanchengfly.tieba.post.utils.NavigationHelper; import com.huanchengfly.tieba.post.utils.NavigationHelper;
import com.huanchengfly.tieba.post.utils.UtilsKt;
public class MyURLSpan extends ClickableSpan { public class MyURLSpan extends ClickableSpan {
public String url; public String url;
@ -34,6 +35,6 @@ public class MyURLSpan extends ClickableSpan {
@Override @Override
public void onClick(@NonNull View view) { public void onClick(@NonNull View view) {
navigationHelper.navigationByData(NavigationHelper.ACTION_URL, this.url); UtilsKt.launchUrl(context, url);
} }
} }

View File

@ -0,0 +1,43 @@
package com.huanchengfly.tieba.post.plugins
import android.content.Context
import com.huanchengfly.tieba.post.plugins.PluginMenuItem.ClickCallback
import com.huanchengfly.tieba.post.plugins.interfaces.IApp
import com.huanchengfly.tieba.post.plugins.models.PluginManifest
abstract class IPlugin(
val app: IApp,
val manifest: PluginManifest
) {
val context: Context
get() = app.getAppContext()
open fun onCreate() {}
open fun onEnable() {}
open fun onDisable() {}
open fun onDestroy() {}
}
inline fun <reified Data> IPlugin.registerMenuItem(
id: String,
title: String,
callback: ClickCallback<Data>? = null
) {
val menu = getMenuByData(Data::class)
PluginManager.registerMenuItem(this, PluginMenuItem(id, menu, title, callback))
}
inline fun <reified Data> IPlugin.registerMenuItem(
id: String,
title: String,
crossinline callback: (Data) -> Unit
) {
registerMenuItem(id, title, object : ClickCallback<Data> {
override fun onClick(data: Data) {
callback.invoke(data)
}
})
}

View File

@ -0,0 +1,18 @@
package com.huanchengfly.tieba.post.plugins
import com.huanchengfly.tieba.post.R
import com.huanchengfly.tieba.post.api.models.ProfileBean
import com.huanchengfly.tieba.post.plugins.interfaces.IApp
import com.huanchengfly.tieba.post.plugins.models.PluginManifest
class PluginCommentLookup(app: IApp, manifest: PluginManifest) : IPlugin(app, manifest) {
override fun onEnable() {
super.onEnable()
registerMenuItem<ProfileBean>(
"lookup_comment",
context.getString(R.string.plugin_comment_lookup_menu)
) {
app.launchUrl("https://www.82cat.com/tieba/reply/${it.user?.name}/1")
}
}
}

View File

@ -0,0 +1,168 @@
package com.huanchengfly.tieba.post.plugins
import android.content.Context
import android.content.SharedPreferences
import com.huanchengfly.tieba.post.api.models.ProfileBean
import com.huanchengfly.tieba.post.fromJson
import com.huanchengfly.tieba.post.plugins.interfaces.IApp
import com.huanchengfly.tieba.post.plugins.models.PluginManifest
import com.huanchengfly.tieba.post.utils.AssetUtil
import com.huanchengfly.tieba.post.utils.SharedPreferencesUtil
import com.huanchengfly.tieba.post.utils.SharedPreferencesUtil.SP_PLUGINS
import kotlin.reflect.KClass
object PluginManager {
const val MENU_USER_ACTIVITY = "user_activity"
const val MENU_NONE = "none"
lateinit var appInstance: IApp
val pluginManifests: MutableList<PluginManifest> = mutableListOf()
val pluginInstances: MutableList<IPlugin> = mutableListOf()
val registeredPluginMenuItems: MutableMap<String, MutableMap<String, PluginMenuItem<*>>> =
mutableMapOf()
val context: Context
get() = appInstance.getAppContext()
val preferences: SharedPreferences
get() = SharedPreferencesUtil.get(SP_PLUGINS)
init {
listOf(
MENU_USER_ACTIVITY,
MENU_NONE
).forEach {
registeredPluginMenuItems[it] = mutableMapOf()
}
}
fun <Data> registerMenuItem(pluginInstance: IPlugin, menuItem: PluginMenuItem<Data>) {
registeredPluginMenuItems[menuItem.menuId]!!["${pluginInstance.manifest.id}_${menuItem.id}"] =
menuItem
}
fun init(app: IApp) {
appInstance = app
reloadPlugins()
}
fun enablePlugin(id: String) {
pluginInstances.filter { it.manifest.id == id }.forEach { enablePlugin(it) }
}
fun disablePlugin(id: String) {
pluginInstances.filter { it.manifest.id == id }.forEach { disablePlugin(it) }
}
private fun createPlugin(pluginManifest: PluginManifest): IPlugin? {
try {
val mainClazz = Class.forName(pluginManifest.mainClass)
val constructor =
mainClazz.getDeclaredConstructor(IApp::class.java, PluginManifest::class.java)
constructor.isAccessible = true
return constructor.newInstance(appInstance, pluginManifest) as IPlugin
} catch (e: Exception) {
e.printStackTrace()
return null
}
}
private fun enablePlugin(pluginInstance: IPlugin) {
try {
pluginInstance.onEnable()
} catch (e: Exception) {
e.printStackTrace()
}
}
private fun disablePlugin(pluginInstance: IPlugin) {
try {
pluginInstance.onDisable()
} catch (e: Exception) {
e.printStackTrace()
}
}
private fun destroyPlugin(pluginInstance: IPlugin) {
try {
pluginInstance.onDestroy()
} catch (e: Exception) {
e.printStackTrace()
}
}
fun reloadPluginManifests() {
pluginManifests.clear()
pluginManifests.addAll(
AssetUtil.getStringFromAsset(context, "plugins.json").fromJson<List<PluginManifest>>()
)
}
fun reloadPlugins() {
pluginInstances.forEach {
disablePlugin(it)
destroyPlugin(it)
}
pluginInstances.clear()
reloadPluginManifests()
pluginManifests.forEach {
try {
if (it.pluginCreated && preferences.getBoolean("${it.id}_enabled", true)) {
val pluginInstance = createPlugin(it)
if (pluginInstance != null) {
pluginInstances.add(pluginInstance)
}
}
} catch (e: Exception) {
e.printStackTrace()
}
}
pluginInstances.forEach {
enablePlugin(it)
}
}
private val PluginManifest.pluginCreated: Boolean
get() {
return pluginInstances.firstOrNull { it.manifest.id == id } != null
}
}
class PluginMenuItem<Data>(
val id: String,
val menuId: String,
val title: String,
val callback: ClickCallback<Data>? = null
) {
interface ClickCallback<Data> {
fun onClick(data: Data)
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is PluginMenuItem<*>) return false
if (id != other.id) return false
if (menuId != other.menuId) return false
if (title != other.title) return false
if (callback != other.callback) return false
return true
}
override fun hashCode(): Int {
var result = id.hashCode()
result = 31 * result + menuId.hashCode()
result = 31 * result + title.hashCode()
return result
}
}
fun getMenuByData(dataClass: KClass<*>): String = getMenuByData(dataClass.java)
fun getMenuByData(dataClass: Class<*>): String {
return when (dataClass.canonicalName) {
ProfileBean::class.java.canonicalName -> PluginManager.MENU_USER_ACTIVITY
else -> "none"
}
}

View File

@ -0,0 +1,9 @@
package com.huanchengfly.tieba.post.plugins.interfaces
import android.content.Context
interface IApp {
fun getAppContext(): Context
fun launchUrl(url: String)
}

View File

@ -0,0 +1,12 @@
package com.huanchengfly.tieba.post.plugins.models
import com.google.gson.annotations.SerializedName
data class PluginManifest(
val id: String,
val name: String,
val desc: String,
val version: String,
@SerializedName("main_class")
val mainClass: String
)

View File

@ -17,6 +17,7 @@ public class SharedPreferencesUtil {
public static final String SP_PERMISSION = "permission"; public static final String SP_PERMISSION = "permission";
public static final String SP_IGNORE_VERSIONS = "ignore_version"; public static final String SP_IGNORE_VERSIONS = "ignore_version";
public static final String SP_WEBVIEW_INFO = "webview_info"; public static final String SP_WEBVIEW_INFO = "webview_info";
public static final String SP_PLUGINS = "plugins";
public static SharedPreferences get(@Preferences String name) { public static SharedPreferences get(@Preferences String name) {
return get(BaseApplication.getInstance(), name); return get(BaseApplication.getInstance(), name);
@ -50,7 +51,7 @@ public class SharedPreferencesUtil {
return put(get(context, preference), key, value); return put(get(context, preference), key, value);
} }
@StringDef({SP_APP_DATA, SP_IGNORE_VERSIONS, SP_PERMISSION, SP_SETTINGS, SP_WEBVIEW_INFO, SP_DRAFT}) @StringDef({SP_APP_DATA, SP_IGNORE_VERSIONS, SP_PERMISSION, SP_SETTINGS, SP_WEBVIEW_INFO, SP_DRAFT, SP_PLUGINS})
@Retention(RetentionPolicy.SOURCE) @Retention(RetentionPolicy.SOURCE)
public @interface Preferences { public @interface Preferences {
} }

View File

@ -1,15 +1,22 @@
package com.huanchengfly.tieba.post.utils package com.huanchengfly.tieba.post.utils
import android.content.ActivityNotFoundException
import android.content.Context import android.content.Context
import android.content.Intent
import android.content.res.ColorStateList import android.content.res.ColorStateList
import android.graphics.Color import android.graphics.Color
import android.graphics.drawable.Drawable import android.graphics.drawable.Drawable
import android.graphics.drawable.GradientDrawable import android.graphics.drawable.GradientDrawable
import android.graphics.drawable.RippleDrawable import android.graphics.drawable.RippleDrawable
import android.net.Uri
import androidx.browser.customtabs.CustomTabColorSchemeParams
import androidx.browser.customtabs.CustomTabsIntent
import com.huanchengfly.tieba.post.BaseApplication import com.huanchengfly.tieba.post.BaseApplication
import com.huanchengfly.tieba.post.R import com.huanchengfly.tieba.post.R
import com.huanchengfly.tieba.post.activities.WebViewActivity
import com.huanchengfly.tieba.post.dpToPxFloat import com.huanchengfly.tieba.post.dpToPxFloat
import com.huanchengfly.tieba.post.ui.theme.utils.ColorStateListUtils import com.huanchengfly.tieba.post.ui.theme.utils.ColorStateListUtils
import com.huanchengfly.tieba.post.ui.theme.utils.ThemeUtils
@JvmOverloads @JvmOverloads
fun getItemBackgroundDrawable( fun getItemBackgroundDrawable(
@ -101,13 +108,54 @@ fun getIntermixedColorBackground(
context, context,
position, position,
itemCount, itemCount,
positionOffset, positionOffset,
radius, radius,
if (context.appPreferences.listItemsBackgroundIntermixed) { if (context.appPreferences.listItemsBackgroundIntermixed) {
colors colors
} else { } else {
intArrayOf(colors[0]) intArrayOf(colors[0])
}, },
ripple ripple
) )
} }
fun launchUrl(context: Context, url: String) {
val uri = Uri.parse(url)
val host = uri.host
val path = uri.path
val scheme = uri.scheme
if (host == null || scheme == null || path == null) {
return
}
if (!path.contains("android_asset")) {
val isTiebaLink =
host.contains("tieba.baidu.com") || host.contains("wappass.baidu.com") || host.contains(
"ufosdk.baidu.com"
) || host.contains("m.help.baidu.com")
if (isTiebaLink || context.appPreferences.useWebView) {
WebViewActivity.launch(context, url)
} else {
if (context.appPreferences.useCustomTabs) {
val intentBuilder = CustomTabsIntent.Builder()
.setShowTitle(true)
.setDefaultColorSchemeParams(
CustomTabColorSchemeParams.Builder()
.setToolbarColor(
ThemeUtils.getColorByAttr(
context,
R.attr.colorToolbar
)
)
.build()
)
try {
intentBuilder.build().launchUrl(context, uri)
} catch (e: ActivityNotFoundException) {
context.startActivity(Intent(Intent.ACTION_VIEW, uri))
}
} else {
context.startActivity(Intent(Intent.ACTION_VIEW, uri))
}
}
}
}

View File

@ -465,4 +465,5 @@
<string name="bubble_not_completed">功能未完成开发,敬请期待</string> <string name="bubble_not_completed">功能未完成开发,敬请期待</string>
<string name="title_theme_translucent">透明主题</string> <string name="title_theme_translucent">透明主题</string>
<string name="title_theme_custom">自定义</string> <string name="title_theme_custom">自定义</string>
<string name="plugin_comment_lookup_menu">查询发言</string>
</resources> </resources>