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
}
}
+8 -5
View File
@@ -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
+29
View File
@@ -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 '未启用';
}
}
+2
View File
@@ -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();
}
+105
View File
@@ -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,
};
}