Add domestic vendor push infrastructure

This commit is contained in:
2026-07-26 01:45:59 +08:00
parent 7cca34b331
commit 0738953e6d
77 changed files with 6470 additions and 855 deletions
+31 -9
View File
@@ -21,8 +21,13 @@ val releaseSigningKeys = listOf(
"keyAlias",
"storeFile",
)
val releaseSigningConfigured = keystorePropertiesFile.exists() &&
releaseSigningKeys.all { !keystoreProperties.getProperty(it).isNullOrBlank() }
val releaseSigningConfigured = keystorePropertiesFile.exists() &&
releaseSigningKeys.all { !keystoreProperties.getProperty(it).isNullOrBlank() }
fun pushBuildValue(name: String): String =
(project.findProperty(name)?.toString() ?: System.getenv(name) ?: "")
.replace("\\", "\\\\")
.replace("\"", "\\\"")
android {
namespace = "com.nx.miaoji"
@@ -44,6 +49,20 @@ android {
targetSdk = flutter.targetSdkVersion
versionCode = flutter.versionCode
versionName = flutter.versionName
buildConfigField("String", "PUSH_HUAWEI_APP_ID", "\"${pushBuildValue("PUSH_HUAWEI_APP_ID")}\"")
buildConfigField("String", "PUSH_HONOR_APP_ID", "\"${pushBuildValue("PUSH_HONOR_APP_ID")}\"")
buildConfigField("String", "PUSH_XIAOMI_APP_ID", "\"${pushBuildValue("PUSH_XIAOMI_APP_ID")}\"")
buildConfigField("String", "PUSH_XIAOMI_APP_KEY", "\"${pushBuildValue("PUSH_XIAOMI_APP_KEY")}\"")
buildConfigField("String", "PUSH_OPPO_APP_KEY", "\"${pushBuildValue("PUSH_OPPO_APP_KEY")}\"")
buildConfigField("String", "PUSH_OPPO_APP_SECRET", "\"${pushBuildValue("PUSH_OPPO_APP_SECRET")}\"")
buildConfigField("String", "PUSH_VIVO_APP_ID", "\"${pushBuildValue("PUSH_VIVO_APP_ID")}\"")
buildConfigField("String", "PUSH_VIVO_APP_KEY", "\"${pushBuildValue("PUSH_VIVO_APP_KEY")}\"")
buildConfigField("String", "PUSH_MEIZU_APP_ID", "\"${pushBuildValue("PUSH_MEIZU_APP_ID")}\"")
buildConfigField("String", "PUSH_MEIZU_APP_KEY", "\"${pushBuildValue("PUSH_MEIZU_APP_KEY")}\"")
}
buildFeatures {
buildConfig = true
}
signingConfigs {
@@ -75,9 +94,11 @@ android {
}
buildTypes {
release {}
}
}
release {
proguardFiles("proguard-rules.pro")
}
}
}
val dartDefines = (project.findProperty("dart-defines") as? String)
.orEmpty()
@@ -161,7 +182,8 @@ flutter {
source = "../.."
}
dependencies {
implementation("com.google.mlkit:text-recognition-chinese:16.0.1")
testImplementation("junit:junit:4.13.2")
}
dependencies {
implementation("com.google.mlkit:text-recognition-chinese:16.0.1")
implementation(fileTree(mapOf("dir" to "libs/push", "include" to listOf("*.aar", "*.jar"))))
testImplementation("junit:junit:4.13.2")
}
+7
View File
@@ -0,0 +1,7 @@
# Vendor push SDKs
Place the official Huawei, Honor, Xiaomi, OPPO/Heytap, vivo and Meizu Android
SDK AAR/JAR files in this directory during CI or a local release build. Binary
SDK files are intentionally ignored by git. Public client app IDs and app keys
are injected through the `PUSH_*` Gradle properties or environment variables;
provider master secrets belong only on the API server.
+8
View File
@@ -0,0 +1,8 @@
# Vendor SDK entry points are resolved by VendorPushBridge through reflection.
-keep class com.huawei.hms.aaid.** { *; }
-keep class com.hihonor.push.** { *; }
-keep class com.hihonor.mcs.push.** { *; }
-keep class com.xiaomi.mipush.sdk.** { *; }
-keep class com.heytap.msp.push.** { *; }
-keep class com.vivo.push.** { *; }
-keep class com.meizu.cloud.pushsdk.** { *; }
@@ -30,12 +30,18 @@
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.SEND"/>
<intent-filter>
<action android:name="android.intent.action.SEND"/>
<category android:name="android.intent.category.DEFAULT"/>
<data android:mimeType="image/*"/>
</intent-filter>
</activity>
<data android:mimeType="image/*"/>
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW"/>
<category android:name="android.intent.category.DEFAULT"/>
<category android:name="android.intent.category.BROWSABLE"/>
<data android:scheme="miaoji" android:host="push" android:pathPrefix="/open"/>
</intent-filter>
</activity>
<activity
android:name=".ProjectionConsentActivity"
@@ -63,7 +63,8 @@ class MainActivity : FlutterActivity() {
private var pendingProgressCount: Int? = null
private var pendingRecognitionAction: Map<String, Any?>? = null
private var recognitionReceiverRegistered = false
private var updateInstallBridge: UpdateInstallBridge? = null
private var updateInstallBridge: UpdateInstallBridge? = null
private var vendorPushBridge: VendorPushBridge? = null
private var pendingSpeechResult: MethodChannel.Result? = null
private var speechRecognizer: SpeechRecognizer? = null
@@ -71,7 +72,11 @@ class MainActivity : FlutterActivity() {
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
updateInstallBridge = UpdateInstallBridge(this).also { it.register(flutterEngine) }
updateInstallBridge = UpdateInstallBridge(this).also { it.register(flutterEngine) }
vendorPushBridge = VendorPushBridge(this).also {
it.register(flutterEngine)
it.handleIntent(intent)
}
channel = MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CHANNEL)
channel?.setMethodCallHandler { call, result ->
when (call.method) {
@@ -202,13 +207,15 @@ class MainActivity : FlutterActivity() {
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
setIntent(intent)
handleIncomingIntent(intent)
setIntent(intent)
vendorPushBridge?.handleIntent(intent)
handleIncomingIntent(intent)
}
override fun onResume() {
super.onResume()
updateInstallBridge?.onResume()
updateInstallBridge?.onResume()
vendorPushBridge?.onResume()
scheduleShortcutIfNeeded()
dispatchPendingScreenshot()
dispatchPendingRecognitionAction()
@@ -0,0 +1,407 @@
package com.nx.miaoji
import android.Manifest
import android.app.NotificationChannel
import android.app.NotificationManager
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Build
import android.provider.Settings
import androidx.core.content.ContextCompat
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import org.json.JSONObject
import java.lang.reflect.Proxy
import java.util.Locale
import java.util.concurrent.Executors
class VendorPushBridge(private val activity: MainActivity) : MethodChannel.MethodCallHandler {
companion object {
private const val CHANNEL = "com.miaoji/push"
private const val PREFS = "jizhi_vendor_push"
private const val KEY_ENABLED = "enabled"
private const val KEY_TOKEN = "token"
private const val KEY_PROVIDER = "provider"
private const val KEY_PENDING = "pending_open"
}
private val executor = Executors.newSingleThreadExecutor()
private val preferences = activity.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
private var channel: MethodChannel? = null
fun register(engine: FlutterEngine) {
channel = MethodChannel(engine.dartExecutor.binaryMessenger, CHANNEL).also {
it.setMethodCallHandler(this)
}
dispatchPendingOpen()
}
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
when (call.method) {
"getStatus" -> result.success(status())
"enable" -> resolveToken(result)
"refreshToken" -> resolveToken(result)
"disable" -> {
disableProvider()
result.success(status())
}
"openNotificationSettings" -> {
openNotificationSettings()
result.success(true)
}
"getPendingOpen" -> result.success(readPendingOpen())
"acknowledgeOpen" -> {
val messageId = call.argument<String>("messageId")
val pending = readPendingOpen()
if (messageId != null && pending?.get("messageId") == messageId) {
preferences.edit().remove(KEY_PENDING).apply()
}
result.success(true)
}
else -> result.notImplemented()
}
}
fun onResume() {
dispatchPendingOpen()
if (preferences.getBoolean(KEY_ENABLED, false) && cachedToken().isNullOrBlank()) {
resolveToken(null)
}
}
fun handleIntent(intent: Intent?) {
val payload = parseOpen(intent) ?: return
val messageId = payload["messageId"]?.toString().orEmpty()
if (messageId.isBlank()) return
preferences.edit().putString(KEY_PENDING, JSONObject(payload).toString()).apply()
dispatchPendingOpen()
}
private fun resolveToken(result: MethodChannel.Result?) {
createNotificationChannels()
if (!notificationsAllowed()) {
result?.success(status(error = "notification_permission_denied"))
return
}
val provider = detectProvider()
if (provider == null) {
result?.success(status(error = "unsupported_vendor"))
return
}
if (!sdkAvailable(provider)) {
result?.success(status(error = "sdk_not_installed"))
return
}
preferences.edit().putBoolean(KEY_ENABLED, true).putString(KEY_PROVIDER, provider).apply()
executor.execute {
val token = runCatching { registerAndReadToken(provider) }.getOrNull()
if (!token.isNullOrBlank()) {
preferences.edit().putString(KEY_TOKEN, token).putString(KEY_PROVIDER, provider).apply()
activity.runOnUiThread {
channel?.invokeMethod("onToken", mapOf("provider" to provider, "token" to token))
}
}
activity.runOnUiThread {
result?.success(status(error = if (token.isNullOrBlank()) "token_pending" else null))
}
}
}
private fun registerAndReadToken(provider: String): String? = when (provider) {
"huawei" -> huaweiToken()
"honor" -> honorToken()
"xiaomi" -> xiaomiToken()
"oppo" -> oppoToken()
"vivo" -> vivoToken()
"meizu" -> meizuToken()
else -> null
}
private fun huaweiToken(): String? {
val appId = config("PUSH_HUAWEI_APP_ID")
if (appId.isBlank()) return null
val type = Class.forName("com.huawei.hms.aaid.HmsInstanceId")
val instance = type.getMethod("getInstance", Context::class.java).invoke(null, activity)
return type.getMethod("getToken", String::class.java, String::class.java)
.invoke(instance, appId, "HCM") as? String
}
private fun honorToken(): String? {
val appId = config("PUSH_HONOR_APP_ID")
if (appId.isBlank()) return null
val type = firstClass(
"com.hihonor.push.sdk.HonorPushClient",
"com.hihonor.mcs.push.HonorPushClient",
) ?: return null
val instance = invokeMatching(type, null, "getInstance", activity)
?: invokeMatching(type, null, "getInstance")
?: return null
val value = invokeMatching(type, instance, "getPushToken")
?: invokeMatching(type, instance, "getPushToken", appId)
return awaitTaskValue(value)
}
private fun xiaomiToken(): String? {
val appId = config("PUSH_XIAOMI_APP_ID")
val appKey = config("PUSH_XIAOMI_APP_KEY")
if (appId.isBlank() || appKey.isBlank()) return null
val type = Class.forName("com.xiaomi.mipush.sdk.MiPushClient")
invokeMatching(type, null, "registerPush", activity, appId, appKey)
repeat(10) {
val token = invokeMatching(type, null, "getRegId", activity) as? String
if (!token.isNullOrBlank()) return token
Thread.sleep(300)
}
return null
}
private fun oppoToken(): String? {
val appKey = config("PUSH_OPPO_APP_KEY")
val appSecret = config("PUSH_OPPO_APP_SECRET")
if (appKey.isBlank() || appSecret.isBlank()) return null
val type = Class.forName("com.heytap.msp.push.HeytapPushManager")
invokeMatching(type, null, "init", activity.applicationContext, true)
val callbackType = firstClass("com.heytap.msp.push.callback.ICallBackResultService")
val callback = callbackType?.let { dynamicCallback(it) }
if (callback != null) invokeMatching(type, null, "register", activity, appKey, appSecret, callback)
repeat(10) {
val token = invokeMatching(type, null, "getRegisterID") as? String
if (!token.isNullOrBlank()) return token
Thread.sleep(300)
}
return null
}
private fun vivoToken(): String? {
val appId = config("PUSH_VIVO_APP_ID")
val appKey = config("PUSH_VIVO_APP_KEY")
if (appId.isBlank() || appKey.isBlank()) return null
val type = Class.forName("com.vivo.push.PushClient")
val instance = invokeMatching(type, null, "getInstance", activity.applicationContext) ?: return null
invokeMatching(type, instance, "initialize")
val callbackType = firstClass("com.vivo.push.IPushActionListener")
callbackType?.let { invokeMatching(type, instance, "turnOnPush", dynamicCallback(it)) }
repeat(10) {
val token = invokeMatching(type, instance, "getRegId") as? String
if (!token.isNullOrBlank()) return token
Thread.sleep(300)
}
return null
}
private fun meizuToken(): String? {
val appId = config("PUSH_MEIZU_APP_ID")
val appKey = config("PUSH_MEIZU_APP_KEY")
if (appId.isBlank() || appKey.isBlank()) return null
val type = Class.forName("com.meizu.cloud.pushsdk.PushManager")
invokeMatching(type, null, "register", activity.applicationContext, appId, appKey)
repeat(10) {
val token = invokeMatching(type, null, "getPushId", activity.applicationContext) as? String
if (!token.isNullOrBlank()) return token
Thread.sleep(300)
}
return null
}
private fun disableProvider() {
val provider = preferences.getString(KEY_PROVIDER, null)
runCatching {
when (provider) {
"xiaomi" -> invokeMatching(
Class.forName("com.xiaomi.mipush.sdk.MiPushClient"),
null,
"unregisterPush",
activity,
)
"oppo" -> invokeMatching(
Class.forName("com.heytap.msp.push.HeytapPushManager"),
null,
"unRegister",
)
"meizu" -> invokeMatching(
Class.forName("com.meizu.cloud.pushsdk.PushManager"),
null,
"unRegister",
activity.applicationContext,
config("PUSH_MEIZU_APP_ID"),
config("PUSH_MEIZU_APP_KEY"),
)
}
}
preferences.edit().putBoolean(KEY_ENABLED, false).remove(KEY_TOKEN).apply()
}
private fun status(error: String? = null): Map<String, Any?> {
val provider = detectProvider()
return mapOf(
"provider" to provider,
"supported" to (provider != null),
"sdkAvailable" to (provider?.let(::sdkAvailable) == true),
"notificationsAllowed" to notificationsAllowed(),
"enabled" to preferences.getBoolean(KEY_ENABLED, false),
"token" to cachedToken(),
"error" to error,
)
}
private fun detectProvider(): String? {
val value = "${Build.MANUFACTURER} ${Build.BRAND}".lowercase(Locale.ROOT)
return when {
value.contains("honor") -> "honor"
value.contains("huawei") -> "huawei"
value.contains("xiaomi") || value.contains("redmi") || value.contains("poco") -> "xiaomi"
value.contains("oppo") || value.contains("realme") || value.contains("oneplus") -> "oppo"
value.contains("vivo") || value.contains("iqoo") -> "vivo"
value.contains("meizu") -> "meizu"
else -> null
}
}
private fun sdkAvailable(provider: String): Boolean = when (provider) {
"huawei" -> firstClass("com.huawei.hms.aaid.HmsInstanceId") != null
"honor" -> firstClass("com.hihonor.push.sdk.HonorPushClient", "com.hihonor.mcs.push.HonorPushClient") != null
"xiaomi" -> firstClass("com.xiaomi.mipush.sdk.MiPushClient") != null
"oppo" -> firstClass("com.heytap.msp.push.HeytapPushManager") != null
"vivo" -> firstClass("com.vivo.push.PushClient") != null
"meizu" -> firstClass("com.meizu.cloud.pushsdk.PushManager") != null
else -> false
}
private fun createNotificationChannels() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
val manager = activity.getSystemService(NotificationManager::class.java)
manager.createNotificationChannels(
listOf(
NotificationChannel("jizhi_system", "系统通知", NotificationManager.IMPORTANCE_DEFAULT),
NotificationChannel("jizhi_budget", "预算提醒", NotificationManager.IMPORTANCE_HIGH),
NotificationChannel("jizhi_operations", "运营通知", NotificationManager.IMPORTANCE_DEFAULT),
),
)
}
private fun notificationsAllowed(): Boolean =
Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU ||
ContextCompat.checkSelfPermission(activity, Manifest.permission.POST_NOTIFICATIONS) ==
PackageManager.PERMISSION_GRANTED
private fun openNotificationSettings() {
val intent = Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS).apply {
putExtra(Settings.EXTRA_APP_PACKAGE, activity.packageName)
}
activity.startActivity(intent)
}
private fun parseOpen(intent: Intent?): Map<String, Any?>? {
if (intent == null) return null
val data = intent.data
if (data?.scheme == "miaoji" && data.host == "push") {
return mapOf(
"messageId" to data.getQueryParameter("messageId"),
"category" to data.getQueryParameter("category"),
"action" to data.getQueryParameter("action"),
"entityId" to data.getQueryParameter("entityId"),
)
}
val raw = intent.getStringExtra("jz_payload")
?: intent.getStringExtra("action_parameters")
?: return null
return runCatching {
val json = JSONObject(raw)
mapOf(
"messageId" to json.optString("messageId"),
"category" to json.optString("category"),
"action" to json.optString("action"),
"entityId" to json.optString("entityId").ifBlank { null },
)
}.getOrNull()
}
private fun readPendingOpen(): Map<String, Any?>? {
val raw = preferences.getString(KEY_PENDING, null) ?: return null
return runCatching {
val json = JSONObject(raw)
mapOf(
"messageId" to json.optString("messageId"),
"category" to json.optString("category"),
"action" to json.optString("action"),
"entityId" to json.optString("entityId").ifBlank { null },
)
}.getOrNull()
}
private fun dispatchPendingOpen() {
readPendingOpen()?.let { channel?.invokeMethod("onPushOpened", it) }
}
private fun cachedToken(): String? = preferences.getString(KEY_TOKEN, null)
private fun config(name: String): String = when (name) {
"PUSH_HUAWEI_APP_ID" -> BuildConfig.PUSH_HUAWEI_APP_ID
"PUSH_HONOR_APP_ID" -> BuildConfig.PUSH_HONOR_APP_ID
"PUSH_XIAOMI_APP_ID" -> BuildConfig.PUSH_XIAOMI_APP_ID
"PUSH_XIAOMI_APP_KEY" -> BuildConfig.PUSH_XIAOMI_APP_KEY
"PUSH_OPPO_APP_KEY" -> BuildConfig.PUSH_OPPO_APP_KEY
"PUSH_OPPO_APP_SECRET" -> BuildConfig.PUSH_OPPO_APP_SECRET
"PUSH_VIVO_APP_ID" -> BuildConfig.PUSH_VIVO_APP_ID
"PUSH_VIVO_APP_KEY" -> BuildConfig.PUSH_VIVO_APP_KEY
"PUSH_MEIZU_APP_ID" -> BuildConfig.PUSH_MEIZU_APP_ID
"PUSH_MEIZU_APP_KEY" -> BuildConfig.PUSH_MEIZU_APP_KEY
else -> ""
}
private fun firstClass(vararg names: String): Class<*>? =
names.firstNotNullOfOrNull { name -> runCatching { Class.forName(name) }.getOrNull() }
private fun invokeMatching(type: Class<*>, target: Any?, name: String, vararg args: Any?): Any? {
val method = type.methods.firstOrNull { candidate ->
candidate.name == name &&
candidate.parameterTypes.size == args.size &&
candidate.parameterTypes.indices.all { index ->
val argument = args[index]
argument == null || boxed(candidate.parameterTypes[index]).isInstance(argument)
}
} ?: return null
return method.invoke(target, *args)
}
private fun boxed(type: Class<*>): Class<*> = when (type) {
java.lang.Boolean.TYPE -> java.lang.Boolean::class.java
java.lang.Byte.TYPE -> java.lang.Byte::class.java
java.lang.Character.TYPE -> java.lang.Character::class.java
java.lang.Double.TYPE -> java.lang.Double::class.java
java.lang.Float.TYPE -> java.lang.Float::class.java
java.lang.Integer.TYPE -> java.lang.Integer::class.java
java.lang.Long.TYPE -> java.lang.Long::class.java
java.lang.Short.TYPE -> java.lang.Short::class.java
else -> type
}
private fun dynamicCallback(type: Class<*>): Any = Proxy.newProxyInstance(
type.classLoader,
arrayOf(type),
) { _, method, args ->
if (method.name.contains("register", ignoreCase = true)) {
val token = args?.firstOrNull { it is String && it.isNotBlank() } as? String
if (!token.isNullOrBlank()) preferences.edit().putString(KEY_TOKEN, token).apply()
}
null
}
private fun awaitTaskValue(value: Any?): String? {
if (value is String) return value
if (value == null) return null
repeat(20) {
val complete = runCatching {
value.javaClass.getMethod("isComplete").invoke(value) as? Boolean
}.getOrNull()
if (complete == true) {
return runCatching { value.javaClass.getMethod("getResult").invoke(value) as? String }.getOrNull()
}
Thread.sleep(200)
}
return null
}
}