Add domestic vendor push infrastructure
This commit is contained in:
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,12 @@
|
||||
allprojects {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
maven(url = "https://developer.huawei.com/repo/")
|
||||
maven(url = "https://developer.honor.com/repo")
|
||||
maven(url = "https://repos.xiaomi.com/maven")
|
||||
}
|
||||
}
|
||||
|
||||
val newBuildDir: Directory =
|
||||
rootProject.layout.buildDirectory
|
||||
|
||||
@@ -18,6 +18,7 @@ import 'package:miaoji_zhang/features/settings/budget_page.dart';
|
||||
import 'package:miaoji_zhang/features/settings/category_manage_page.dart';
|
||||
import 'package:miaoji_zhang/features/settings/companion_page.dart';
|
||||
import 'package:miaoji_zhang/features/settings/me_page.dart';
|
||||
import 'package:miaoji_zhang/features/settings/push_settings_page.dart';
|
||||
import 'package:miaoji_zhang/features/settings/recycle_bin_page.dart';
|
||||
import 'package:miaoji_zhang/features/settings/recognition_batch_page.dart';
|
||||
import 'package:miaoji_zhang/features/settings/legal_document_page.dart';
|
||||
@@ -34,6 +35,7 @@ import 'package:miaoji_zhang/shared/services/recognition_import_service.dart';
|
||||
import 'package:miaoji_zhang/shared/services/screenshot_channel.dart';
|
||||
import 'package:miaoji_zhang/shared/services/sync_service.dart';
|
||||
import 'package:miaoji_zhang/shared/services/session_store.dart';
|
||||
import 'package:miaoji_zhang/shared/services/push_service.dart';
|
||||
import 'package:miaoji_zhang/shared/theme/theme_store.dart';
|
||||
import 'package:miaoji_zhang/shared/update/update_coordinator.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
@@ -70,6 +72,10 @@ final router = GoRouter(
|
||||
GoRoute(path: '/budget', builder: (_, __) => const BudgetPage()),
|
||||
GoRoute(path: '/account-data', builder: (_, __) => const AccountDataPage()),
|
||||
GoRoute(path: '/appearance', builder: (_, __) => const AppearancePage()),
|
||||
GoRoute(
|
||||
path: '/notification-settings',
|
||||
builder: (_, __) => const PushSettingsPage(),
|
||||
),
|
||||
GoRoute(path: '/recycle-bin', builder: (_, __) => const RecycleBinPage()),
|
||||
GoRoute(
|
||||
path: '/sync-conflicts',
|
||||
@@ -147,6 +153,7 @@ class _MiaoJiAppState extends State<MiaoJiApp> with WidgetsBindingObserver {
|
||||
onError: _handleScreenshotError,
|
||||
);
|
||||
ScreenshotChannel.onRecognitionAction(_handleRecognitionAction);
|
||||
PushService.instance.setOpenHandler(_handlePushOpen);
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -170,6 +177,7 @@ class _MiaoJiAppState extends State<MiaoJiApp> with WidgetsBindingObserver {
|
||||
}
|
||||
await _runSafely(RecognitionImportService.configureNativeContext);
|
||||
await _runSafely(RecognitionImportService.importAutomatic);
|
||||
await _runSafely(PushService.instance.initialize);
|
||||
if (mounted) setState(() {});
|
||||
unawaited(_refreshRemoteState());
|
||||
}
|
||||
@@ -177,6 +185,7 @@ class _MiaoJiAppState extends State<MiaoJiApp> with WidgetsBindingObserver {
|
||||
Future<void> _resumeServices() async {
|
||||
await _runSafely(RecognitionImportService.configureNativeContext);
|
||||
await _runSafely(RecognitionImportService.importAutomatic);
|
||||
await _runSafely(PushService.instance.refresh);
|
||||
unawaited(_refreshRemoteState());
|
||||
}
|
||||
|
||||
@@ -213,6 +222,26 @@ class _MiaoJiAppState extends State<MiaoJiApp> with WidgetsBindingObserver {
|
||||
await RecognitionImportService.handleAction(context, action);
|
||||
}
|
||||
|
||||
Future<void> _handlePushOpen(PushOpen open) async {
|
||||
await Future<void>.delayed(const Duration(milliseconds: 150));
|
||||
final context = _rootNavigatorKey.currentContext;
|
||||
if (!mounted || context == null || !context.mounted) return;
|
||||
if (!SessionStore.instance.isAccount && open.action == 'budget') {
|
||||
router.go('/login', extra: null);
|
||||
return;
|
||||
}
|
||||
switch (open.action) {
|
||||
case 'home':
|
||||
router.go('/home');
|
||||
case 'budget':
|
||||
router.push('/budget');
|
||||
case 'update':
|
||||
await UpdateCoordinator.instance.checkManually(context);
|
||||
case 'none':
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
void _handleSessionExpired() {
|
||||
final context = _rootNavigatorKey.currentContext;
|
||||
if (context != null && context.mounted) {
|
||||
|
||||
@@ -7,6 +7,7 @@ import 'package:miaoji_zhang/shared/services/current_ledger_store.dart';
|
||||
import 'package:miaoji_zhang/shared/services/guest_merge_service.dart';
|
||||
import 'package:miaoji_zhang/shared/services/local_database.dart';
|
||||
import 'package:miaoji_zhang/shared/services/session_store.dart';
|
||||
import 'package:miaoji_zhang/shared/services/push_service.dart';
|
||||
import 'package:miaoji_zhang/shared/theme/app_theme.dart';
|
||||
import 'package:miaoji_zhang/shared/version.dart';
|
||||
import 'package:miaoji_zhang/shared/widgets/app_controls.dart';
|
||||
@@ -63,6 +64,7 @@ class _LoginPageState extends State<LoginPage> {
|
||||
_pass.text,
|
||||
);
|
||||
final profile = await AuthApi.me();
|
||||
await PushService.instance.refresh();
|
||||
await CurrentLedgerStore.instance.ensureLoaded(force: true);
|
||||
if (!mounted) return;
|
||||
if (guestSnapshot?['hasData'] == true) {
|
||||
|
||||
@@ -304,6 +304,14 @@ class _MePageState extends State<MePage> {
|
||||
'外观设置',
|
||||
onTap: () => context.push('/appearance'),
|
||||
),
|
||||
if (session.isAccount)
|
||||
_row(
|
||||
AppIcons.bell,
|
||||
context.jz.primaryBackground,
|
||||
AppTheme.primary,
|
||||
'通知设置',
|
||||
onTap: () => context.push('/notification-settings'),
|
||||
),
|
||||
if (session.isAccount &&
|
||||
SyncService.instance.conflictCount > 0)
|
||||
_row(
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:miaoji_zhang/shared/services/push_service.dart';
|
||||
import 'package:miaoji_zhang/shared/theme/app_theme.dart';
|
||||
import 'package:miaoji_zhang/shared/widgets/app_controls.dart';
|
||||
|
||||
class PushSettingsPage extends StatefulWidget {
|
||||
const PushSettingsPage({super.key});
|
||||
|
||||
@override
|
||||
State<PushSettingsPage> createState() => _PushSettingsPageState();
|
||||
}
|
||||
|
||||
class _PushSettingsPageState extends State<PushSettingsPage> {
|
||||
final service = PushService.instance;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
service.addListener(_changed);
|
||||
service.refresh();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
service.removeListener(_changed);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _changed() {
|
||||
if (mounted) setState(() {});
|
||||
}
|
||||
|
||||
Future<void> _toggle(String category, bool value) async {
|
||||
final ok = await service.setCategory(category, value);
|
||||
if (!ok && mounted && service.lastError != null) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(service.lastError!)));
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final status = service.nativeStatus;
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('通知设置')),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 24),
|
||||
children: [
|
||||
Card(
|
||||
child: Column(
|
||||
children: [
|
||||
_switchRow(
|
||||
'系统通知',
|
||||
'版本更新和重要服务状态',
|
||||
service.preferences.system,
|
||||
(value) => _toggle('system', value),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
_switchRow(
|
||||
'预算提醒',
|
||||
'预算达到 80% 或 100% 时提醒',
|
||||
service.preferences.budget,
|
||||
(value) => _toggle('budget', value),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
_switchRow(
|
||||
'运营通知',
|
||||
'活动和产品公告',
|
||||
service.preferences.operations,
|
||||
(value) => _toggle('operations', value),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'推送通道',
|
||||
style: TextStyle(fontSize: 14, fontWeight: FontWeight.w800),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
_providerLabel(status.provider),
|
||||
style: TextStyle(color: context.jz.text2, fontSize: 12),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
_statusLabel(status),
|
||||
style: TextStyle(
|
||||
color: status.notificationsAllowed
|
||||
? context.jz.text2
|
||||
: AppTheme.orange,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
if (!status.notificationsAllowed) ...[
|
||||
const SizedBox(height: 12),
|
||||
JzActionButton(
|
||||
label: '打开系统通知设置',
|
||||
onPressed: service.openNotificationSettings,
|
||||
secondary: true,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (service.loading) ...[
|
||||
const SizedBox(height: 16),
|
||||
const Center(child: CircularProgressIndicator(strokeWidth: 2)),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _switchRow(
|
||||
String title,
|
||||
String subtitle,
|
||||
bool value,
|
||||
ValueChanged<bool> onChanged,
|
||||
) => Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(title, style: const TextStyle(fontWeight: FontWeight.w700)),
|
||||
const SizedBox(height: 3),
|
||||
Text(
|
||||
subtitle,
|
||||
style: TextStyle(color: context.jz.text2, fontSize: 11.5),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Switch(value: value, onChanged: service.loading ? null : onChanged),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
String _providerLabel(String? provider) => switch (provider) {
|
||||
'huawei' => '华为 Push Kit',
|
||||
'honor' => '荣耀 Push Kit',
|
||||
'xiaomi' => '小米推送',
|
||||
'oppo' => 'OPPO 推送',
|
||||
'vivo' => 'vivo 推送',
|
||||
'meizu' => '魅族推送',
|
||||
_ => '当前设备没有可用的国产厂商通道',
|
||||
};
|
||||
|
||||
String _statusLabel(PushNativeStatus status) {
|
||||
if (!status.supported) return '不支持';
|
||||
if (!status.sdkAvailable) return '当前安装包未配置对应厂商 SDK';
|
||||
if (!status.notificationsAllowed) return '系统通知权限已关闭';
|
||||
if (status.token?.isNotEmpty == true) return '已连接';
|
||||
if (status.enabled) return '正在获取厂商令牌';
|
||||
return '未启用';
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import 'package:miaoji_zhang/shared/api/api_client.dart';
|
||||
import 'package:miaoji_zhang/shared/services/current_ledger_store.dart';
|
||||
import 'package:miaoji_zhang/shared/services/local_export_service.dart';
|
||||
import 'package:miaoji_zhang/shared/services/session_store.dart';
|
||||
import 'package:miaoji_zhang/shared/services/push_service.dart';
|
||||
import 'package:miaoji_zhang/shared/services/shanghai_time.dart';
|
||||
|
||||
class AiCompanion {
|
||||
@@ -237,6 +238,7 @@ class AuthApi {
|
||||
|
||||
static Future<void> logout() async {
|
||||
CurrentLedgerStore.instance.clear();
|
||||
await PushService.instance.logout();
|
||||
await ApiClient.instance.clearToken();
|
||||
await SessionStore.instance.clearActiveSession();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:miaoji_zhang/shared/api/api_client.dart';
|
||||
|
||||
class PushPreferences {
|
||||
final bool system;
|
||||
final bool budget;
|
||||
final bool operations;
|
||||
|
||||
const PushPreferences({
|
||||
this.system = false,
|
||||
this.budget = false,
|
||||
this.operations = false,
|
||||
});
|
||||
|
||||
bool get anyEnabled => system || budget || operations;
|
||||
|
||||
PushPreferences copyWith({bool? system, bool? budget, bool? operations}) =>
|
||||
PushPreferences(
|
||||
system: system ?? this.system,
|
||||
budget: budget ?? this.budget,
|
||||
operations: operations ?? this.operations,
|
||||
);
|
||||
|
||||
factory PushPreferences.fromJson(Map<String, dynamic> json) =>
|
||||
PushPreferences(
|
||||
system: json['system'] as bool? ?? false,
|
||||
budget: json['budget'] as bool? ?? false,
|
||||
operations: json['operations'] as bool? ?? false,
|
||||
);
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'system': system,
|
||||
'budget': budget,
|
||||
'operations': operations,
|
||||
};
|
||||
}
|
||||
|
||||
class PushRegistration {
|
||||
final int deviceId;
|
||||
final String unbindToken;
|
||||
|
||||
const PushRegistration({required this.deviceId, required this.unbindToken});
|
||||
|
||||
factory PushRegistration.fromJson(Map<String, dynamic> json) =>
|
||||
PushRegistration(
|
||||
deviceId: (json['deviceId'] as num).toInt(),
|
||||
unbindToken: json['unbindToken'] as String,
|
||||
);
|
||||
}
|
||||
|
||||
class PushApi {
|
||||
static final Dio _dio = ApiClient.instance.dio;
|
||||
|
||||
static Future<PushPreferences> preferences() async {
|
||||
final response = await _dio.get('/api/push/preferences');
|
||||
return PushPreferences.fromJson(response.data as Map<String, dynamic>);
|
||||
}
|
||||
|
||||
static Future<PushPreferences> updatePreferences(
|
||||
PushPreferences preferences,
|
||||
) async {
|
||||
final response = await _dio.put(
|
||||
'/api/push/preferences',
|
||||
data: preferences.toJson(),
|
||||
);
|
||||
return PushPreferences.fromJson(response.data as Map<String, dynamic>);
|
||||
}
|
||||
|
||||
static Future<PushRegistration> registerDevice({
|
||||
required String installationId,
|
||||
required String provider,
|
||||
required String token,
|
||||
required String packageName,
|
||||
required String flavor,
|
||||
required String appVersion,
|
||||
required int versionCode,
|
||||
required bool notificationsAllowed,
|
||||
}) async {
|
||||
final response = await _dio.put(
|
||||
'/api/push/devices/$installationId',
|
||||
data: {
|
||||
'provider': provider,
|
||||
'token': token,
|
||||
'packageName': packageName,
|
||||
'flavor': flavor,
|
||||
'appVersion': appVersion,
|
||||
'versionCode': versionCode,
|
||||
'notificationsAllowed': notificationsAllowed,
|
||||
},
|
||||
);
|
||||
return PushRegistration.fromJson(response.data as Map<String, dynamic>);
|
||||
}
|
||||
|
||||
static Future<void> unregisterDevice({
|
||||
required String installationId,
|
||||
String? unbindToken,
|
||||
}) async {
|
||||
await _dio.delete<void>(
|
||||
'/api/push/devices/$installationId',
|
||||
options: unbindToken == null
|
||||
? null
|
||||
: Options(headers: {'X-Push-Unbind-Token': unbindToken}),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,338 @@
|
||||
import 'dart:async';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:miaoji_zhang/shared/api/api_client.dart';
|
||||
import 'package:miaoji_zhang/shared/api/push_api.dart';
|
||||
import 'package:miaoji_zhang/shared/services/screenshot_channel.dart';
|
||||
import 'package:miaoji_zhang/shared/services/session_store.dart';
|
||||
import 'package:miaoji_zhang/shared/version.dart';
|
||||
|
||||
class PushNativeStatus {
|
||||
final String? provider;
|
||||
final bool supported;
|
||||
final bool sdkAvailable;
|
||||
final bool notificationsAllowed;
|
||||
final bool enabled;
|
||||
final String? token;
|
||||
final String? error;
|
||||
|
||||
const PushNativeStatus({
|
||||
this.provider,
|
||||
this.supported = false,
|
||||
this.sdkAvailable = false,
|
||||
this.notificationsAllowed = false,
|
||||
this.enabled = false,
|
||||
this.token,
|
||||
this.error,
|
||||
});
|
||||
|
||||
factory PushNativeStatus.fromMap(Map<dynamic, dynamic>? map) =>
|
||||
PushNativeStatus(
|
||||
provider: map?['provider'] as String?,
|
||||
supported: map?['supported'] as bool? ?? false,
|
||||
sdkAvailable: map?['sdkAvailable'] as bool? ?? false,
|
||||
notificationsAllowed: map?['notificationsAllowed'] as bool? ?? false,
|
||||
enabled: map?['enabled'] as bool? ?? false,
|
||||
token: map?['token'] as String?,
|
||||
error: map?['error'] as String?,
|
||||
);
|
||||
}
|
||||
|
||||
class PushOpen {
|
||||
final String messageId;
|
||||
final String category;
|
||||
final String action;
|
||||
final String? entityId;
|
||||
|
||||
const PushOpen({
|
||||
required this.messageId,
|
||||
required this.category,
|
||||
required this.action,
|
||||
this.entityId,
|
||||
});
|
||||
|
||||
factory PushOpen.fromMap(Map<dynamic, dynamic> map) => PushOpen(
|
||||
messageId: map['messageId']?.toString() ?? '',
|
||||
category: map['category']?.toString() ?? 'system',
|
||||
action: map['action']?.toString() ?? 'none',
|
||||
entityId: map['entityId']?.toString(),
|
||||
);
|
||||
}
|
||||
|
||||
class PushService extends ChangeNotifier {
|
||||
PushService._();
|
||||
|
||||
static final instance = PushService._();
|
||||
static const _channel = MethodChannel('com.miaoji/push');
|
||||
static const _storage = FlutterSecureStorage();
|
||||
static const _installationKey = 'push_installation_id';
|
||||
static const _unbindKey = 'push_unbind_token';
|
||||
static const _pendingUnbindInstallationKey = 'push_pending_unbind_id';
|
||||
static const _pendingUnbindTokenKey = 'push_pending_unbind_token';
|
||||
static const _consumedKey = 'push_consumed_message_ids';
|
||||
|
||||
PushPreferences preferences = const PushPreferences();
|
||||
PushNativeStatus nativeStatus = const PushNativeStatus();
|
||||
bool loading = false;
|
||||
bool initialized = false;
|
||||
String? lastError;
|
||||
Future<void> Function(PushOpen open)? _openHandler;
|
||||
|
||||
void setOpenHandler(Future<void> Function(PushOpen open) handler) {
|
||||
_openHandler = handler;
|
||||
}
|
||||
|
||||
Future<void> initialize() async {
|
||||
if (!initialized) {
|
||||
initialized = true;
|
||||
_channel.setMethodCallHandler(_handleNativeCall);
|
||||
}
|
||||
await _retryPendingUnbind();
|
||||
await refresh();
|
||||
try {
|
||||
final pending = await _channel.invokeMapMethod<dynamic, dynamic>(
|
||||
'getPendingOpen',
|
||||
);
|
||||
if (pending != null) await _handleOpen(PushOpen.fromMap(pending));
|
||||
} on MissingPluginException {
|
||||
// Push is Android-only.
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> refresh() async {
|
||||
if (!SessionStore.instance.isAccount ||
|
||||
SessionStore.instance.shouldUseLocalOnly) {
|
||||
preferences = const PushPreferences();
|
||||
await _readNativeStatus();
|
||||
notifyListeners();
|
||||
return;
|
||||
}
|
||||
loading = true;
|
||||
lastError = null;
|
||||
notifyListeners();
|
||||
try {
|
||||
preferences = await PushApi.preferences();
|
||||
await _readNativeStatus();
|
||||
if (preferences.anyEnabled) {
|
||||
if (nativeStatus.token?.isNotEmpty == true) {
|
||||
await _register(nativeStatus);
|
||||
} else if (nativeStatus.notificationsAllowed &&
|
||||
nativeStatus.supported &&
|
||||
nativeStatus.sdkAvailable) {
|
||||
await _refreshNativeToken();
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
lastError = apiErrorMessage(error);
|
||||
} finally {
|
||||
loading = false;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> setCategory(String category, bool enabled) async {
|
||||
if (!SessionStore.instance.isAccount) return false;
|
||||
loading = true;
|
||||
lastError = null;
|
||||
notifyListeners();
|
||||
try {
|
||||
if (enabled) {
|
||||
final granted = await ScreenshotChannel.requestNotificationPermission();
|
||||
if (!granted) {
|
||||
await _readNativeStatus();
|
||||
lastError = '系统通知权限未开启';
|
||||
return false;
|
||||
}
|
||||
}
|
||||
final next = switch (category) {
|
||||
'system' => preferences.copyWith(system: enabled),
|
||||
'budget' => preferences.copyWith(budget: enabled),
|
||||
'operations' => preferences.copyWith(operations: enabled),
|
||||
_ => throw ArgumentError.value(category, 'category'),
|
||||
};
|
||||
preferences = await PushApi.updatePreferences(next);
|
||||
if (!preferences.anyEnabled) {
|
||||
await _unregisterCurrent();
|
||||
await _invokeNative('disable');
|
||||
await _readNativeStatus();
|
||||
} else if (enabled) {
|
||||
final map = await _channel.invokeMapMethod<dynamic, dynamic>('enable');
|
||||
nativeStatus = PushNativeStatus.fromMap(map);
|
||||
if (nativeStatus.token?.isNotEmpty == true) {
|
||||
await _register(nativeStatus);
|
||||
} else {
|
||||
lastError = _statusMessage(nativeStatus);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
} catch (error) {
|
||||
lastError = apiErrorMessage(error);
|
||||
return false;
|
||||
} finally {
|
||||
loading = false;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> openNotificationSettings() =>
|
||||
_invokeNative('openNotificationSettings');
|
||||
|
||||
Future<void> logout() async {
|
||||
await _unregisterCurrent(queueOnFailure: true);
|
||||
await _invokeNative('disable');
|
||||
preferences = const PushPreferences();
|
||||
nativeStatus = const PushNativeStatus();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<dynamic> _handleNativeCall(MethodCall call) async {
|
||||
if (call.method == 'onToken') {
|
||||
final status = PushNativeStatus.fromMap(call.arguments as Map?);
|
||||
nativeStatus = PushNativeStatus(
|
||||
provider: status.provider,
|
||||
supported: true,
|
||||
sdkAvailable: true,
|
||||
notificationsAllowed: true,
|
||||
enabled: true,
|
||||
token: status.token,
|
||||
);
|
||||
if (preferences.anyEnabled && SessionStore.instance.isAccount) {
|
||||
await _register(nativeStatus);
|
||||
}
|
||||
notifyListeners();
|
||||
} else if (call.method == 'onPushOpened' && call.arguments is Map) {
|
||||
await _handleOpen(PushOpen.fromMap(call.arguments as Map));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _handleOpen(PushOpen open) async {
|
||||
if (open.messageId.isEmpty) return;
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final consumed = prefs.getStringList(_consumedKey) ?? <String>[];
|
||||
if (!consumed.contains(open.messageId)) {
|
||||
await _openHandler?.call(open);
|
||||
consumed.add(open.messageId);
|
||||
if (consumed.length > 50) consumed.removeRange(0, consumed.length - 50);
|
||||
await prefs.setStringList(_consumedKey, consumed);
|
||||
}
|
||||
await _channel.invokeMethod('acknowledgeOpen', {
|
||||
'messageId': open.messageId,
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _readNativeStatus() async {
|
||||
try {
|
||||
final map = await _channel.invokeMapMethod<dynamic, dynamic>('getStatus');
|
||||
nativeStatus = PushNativeStatus.fromMap(map);
|
||||
} on MissingPluginException {
|
||||
nativeStatus = const PushNativeStatus(error: 'platform_not_supported');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _refreshNativeToken() async {
|
||||
final map = await _channel.invokeMapMethod<dynamic, dynamic>(
|
||||
'refreshToken',
|
||||
);
|
||||
nativeStatus = PushNativeStatus.fromMap(map);
|
||||
if (nativeStatus.token?.isNotEmpty == true) await _register(nativeStatus);
|
||||
}
|
||||
|
||||
Future<void> _register(PushNativeStatus status) async {
|
||||
final provider = status.provider;
|
||||
final token = status.token;
|
||||
if (provider == null || token == null || token.isEmpty) return;
|
||||
final installationId = await _installationId();
|
||||
final internal = ApiClient.isInternalBuild;
|
||||
final registration = await PushApi.registerDevice(
|
||||
installationId: installationId,
|
||||
provider: provider,
|
||||
token: token,
|
||||
packageName: internal ? 'com.nx.miaoji.internal' : 'com.nx.miaoji',
|
||||
flavor: internal ? 'internal' : 'production',
|
||||
appVersion: AppVersion.versionName,
|
||||
versionCode: AppVersion.buildNumber,
|
||||
notificationsAllowed: status.notificationsAllowed,
|
||||
);
|
||||
await _storage.write(key: _unbindKey, value: registration.unbindToken);
|
||||
}
|
||||
|
||||
Future<void> _unregisterCurrent({bool queueOnFailure = false}) async {
|
||||
final installationId = await _storage.read(key: _installationKey);
|
||||
final unbindToken = await _storage.read(key: _unbindKey);
|
||||
if (installationId == null || unbindToken == null) return;
|
||||
try {
|
||||
await PushApi.unregisterDevice(
|
||||
installationId: installationId,
|
||||
unbindToken: unbindToken,
|
||||
);
|
||||
await _storage.delete(key: _unbindKey);
|
||||
} catch (_) {
|
||||
if (queueOnFailure) {
|
||||
await _storage.write(
|
||||
key: _pendingUnbindInstallationKey,
|
||||
value: installationId,
|
||||
);
|
||||
await _storage.write(key: _pendingUnbindTokenKey, value: unbindToken);
|
||||
await _storage.delete(key: _unbindKey);
|
||||
} else {
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _retryPendingUnbind() async {
|
||||
final installationId = await _storage.read(
|
||||
key: _pendingUnbindInstallationKey,
|
||||
);
|
||||
final unbindToken = await _storage.read(key: _pendingUnbindTokenKey);
|
||||
if (installationId == null || unbindToken == null) return;
|
||||
try {
|
||||
await PushApi.unregisterDevice(
|
||||
installationId: installationId,
|
||||
unbindToken: unbindToken,
|
||||
);
|
||||
await _storage.delete(key: _pendingUnbindInstallationKey);
|
||||
await _storage.delete(key: _pendingUnbindTokenKey);
|
||||
} catch (_) {
|
||||
// Retried on the next launch or resume.
|
||||
}
|
||||
}
|
||||
|
||||
Future<String> _installationId() async {
|
||||
final existing = await _storage.read(key: _installationKey);
|
||||
if (existing != null) return existing;
|
||||
final random = Random.secure();
|
||||
final bytes = List<int>.generate(16, (_) => random.nextInt(256));
|
||||
bytes[6] = (bytes[6] & 0x0f) | 0x40;
|
||||
bytes[8] = (bytes[8] & 0x3f) | 0x80;
|
||||
String hex(int start, int end) => bytes
|
||||
.sublist(start, end)
|
||||
.map((value) => value.toRadixString(16).padLeft(2, '0'))
|
||||
.join();
|
||||
final value =
|
||||
'${hex(0, 4)}-${hex(4, 6)}-${hex(6, 8)}-${hex(8, 10)}-${hex(10, 16)}';
|
||||
await _storage.write(key: _installationKey, value: value);
|
||||
return value;
|
||||
}
|
||||
|
||||
Future<void> _invokeNative(String method) async {
|
||||
try {
|
||||
await _channel.invokeMethod(method);
|
||||
} on MissingPluginException {
|
||||
// Push is Android-only.
|
||||
}
|
||||
}
|
||||
|
||||
static String? _statusMessage(PushNativeStatus status) =>
|
||||
switch (status.error) {
|
||||
'unsupported_vendor' => '当前设备不支持国产厂商推送',
|
||||
'sdk_not_installed' => '当前安装包未配置对应厂商推送 SDK',
|
||||
'token_pending' => '厂商令牌正在生成,请稍后重试',
|
||||
'notification_permission_denied' => '系统通知权限未开启',
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user