482 lines
16 KiB
Dart
482 lines
16 KiB
Dart
import 'dart:convert';
|
|
|
|
import 'package:flutter/services.dart';
|
|
|
|
class RecognitionDiagnostic {
|
|
final DateTime at;
|
|
final String appName, stage, result, reason, statusStrength;
|
|
final String? recognitionKind, amountSource, resultFingerprint;
|
|
final int nodeCount, amountCandidates;
|
|
final int? ocrMs;
|
|
final bool? expectedAmountMatched, resultTransitionObserved;
|
|
final List<String> ocrPreview;
|
|
final DateTime? previewExpiresAt;
|
|
|
|
const RecognitionDiagnostic({
|
|
required this.at,
|
|
required this.appName,
|
|
required this.stage,
|
|
required this.result,
|
|
required this.reason,
|
|
required this.nodeCount,
|
|
required this.amountCandidates,
|
|
this.ocrMs,
|
|
this.statusStrength = 'none',
|
|
this.recognitionKind,
|
|
this.amountSource,
|
|
this.resultFingerprint,
|
|
this.expectedAmountMatched,
|
|
this.resultTransitionObserved,
|
|
this.ocrPreview = const [],
|
|
this.previewExpiresAt,
|
|
});
|
|
|
|
factory RecognitionDiagnostic.fromJson(Map<String, dynamic> value) {
|
|
final epoch = (value['at'] as num?)?.toInt() ?? 0;
|
|
return RecognitionDiagnostic(
|
|
at: DateTime.fromMillisecondsSinceEpoch(epoch),
|
|
appName: value['appName']?.toString() ?? '支付应用',
|
|
stage: value['stage']?.toString() ?? 'event',
|
|
result: value['result']?.toString() ?? 'unknown',
|
|
reason: value['reason']?.toString() ?? '',
|
|
nodeCount: (value['nodeCount'] as num?)?.toInt() ?? 0,
|
|
amountCandidates: (value['amountCandidates'] as num?)?.toInt() ?? 0,
|
|
ocrMs: (value['ocrMs'] as num?)?.toInt(),
|
|
statusStrength: value['statusStrength']?.toString() ?? 'none',
|
|
recognitionKind: value['recognitionKind']?.toString(),
|
|
amountSource: value['amountSource']?.toString(),
|
|
resultFingerprint: value['resultFingerprint']?.toString(),
|
|
expectedAmountMatched: value['expectedAmountMatched'] as bool?,
|
|
resultTransitionObserved: value['resultTransitionObserved'] as bool?,
|
|
ocrPreview:
|
|
(value['ocrPreview'] as List<dynamic>?)
|
|
?.map((item) => item.toString())
|
|
.toList(growable: false) ??
|
|
const [],
|
|
previewExpiresAt: (value['previewExpiresAt'] as num?) == null
|
|
? null
|
|
: DateTime.fromMillisecondsSinceEpoch(
|
|
(value['previewExpiresAt'] as num).toInt(),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class RecognitionStatus {
|
|
final bool accessibilityAuthorized;
|
|
final bool accessibilityConnected;
|
|
final bool notificationAuthorized;
|
|
final bool notificationConnected;
|
|
final bool postNotificationsGranted;
|
|
final bool accessibilityEvents;
|
|
final bool notificationEvents;
|
|
final bool aiScreenshot;
|
|
final bool ocrDiagnosticPreview;
|
|
final DateTime? ocrDiagnosticPreviewExpiresAt;
|
|
final bool batteryOptimizationIgnored;
|
|
final String manufacturer;
|
|
final String? latestStatus;
|
|
final RecognitionDiagnostic? latestDiagnostic;
|
|
|
|
const RecognitionStatus({
|
|
required this.accessibilityAuthorized,
|
|
required this.accessibilityConnected,
|
|
required this.notificationAuthorized,
|
|
required this.notificationConnected,
|
|
required this.postNotificationsGranted,
|
|
required this.accessibilityEvents,
|
|
required this.notificationEvents,
|
|
required this.aiScreenshot,
|
|
this.ocrDiagnosticPreview = false,
|
|
this.ocrDiagnosticPreviewExpiresAt,
|
|
this.batteryOptimizationIgnored = false,
|
|
this.manufacturer = '',
|
|
this.latestStatus,
|
|
this.latestDiagnostic,
|
|
});
|
|
|
|
factory RecognitionStatus.fromMap(Map<Object?, Object?> value) {
|
|
final rawSettings = value['settings']?.toString();
|
|
final rawDiagnostic = value['latestDiagnostic']?.toString();
|
|
final settings = rawSettings == null || rawSettings.isEmpty
|
|
? const <String, dynamic>{}
|
|
: jsonDecode(rawSettings) as Map<String, dynamic>;
|
|
return RecognitionStatus(
|
|
accessibilityAuthorized:
|
|
value['accessibilityAuthorized'] as bool? ?? false,
|
|
accessibilityConnected: value['accessibilityConnected'] as bool? ?? false,
|
|
notificationAuthorized: value['notificationAuthorized'] as bool? ?? false,
|
|
notificationConnected: value['notificationConnected'] as bool? ?? false,
|
|
postNotificationsGranted:
|
|
value['postNotificationsGranted'] as bool? ?? true,
|
|
accessibilityEvents: settings['accessibilityEvents'] as bool? ?? false,
|
|
notificationEvents: settings['notificationEvents'] as bool? ?? false,
|
|
aiScreenshot: settings['aiScreenshot'] as bool? ?? false,
|
|
ocrDiagnosticPreview: settings['ocrDiagnosticPreview'] as bool? ?? false,
|
|
ocrDiagnosticPreviewExpiresAt:
|
|
(settings['ocrDiagnosticPreviewExpiresAt'] as num?) == null
|
|
? null
|
|
: DateTime.fromMillisecondsSinceEpoch(
|
|
(settings['ocrDiagnosticPreviewExpiresAt'] as num).toInt(),
|
|
),
|
|
batteryOptimizationIgnored:
|
|
value['batteryOptimizationIgnored'] as bool? ?? false,
|
|
manufacturer: value['manufacturer']?.toString() ?? '',
|
|
latestStatus: value['latestStatus']?.toString(),
|
|
latestDiagnostic: rawDiagnostic == null || rawDiagnostic.isEmpty
|
|
? null
|
|
: RecognitionDiagnostic.fromJson(
|
|
jsonDecode(rawDiagnostic) as Map<String, dynamic>,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class RecognitionCandidate {
|
|
final String id, clientRequestId, state, confidence, type, source, appName;
|
|
final double amount;
|
|
final String? merchant, orderId, sourceText, note;
|
|
final String recognitionKind, amountSource;
|
|
final String? categoryHint, resultFingerprint;
|
|
final int occurredAtEpochMs;
|
|
|
|
RecognitionCandidate.fromJson(Map<String, dynamic> value)
|
|
: id = value['id'] as String,
|
|
clientRequestId = value['clientRequestId'] as String,
|
|
state = value['state'] as String,
|
|
confidence = value['confidence'] as String,
|
|
type = value['type'] as String,
|
|
source = value['source'] as String,
|
|
appName = value['appName'] as String? ?? '支付应用',
|
|
amount = (value['amount'] as num).toDouble(),
|
|
merchant = value['merchant'] as String?,
|
|
orderId = value['orderId'] as String?,
|
|
sourceText = value['sourceText'] as String?,
|
|
note = value['note'] as String?,
|
|
recognitionKind = value['recognitionKind']?.toString() ?? 'payment',
|
|
categoryHint = value['categoryHint']?.toString(),
|
|
amountSource = value['amountSource']?.toString() ?? 'result',
|
|
resultFingerprint = value['resultFingerprint']?.toString(),
|
|
occurredAtEpochMs = (value['occurredAtEpochMs'] as num).toInt();
|
|
|
|
bool get canAutoImport => state == 'auto_ready' && confidence == 'auto';
|
|
}
|
|
|
|
class SpeechEvent {
|
|
final String type;
|
|
final String? text;
|
|
final double rms;
|
|
|
|
const SpeechEvent({required this.type, this.text, this.rms = 0});
|
|
}
|
|
|
|
/// Android native capabilities for screenshots, AI progress and speech.
|
|
class ScreenshotChannel {
|
|
static const _channel = MethodChannel('com.miaoji/screenshot');
|
|
static void Function(String path)? _screenshotReady;
|
|
static void Function(String error)? _screenshotError;
|
|
static void Function(SpeechEvent event)? _speechEvent;
|
|
static void Function(Map<String, dynamic> action)? _recognitionAction;
|
|
static bool _handlerInstalled = false;
|
|
|
|
static Future<void> cleanupStaleScreenshots() async {
|
|
try {
|
|
await _channel.invokeMethod<int>('cleanupStaleScreenshots');
|
|
} on PlatformException {
|
|
// Cleanup is best-effort and will run again on the next launch.
|
|
} on MissingPluginException {
|
|
// Screenshot storage is Android-only.
|
|
}
|
|
}
|
|
|
|
static Future<String?> capture() async {
|
|
try {
|
|
return await _channel.invokeMethod<String>('captureScreenshot');
|
|
} on PlatformException catch (e) {
|
|
throw Exception(e.message ?? '截屏失败,请重试');
|
|
} on MissingPluginException {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
static Future<void> startAiProgress() async {
|
|
try {
|
|
await _channel.invokeMethod<bool>('startAiProgress');
|
|
} on MissingPluginException {
|
|
// Non-Android platforms do not expose native progress notifications.
|
|
}
|
|
}
|
|
|
|
static Future<void> updateAiProgress(int count) async {
|
|
try {
|
|
await _channel.invokeMethod<bool>('updateAiProgress', {'count': count});
|
|
} on MissingPluginException {
|
|
// Non-Android platforms do not expose native progress notifications.
|
|
}
|
|
}
|
|
|
|
static Future<void> finishAiProgress(int count, double total) async {
|
|
try {
|
|
await _channel.invokeMethod<bool>('finishAiProgress', {
|
|
'count': count,
|
|
'total': total,
|
|
});
|
|
} on MissingPluginException {
|
|
// Non-Android platforms do not expose native progress notifications.
|
|
}
|
|
}
|
|
|
|
static Future<void> failAiProgress(String message) async {
|
|
try {
|
|
await _channel.invokeMethod<bool>('failAiProgress', {'message': message});
|
|
} on MissingPluginException {
|
|
// Non-Android platforms do not expose native progress notifications.
|
|
}
|
|
}
|
|
|
|
static Future<String?> recognizeSpeech() async {
|
|
try {
|
|
return await _channel.invokeMethod<String>('recognizeSpeech');
|
|
} on PlatformException catch (e) {
|
|
throw Exception(e.message ?? '语音识别不可用');
|
|
} on MissingPluginException {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
static Future<void> startSpeechRecognition(
|
|
void Function(SpeechEvent event) onEvent,
|
|
) async {
|
|
_speechEvent = onEvent;
|
|
_installHandler();
|
|
try {
|
|
await _channel.invokeMethod<bool>('startSpeechRecognition');
|
|
} on PlatformException catch (e) {
|
|
_speechEvent = null;
|
|
throw Exception(e.message ?? '语音识别不可用');
|
|
} on MissingPluginException {
|
|
_speechEvent = null;
|
|
throw Exception('当前设备不支持语音识别');
|
|
}
|
|
}
|
|
|
|
static Future<void> stopSpeechRecognition() async {
|
|
try {
|
|
await _channel.invokeMethod<bool>('stopSpeechRecognition');
|
|
} on MissingPluginException {
|
|
// No native recognizer to stop.
|
|
}
|
|
}
|
|
|
|
static Future<void> cancelSpeechRecognition() async {
|
|
_speechEvent = null;
|
|
try {
|
|
await _channel.invokeMethod<bool>('cancelSpeechRecognition');
|
|
} on MissingPluginException {
|
|
// No native recognizer to cancel.
|
|
}
|
|
}
|
|
|
|
static Future<bool> isAccessibilityEnabled() async {
|
|
try {
|
|
return await _channel.invokeMethod<bool>('isAccessibilityEnabled') ??
|
|
false;
|
|
} on MissingPluginException {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
static Future<void> openAccessibilitySettings() async {
|
|
try {
|
|
await _channel.invokeMethod('openAccessibilitySettings');
|
|
} on MissingPluginException {
|
|
// Accessibility shortcut is Android-only.
|
|
}
|
|
}
|
|
|
|
static Future<void> openQuickSettings() async {
|
|
try {
|
|
await _channel.invokeMethod('openQuickSettings');
|
|
} on MissingPluginException {
|
|
// Quick settings tiles are Android-only.
|
|
}
|
|
}
|
|
|
|
static Future<RecognitionStatus> recognitionStatus() async {
|
|
try {
|
|
final raw = await _channel.invokeMethod<Map<Object?, Object?>>(
|
|
'getRecognitionStatus',
|
|
);
|
|
return RecognitionStatus.fromMap(raw ?? const {});
|
|
} on MissingPluginException {
|
|
return const RecognitionStatus(
|
|
accessibilityAuthorized: false,
|
|
accessibilityConnected: false,
|
|
notificationAuthorized: false,
|
|
notificationConnected: false,
|
|
postNotificationsGranted: true,
|
|
accessibilityEvents: false,
|
|
notificationEvents: false,
|
|
aiScreenshot: false,
|
|
);
|
|
}
|
|
}
|
|
|
|
static Future<bool> clearRecognitionDiagnostic() async {
|
|
try {
|
|
return await _channel.invokeMethod<bool>('clearRecognitionDiagnostic') ??
|
|
false;
|
|
} on MissingPluginException {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
static Future<bool> setRecognitionToggle(String key, bool enabled) async {
|
|
try {
|
|
return await _channel.invokeMethod<bool>('setRecognitionToggle', {
|
|
'key': key,
|
|
'enabled': enabled,
|
|
}) ??
|
|
false;
|
|
} on MissingPluginException {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
static Future<void> configureRecognitionContext({
|
|
required bool hasAccount,
|
|
required bool aiAllowed,
|
|
required String baseUrl,
|
|
String? token,
|
|
}) async {
|
|
try {
|
|
await _channel.invokeMethod<bool>('configureRecognitionContext', {
|
|
'hasAccount': hasAccount,
|
|
'aiAllowed': aiAllowed,
|
|
'baseUrl': baseUrl,
|
|
'token': token,
|
|
});
|
|
} on MissingPluginException {
|
|
// Native background recognition is Android-only.
|
|
}
|
|
}
|
|
|
|
static Future<List<RecognitionCandidate>> drainRecognitionCandidates() async {
|
|
try {
|
|
final values =
|
|
await _channel.invokeMethod<List<Object?>>(
|
|
'drainRecognitionCandidates',
|
|
) ??
|
|
const [];
|
|
return values
|
|
.whereType<String>()
|
|
.map(
|
|
(value) => RecognitionCandidate.fromJson(
|
|
jsonDecode(value) as Map<String, dynamic>,
|
|
),
|
|
)
|
|
.toList();
|
|
} on MissingPluginException {
|
|
return const [];
|
|
}
|
|
}
|
|
|
|
static Future<bool> acknowledgeRecognitionCandidate(
|
|
String id,
|
|
String state, {
|
|
int? transactionId,
|
|
}) async {
|
|
try {
|
|
return await _channel.invokeMethod<bool>('ackRecognitionCandidate', {
|
|
'id': id,
|
|
'state': state,
|
|
if (transactionId != null) 'transactionId': transactionId,
|
|
}) ??
|
|
false;
|
|
} on MissingPluginException {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
static Future<bool> requestNotificationPermission() async {
|
|
try {
|
|
return await _channel.invokeMethod<bool>(
|
|
'requestNotificationPermission',
|
|
) ??
|
|
false;
|
|
} on MissingPluginException {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
static Future<void> openNotificationAccessSettings() async {
|
|
try {
|
|
await _channel.invokeMethod('openNotificationAccessSettings');
|
|
} on MissingPluginException {
|
|
// Notification access is Android-only.
|
|
}
|
|
}
|
|
|
|
static Future<void> openBatteryOptimizationSettings() async {
|
|
try {
|
|
await _channel.invokeMethod('openBatteryOptimizationSettings');
|
|
} on MissingPluginException {
|
|
// Background battery settings are Android-only.
|
|
}
|
|
}
|
|
|
|
static Future<void> openBackgroundStartupSettings() async {
|
|
try {
|
|
await _channel.invokeMethod('openBackgroundStartupSettings');
|
|
} on MissingPluginException {
|
|
// Vendor background startup settings are Android-only.
|
|
}
|
|
}
|
|
|
|
static void onRecognitionAction(
|
|
void Function(Map<String, dynamic> action) callback,
|
|
) {
|
|
_recognitionAction = callback;
|
|
_installHandler();
|
|
}
|
|
|
|
static Future<void> onScreenshotReady(
|
|
void Function(String path) callback, {
|
|
void Function(String error)? onError,
|
|
}) async {
|
|
_screenshotReady = callback;
|
|
_screenshotError = onError;
|
|
_installHandler();
|
|
}
|
|
|
|
static void _installHandler() {
|
|
if (_handlerInstalled) return;
|
|
_handlerInstalled = true;
|
|
_channel.setMethodCallHandler((call) async {
|
|
if (call.method == 'onScreenshotReady') {
|
|
final path = call.arguments as String?;
|
|
if (path != null) _screenshotReady?.call(path);
|
|
} else if (call.method == 'onScreenshotError') {
|
|
_screenshotError?.call(call.arguments?.toString() ?? '截屏失败,请重试');
|
|
} else if (call.method == 'onSpeechEvent') {
|
|
final raw = Map<Object?, Object?>.from(call.arguments as Map);
|
|
final type = raw['type']?.toString() ?? 'error';
|
|
final event = SpeechEvent(
|
|
type: type,
|
|
text: raw['text']?.toString(),
|
|
rms: (raw['rms'] as num?)?.toDouble() ?? 0,
|
|
);
|
|
_speechEvent?.call(event);
|
|
if (type == 'final' || type == 'error' || type == 'cancelled') {
|
|
_speechEvent = null;
|
|
}
|
|
} else if (call.method == 'onRecognitionAction') {
|
|
final raw = Map<Object?, Object?>.from(call.arguments as Map);
|
|
_recognitionAction?.call(
|
|
raw.map((key, value) => MapEntry(key.toString(), value)),
|
|
);
|
|
}
|
|
});
|
|
}
|
|
}
|