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
+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,
};
}