Files
jizhi/frontend/lib/shared/services/screenshot_channel.dart
T
2026-07-24 23:11:20 +08:00

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