Add domestic vendor push infrastructure
This commit is contained in:
@@ -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