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? 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 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 Function(PushOpen open)? _openHandler; void setOpenHandler(Future Function(PushOpen open) handler) { _openHandler = handler; } Future initialize() async { if (!initialized) { initialized = true; _channel.setMethodCallHandler(_handleNativeCall); } await _retryPendingUnbind(); await refresh(); try { final pending = await _channel.invokeMapMethod( 'getPendingOpen', ); if (pending != null) await _handleOpen(PushOpen.fromMap(pending)); } on MissingPluginException { // Push is Android-only. } } Future 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 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('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 openNotificationSettings() => _invokeNative('openNotificationSettings'); Future logout() async { await _unregisterCurrent(queueOnFailure: true); await _invokeNative('disable'); preferences = const PushPreferences(); nativeStatus = const PushNativeStatus(); notifyListeners(); } Future _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 _handleOpen(PushOpen open) async { if (open.messageId.isEmpty) return; final prefs = await SharedPreferences.getInstance(); final consumed = prefs.getStringList(_consumedKey) ?? []; 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 _readNativeStatus() async { try { final map = await _channel.invokeMapMethod('getStatus'); nativeStatus = PushNativeStatus.fromMap(map); } on MissingPluginException { nativeStatus = const PushNativeStatus(error: 'platform_not_supported'); } } Future _refreshNativeToken() async { final map = await _channel.invokeMapMethod( 'refreshToken', ); nativeStatus = PushNativeStatus.fromMap(map); if (nativeStatus.token?.isNotEmpty == true) await _register(nativeStatus); } Future _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 _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 _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 _installationId() async { final existing = await _storage.read(key: _installationKey); if (existing != null) return existing; final random = Random.secure(); final bytes = List.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 _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, }; }