refactor: extension system.

This commit is contained in:
oxy-macmini
2025-03-28 20:06:44 +08:00
parent ec24015aaf
commit e78dc3acd8
31 changed files with 379 additions and 173 deletions

View File

@ -41,7 +41,7 @@ android {
}
dependencies {
implementation(project(":data:extension"))
implementation(project(":extension:api"))
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.runtime.ktx)

View File

@ -1,8 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<permission
android:name="com.m3u.permission.CONNECT_EXTENSION_PLUGIN"
android:protectionLevel="normal" />
<uses-permission android:name="com.m3u.permission.CONNECT_EXTENSION_PLUGIN"/>
<application
android:allowBackup="true"
@ -21,16 +18,6 @@
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<service
android:name="com.m3u.data.extension.RemoteServer"
android:exported="true"
android:permission="com.m3u.permission.CONNECT_EXTENSION_PLUGIN">
<intent-filter>
<action android:name="com.m3u.permission.CONNECT_EXTENSION_PLUGIN" />
</intent-filter>
</service>
</application>
<queries>
<package android:name="com.m3u.smartphone" />

View File

@ -1,5 +1,6 @@
package com.m3u.extension
import android.content.Intent
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
@ -18,6 +19,7 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.m3u.data.extension.Const
import com.m3u.data.extension.RemoteClient
import com.m3u.extension.ui.theme.M3UTheme
import kotlinx.coroutines.launch
@ -28,6 +30,8 @@ class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
val callToken = handleArguments(intent)
setContent {
M3UTheme {
val coroutineScope = rememberCoroutineScope()
@ -42,20 +46,33 @@ class MainActivity : ComponentActivity() {
) {
Button(
onClick = {
if (!isConnected) client.connect(this@MainActivity)
else client.disconnect(this@MainActivity)
val callToken = callToken
if (!isConnected && callToken != null) {
client.connect(
context = this@MainActivity,
targetPackageName = callToken.packageName,
targetClassName = callToken.className,
targetPermission = callToken.permission,
accessKey = callToken.accessKey
)
} else {
client.disconnect(this@MainActivity)
}
}
) {
Text(
text = if (isConnected) "Disconnect" else "Connect"
text = when {
isConnected -> "Disconnect"
else -> "Connect"
}
)
}
Button(
enabled = isConnected,
onClick = {
coroutineScope.launch {
channelCount = client.call("test", "read-channel-count", "{}")
?.toIntOrNull() ?: -1
// channelCount = client.call("test", "read-channel-count", "{}")
// ?.toIntOrNull() ?: -1
}
}
) {
@ -68,4 +85,19 @@ class MainActivity : ComponentActivity() {
}
}
}
private fun handleArguments(intent: Intent): CallToken? {
val packageName = intent.getStringExtra(Const.PACKAGE_NAME) ?: return null
val className = intent.getStringExtra(Const.CLASS_NAME) ?: return null
val permission = intent.getStringExtra(Const.PERMISSION) ?: return null
val accessKey = intent.getStringExtra(Const.ACCESS_KEY) ?: return null
return CallToken(packageName, className, permission, accessKey)
}
}
private data class CallToken(
val packageName: String,
val className: String,
val permission: String,
val accessKey: String
)

View File

@ -1,16 +0,0 @@
package com.m3u.extension
import android.util.Log
import com.google.auto.service.AutoService
import com.m3u.data.extension.IRemoteCallback
import com.m3u.data.extension.OnRemoteServerCall
@AutoService(OnRemoteServerCall::class)
class OnRemoteServiceCallImpl: OnRemoteServerCall {
override fun onCall(module: String, method: String, param: String, callback: IRemoteCallback?) {
Log.d(TAG, "onCall: $module, $method, $param, $callback")
}
companion object {
private const val TAG = "OnRemoteServiceCallImpl"
}
}

View File

@ -122,7 +122,8 @@ dependencies {
implementation(project(":core"))
implementation(project(":core:foundation"))
implementation(project(":data"))
implementation(project(":data:extension"))
implementation(project(":extension:api"))
implementation(project(":extension:runtime"))
// business
implementation(project(":business:foryou"))
implementation(project(":business:favorite"))

View File

@ -15,7 +15,6 @@
<permission
android:name="com.m3u.permission.CONNECT_EXTENSION_PLUGIN"
android:protectionLevel="normal" />
<uses-permission android:name="com.m3u.permission.CONNECT_EXTENSION_PLUGIN"/>
<uses-feature
android:name="android.software.leanback"
@ -76,7 +75,7 @@
android:theme="@style/Theme.M3U" />
<service
android:name="com.m3u.data.extension.RemoteServer"
android:name="com.m3u.data.extension.RemoteService"
android:exported="true"
android:permission="com.m3u.permission.CONNECT_EXTENSION_PLUGIN">
<intent-filter>

View File

@ -14,6 +14,7 @@ plugins {
alias(libs.plugins.org.jetbrains.kotlin.serialization) apply false
alias(libs.plugins.org.jetbrains.kotlin.jvm) apply false
alias(libs.plugins.androidx.baselineprofile) apply false
id("com.squareup.wire") version "4.9.2" apply false
}
subprojects {

View File

@ -26,7 +26,7 @@ android {
dependencies {
implementation(project(":core"))
implementation(project(":data:extension"))
implementation(project(":extension:api"))
implementation(project(":lint:annotation"))
ksp(project(":lint:processor"))

View File

@ -1,46 +0,0 @@
package com.m3u.data.extension
import android.app.Service
import android.content.Intent
import android.os.IBinder
import android.util.Log
import java.util.ServiceLoader
class RemoteServer : Service() {
private val onRemoteServerCall: OnRemoteServerCall = ServiceLoader.load<OnRemoteServerCall>(
OnRemoteServerCall::class.java
).let {
val count = it.count()
if (count == 0) {
throw IllegalStateException("No implementation of OnRemoteServerCall found")
} else if (count > 1) {
throw IllegalStateException("Multiple implementations of OnRemoteServerCall found")
} else {
it.first()
}
}
private val binder: IRemoteService.Stub = object : IRemoteService.Stub() {
override fun call(
module: String,
method: String,
param: String,
callback: IRemoteCallback?
) {
onRemoteServerCall.onCall(module, method, param, callback)
}
}
override fun onBind(intent: Intent?): IBinder? {
Log.d(TAG, "onBind: $intent")
return binder
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
Log.d(TAG, "onStartCommand: $intent, $flags, $startId")
return super.onStartCommand(intent, flags, startId)
}
companion object {
private const val TAG = "RemoteClient"
}
}

View File

@ -1,64 +0,0 @@
package com.m3u.data.service
import android.util.Log
import com.google.auto.service.AutoService
import com.m3u.data.extension.IRemoteCallback
import com.m3u.data.extension.OnRemoteServerCall
import com.m3u.data.extension.RemoteCallException
import java.util.ServiceLoader
@AutoService(OnRemoteServerCall::class)
class OnRemoteServiceCallImpl : OnRemoteServerCall {
private val modules = ServiceLoader.load(RemoteModule::class.java)
?.toList().orEmpty().filterNotNull().associateBy { it.module }
override fun onCall(module: String, method: String, param: String, callback: IRemoteCallback?) {
Log.d(TAG, "onCall: $module, $method, $param, $callback")
try {
val moduleInstance = modules[module]
if (moduleInstance == null) {
callback?.onError(
module,
method,
OnRemoteServerCall.ERROR_CODE_MODULE_NOT_FOUNDED,
"Module $module not founded"
)
return
}
if (method !in moduleInstance.methods) {
callback?.onError(
module,
method,
OnRemoteServerCall.ERROR_CODE_METHOD_NOT_FOUNDED,
"Method $method not founded"
)
return
}
moduleInstance.callMethod(method, param, callback)
} catch (e: RemoteCallException) {
callback?.onError(
module,
method,
e.errorCode,
e.errorMessage
)
} catch (e: Exception) {
callback?.onError(
module,
method,
OnRemoteServerCall.ERROR_CODE_UNCAUGHT,
e.message
)
}
}
companion object {
private const val TAG = "Host-OnRemoteServiceCallImpl"
}
}
interface RemoteModule {
val module: String
val methods: List<String>
fun callMethod(method: String, param: String, callback: IRemoteCallback?)
}

View File

@ -5,7 +5,7 @@ plugins {
}
android {
namespace = "com.m3u.data.extension"
namespace = "com.m3u.extension.api"
kotlinOptions {
jvmTarget = "17"
}

View File

@ -1,6 +1,6 @@
package com.m3u.data.extension;
interface IRemoteCallback {
void onSuccess(String module, String method, String param);
void onSuccess(String module, String method, in byte[] param);
void onError(String module, String method, int errorCode, String errorMessage);
}

View File

@ -3,5 +3,5 @@ package com.m3u.data.extension;
import com.m3u.data.extension.IRemoteCallback;
interface IRemoteService {
void call(String module, String method, String param, IRemoteCallback callback);
void call(String module, String method, in byte[] param, IRemoteCallback callback);
}

View File

@ -0,0 +1,8 @@
package com.m3u.data.extension
object Const {
const val PACKAGE_NAME = "package_name"
const val CLASS_NAME = "class_name"
const val PERMISSION = "permission"
const val ACCESS_KEY = "access_key"
}

View File

@ -1,7 +1,7 @@
package com.m3u.data.extension
interface OnRemoteServerCall {
fun onCall(module: String, method: String, param: String, callback: IRemoteCallback?)
interface OnRemoteCall {
operator fun invoke(module: String, method: String, bytes: ByteArray, callback: IRemoteCallback?)
companion object {
const val ERROR_CODE_MODULE_NOT_FOUNDED = -1
const val ERROR_CODE_METHOD_NOT_FOUNDED = -2
@ -12,4 +12,4 @@ interface OnRemoteServerCall {
class RemoteCallException(
val errorCode: Int,
val errorMessage: String?
): RuntimeException(errorMessage)
) : RuntimeException(errorMessage)

View File

@ -42,12 +42,16 @@ class RemoteClient {
fun connect(
context: Context,
targetPackageName: String = PACKAGE_NAME_HOST
targetPackageName: String,
targetClassName: String,
targetPermission: String,
accessKey: String
) {
Log.d(TAG, "connect")
val intent = Intent(context, RemoteServer::class.java).apply {
action = "com.m3u.permission.CONNECT_EXTENSION_PLUGIN"
component = ComponentName(targetPackageName, "com.m3u.data.extension.RemoteServer")
val intent = Intent(context, RemoteService::class.java).apply {
action = targetPermission
component = ComponentName(targetPackageName, targetClassName)
putExtra(Const.ACCESS_KEY, accessKey)
}
context.bindService(intent, connection, Context.BIND_AUTO_CREATE)
}
@ -60,11 +64,11 @@ class RemoteClient {
suspend fun call(
module: String,
method: String,
param: String
): String? = suspendCoroutine { cont ->
param: ByteArray
): ByteArray = suspendCoroutine { cont ->
val remoteService = requireNotNull(server) { "RemoteService is not connected!" }
remoteService.call(module, method, param, object : IRemoteCallback.Stub() {
override fun onSuccess(module: String, method: String, param: String?) {
override fun onSuccess(module: String, method: String, param: ByteArray) {
Log.d(TAG, "onSuccess: $method, $param")
cont.resume(param)
}
@ -89,6 +93,5 @@ class RemoteClient {
companion object {
private const val TAG = "RemoteClient"
const val PACKAGE_NAME_HOST = "com.m3u.smartphone"
}
}

View File

@ -0,0 +1,68 @@
package com.m3u.data.extension
import android.app.Service
import android.content.Intent
import android.os.IBinder
import android.util.Log
import java.util.ServiceLoader
import java.util.concurrent.ConcurrentHashMap
class RemoteService : Service() {
private val onRemoteCall: OnRemoteCall by lazy {
ServiceLoader.load<OnRemoteCall>(
OnRemoteCall::class.java
).let {
val count = it.count()
if (count == 0) {
throw IllegalStateException("No implementation of OnRemoteCall found")
} else if (count > 1) {
throw IllegalStateException("Multiple implementations of OnRemoteCall found")
} else {
it.first()
}
}
}
private val binders = ConcurrentHashMap<String, IRemoteService.Stub>()
private inner class RemoteServiceImpl: IRemoteService.Stub() {
override fun call(
module: String,
method: String,
param: ByteArray,
callback: IRemoteCallback?
) {
onRemoteCall(module, method, param, callback)
}
}
override fun onBind(intent: Intent?): IBinder? {
Log.d(TAG, "onBind: $intent")
intent?: return null
val packageName = intent.`package` ?: return null
val binder = binders.getOrPut(packageName) {
RemoteServiceImpl()
}
return binder
}
override fun onUnbind(intent: Intent?): Boolean {
Log.d(TAG, "onUnbind: $intent")
intent ?: return super.onUnbind(intent)
val packageName = intent.`package` ?: return super.onUnbind(intent)
val binder = binders.remove(packageName)
if (binder != null) {
return true
}
return super.onUnbind(intent)
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
Log.d(TAG, "onStartCommand: $intent, $flags, $startId")
return super.onStartCommand(intent, flags, startId)
}
companion object {
private const val TAG = "RemoteClient"
}
}

1
extension/runtime/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
/build

View File

@ -0,0 +1,38 @@
plugins {
alias(libs.plugins.com.android.library)
alias(libs.plugins.org.jetbrains.kotlin.android)
alias(libs.plugins.org.jetbrains.kotlin.serialization)
alias(libs.plugins.com.google.devtools.ksp)
id("com.squareup.wire")
}
android {
namespace = "com.m3u.extension.runtime"
kotlinOptions {
jvmTarget = "17"
}
buildFeatures {
aidl = true
}
}
wire {
kotlin {}
}
dependencies {
implementation(project(":extension:api"))
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.appcompat)
// reflect
implementation(libs.org.jetbrains.kotlin.kotlin.reflect)
// auto
implementation(libs.auto.service.annotations)
ksp(libs.auto.service.ksp)
// wire
implementation("com.squareup.wire:wire-runtime:4.9.2")
}

View File

21
extension/runtime/proguard-rules.pro vendored Normal file
View File

@ -0,0 +1,21 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile

View File

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
</manifest>

View File

@ -0,0 +1,133 @@
package com.m3u.extension.runtime
import android.util.Log
import com.google.auto.service.AutoService
import com.m3u.data.extension.IRemoteCallback
import com.m3u.data.extension.OnRemoteCall
import com.m3u.data.extension.RemoteCallException
import com.squareup.wire.ProtoAdapter
import java.lang.reflect.Method
import java.lang.reflect.Parameter
import java.util.ServiceLoader
import kotlin.collections.orEmpty
import kotlin.reflect.full.companionObject
import kotlin.reflect.full.declaredMemberProperties
@AutoService(OnRemoteCall::class)
class OnRemoteCallImpl : OnRemoteCall {
private val remoteModules = ServiceLoader.load(RemoteModule::class.java)
?.toList().orEmpty().filterNotNull().associateBy { it.module }
// Map<module-name, Map<method-name, method>>
private val remoteMethods = mutableMapOf<String, Map<String, Method>>()
// Map<type-name, adapter>
private val protobufAdapters = mutableMapOf<String, ProtoAdapter<*>>()
override fun invoke(
module: String,
method: String,
bytes: ByteArray,
callback: IRemoteCallback?
) {
Log.d(TAG, "$module, $method, ${bytes.size}, $callback")
try {
val instance = remoteModules[module]
if (instance == null) {
callback?.onError(
module,
method,
OnRemoteCall.ERROR_CODE_MODULE_NOT_FOUNDED,
"Module $module not founded"
)
return
}
invokeImpl(instance, module, method, bytes, callback)
} catch (e: RemoteCallException) {
callback?.onError(
module,
method,
e.errorCode,
e.errorMessage
)
} catch (e: Exception) {
callback?.onError(
module,
method,
OnRemoteCall.ERROR_CODE_UNCAUGHT,
e.message
)
}
}
private fun invokeImpl(
instance: RemoteModule,
module: String,
method: String,
param: ByteArray,
callback: IRemoteCallback?
) {
val methods = remoteMethods.getOrPut(module) {
val moduleClass = instance::class.java
moduleClass.declaredMethods
.asSequence()
.filter { it.isAnnotationPresent(RemoteMethod::class.java) }
.filter { it.isAccessible }
.toList()
.associateBy { it.getAnnotation(RemoteMethod::class.java)!!.name }
}
val remoteMethod = methods[method]
if (remoteMethod == null) {
callback?.onError(
module,
method,
OnRemoteCall.ERROR_CODE_METHOD_NOT_FOUNDED,
"Method $method not founded"
)
return
}
// handle protobuf param
val pbArg = remoteMethod.parameters
.find { it.isAnnotationPresent(RemoteMethodParam::class.java) }
?.let { decodeParamFromBytes(it, param) }
val args = listOfNotNull(
pbArg,
callback
)
remoteMethod.invoke(instance, args)
}
private fun decodeParamFromBytes(
param: Parameter,
bytes: ByteArray
): Any? {
val adapter = protobufAdapters.getOrPut(param.type.typeName) {
val companionObject = Class.forName(param.type.typeName).kotlin.companionObject
// TODO: fix this
// Class.forName(param.type.typeName).kotlin.companionObjectInstance
val adapter = companionObject?.declaredMemberProperties.orEmpty().find { it.name == "ADAPTER" }
adapter as ProtoAdapter<*>
}
return adapter.decode(bytes)
}
companion object {
private const val TAG = "Host-OnRemoteCallImpl"
}
}
interface RemoteModule {
val module: String
}
@Retention(AnnotationRetention.SOURCE)
@Target(AnnotationTarget.FUNCTION)
annotation class RemoteMethod(
val name: String
)
@Retention(AnnotationRetention.SOURCE)
@Target(AnnotationTarget.VALUE_PARAMETER)
annotation class RemoteMethodParam

View File

@ -0,0 +1,25 @@
package com.m3u.extension.runtime.business
import GetAppInfoRequest
import GetAppInfoResponse
import com.m3u.extension.runtime.RemoteMethod
import com.m3u.extension.runtime.RemoteMethodParam
import com.m3u.extension.runtime.RemoteModule
class InfoModule constructor(): RemoteModule {
override val module: String = "info"
@RemoteMethod("getAppInfo")
fun getAppInfo(
@RemoteMethodParam param: GetAppInfoRequest,
callback: (GetAppInfoResponse) -> Unit
) {
callback(
GetAppInfoResponse(
"com.m3u.extension.runtime",
"InfoModule",
"1.0.0"
)
)
}
}

View File

@ -0,0 +1,14 @@
syntax = "proto3";
message GetAppInfoResponse {
string app_id = 1;
string app_version = 2;
string app_name = 3;
string app_icon = 4;
string app_description = 5;
string app_package_name = 6;
}
message GetAppInfoRequest {
}

View File

@ -173,13 +173,8 @@ androidx-graphics-shapes = { group = "androidx.graphics", name = "graphics-shape
symbol-processing-api = { module = "com.google.devtools.ksp:symbol-processing-api", version.ref = "kotlin-symbol-processor" }
androidx-profileinstaller = { group = "androidx.profileinstaller", name = "profileinstaller", version.ref = "profileinstaller" }
jakewharton-disklrucache = { group = "com.jakewharton", name = "disklrucache", version.ref = "jakewharton-disklrucache" }
junit = { group = "junit", name = "junit", version.ref = "junit" }
androidx-ui = { group = "androidx.compose.ui", name = "ui" }
androidx-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
androidx-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
org-jetbrains-kotlin-kotlin-reflect = { group = "org.jetbrains.kotlin", name = "kotlin-reflect", version.ref = "kotlin" }
[plugins]
com-android-application = { id = "com.android.application", version.ref = "android-gradle-plugin" }

View File

@ -15,7 +15,7 @@ dependencyResolutionManagement {
}
}
rootProject.name = "M3U"
include(":app:smartphone", ":app:tv")
include(":app:smartphone", ":app:tv", ":app:extension")
include(":core", ":core:foundation")
include(":data")
include(":data:codec", ":data:codec:lite", ":data:codec:rich")
@ -33,5 +33,7 @@ include(
":lint:annotation",
":lint:processor"
)
include(":data:extension")
include(":app:extension")
include(
":extension:api",
":extension:runtime"
)