From 3e6bdb75c632437b4e5641009c01d7a70b887cb3 Mon Sep 17 00:00:00 2001 From: TNT-Likely Date: Sat, 30 May 2026 23:12:47 +0800 Subject: [PATCH] =?UTF-8?q?feat(shake):=20=E5=AE=89=E5=8D=93=E7=AB=AF?= =?UTF-8?q?=E5=AE=9E=E7=8E=B0=E6=91=87=E4=B8=80=E6=91=87=E8=87=AA=E5=8A=A8?= =?UTF-8?q?=E8=AE=B0=E8=B4=A6=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 摇一摇检测:ShakeDetector 加速度传感器,连续峰值算法判定摇动动作 - 无障碍截屏:BillingAccessibilityService 接管截屏,API 31+ 截屏文件 / API 30- 节点文本双路径 - 五层保活:前台服务+通知栏+JobScheduler+广播唤醒+相互唤醒 - 设置页:无障碍引导、保活状态/电池优化/自启动检查、通知渠道横幅检测 - 启动恢复:应用重启后自动恢复摇一摇与保活状态 - 通知优化:processScreenshot 支持 showProgressNotifications 参数控制中间通知; 结果通知优先走原生通道,降级 Flutter 通知 + SP 兜底; 超时后等待 AI 延迟响应,通知追加待处理计数 --- android/app/src/main/AndroidManifest.xml | 52 +- .../beecount/BillingAccessibilityService.kt | 690 +++++++++++++++ .../com/tntlikely/beecount/BootReceiver.kt | 38 + .../tntlikely/beecount/KeepAliveJobService.kt | 63 ++ .../tntlikely/beecount/KeepAliveService.kt | 398 +++++++++ .../com/tntlikely/beecount/MainActivity.kt | 383 ++++++++- .../beecount/ScreenshotController.kt | 125 +++ .../com/tntlikely/beecount/ShakeDetector.kt | 226 +++++ android/app/src/main/res/values/strings.xml | 6 +- .../res/xml/accessibility_service_config.xml | 9 + lib/main.dart | 52 +- lib/pages/automation/shake_billing_page.dart | 789 ++++++++++++++++++ lib/pages/main/mine_page.dart | 1 - lib/pages/settings/smart_billing_page.dart | 15 + lib/providers/keep_alive_provider.dart | 17 + .../automation/auto_billing_service.dart | 350 ++++++-- .../platform/shake_billing_service.dart | 360 ++++++++ 17 files changed, 3493 insertions(+), 81 deletions(-) create mode 100644 android/app/src/main/kotlin/com/tntlikely/beecount/BillingAccessibilityService.kt create mode 100644 android/app/src/main/kotlin/com/tntlikely/beecount/BootReceiver.kt create mode 100644 android/app/src/main/kotlin/com/tntlikely/beecount/KeepAliveJobService.kt create mode 100644 android/app/src/main/kotlin/com/tntlikely/beecount/KeepAliveService.kt create mode 100644 android/app/src/main/kotlin/com/tntlikely/beecount/ScreenshotController.kt create mode 100644 android/app/src/main/kotlin/com/tntlikely/beecount/ShakeDetector.kt create mode 100644 android/app/src/main/res/xml/accessibility_service_config.xml create mode 100644 lib/pages/automation/shake_billing_page.dart create mode 100644 lib/providers/keep_alive_provider.dart create mode 100644 lib/services/platform/shake_billing_service.dart diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 216b729db..0d1e8f6e1 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -26,13 +26,20 @@ + + - + + + + + - @@ -132,6 +138,48 @@ android:resource="@xml/beecount_widget_info" /> + + + + + + + + + + + + + + + + + + + + + + + - - + + 摇一摇自动记账 + 摇动手机自动识别当前屏幕内容并记账 diff --git a/android/app/src/main/res/xml/accessibility_service_config.xml b/android/app/src/main/res/xml/accessibility_service_config.xml new file mode 100644 index 000000000..c6404dd74 --- /dev/null +++ b/android/app/src/main/res/xml/accessibility_service_config.xml @@ -0,0 +1,9 @@ + + diff --git a/lib/main.dart b/lib/main.dart index e52d3d512..bce017f30 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -20,6 +20,7 @@ import 'providers/security_providers.dart'; import 'services/system/reminder_monitor_service.dart'; import 'providers/credit_card_reminder_providers.dart'; import 'services/platform/screenshot_monitor_service.dart'; +import 'services/platform/shake_billing_service.dart'; import 'services/platform/image_share_handler_service.dart'; import 'services/platform/app_link_service.dart'; import 'services/system/logger_service.dart'; @@ -115,6 +116,9 @@ Future main() async { // 恢复截图自动识别设置(Android专属),传入container await _restoreScreenshotMonitor(container); + // 恢复摇一摇自动记账状态(Android专属) + await _restoreShakeBilling(container); + // 初始化图片分享处理服务(Android专属) if (Platform.isAndroid) { _setupImageShareHandler(container); @@ -140,11 +144,23 @@ Future main() async { // 执行,失败不致命。 unawaited(_runOrphanFileGcOnce(container)); - runApp(ProviderScope( - parent: container, - observers: const [_WidgetUpdateObserver()], - child: const MainApp(), - )); + // 全局错误捕获 — 未捕获的 Flutter/Dart 异常 + FlutterError.onError = (FlutterErrorDetails details) { + logger.error('FlutterError', '未捕获的 Flutter 错误', details.exception.toString(), details.stack); + }; + + runZonedGuarded( + () { + runApp(ProviderScope( + parent: container, + observers: const [_WidgetUpdateObserver()], + child: const MainApp(), + )); + }, + (Object error, StackTrace stack) { + logger.error('ZONE', '未捕获的区域错误', error, stack); + }, + ); } /// Provider observer to update widget on app start @@ -256,7 +272,31 @@ Future _restoreScreenshotMonitor(ProviderContainer container) async { } } catch (e) { print('❌ 恢复截图监听失败: $e'); - // 不抛出异常,避免影响应用启动 + } + +} + +/// 恢复摇一摇自动记账状态(仅Android) +/// +/// 应用崩溃/重启后 Dart 内存状态丢失,从 SharedPreferences 恢复。 +Future _restoreShakeBilling(ProviderContainer container) async { + if (!Platform.isAndroid) return; + + try { + print('📳 检查并恢复摇一摇自动记账...'); + final shakeBilling = ShakeBillingService(container); + final shakeEnabled = await shakeBilling.isShakeBillingEnabled(); + + if (shakeEnabled) { + print('✅ 发现用户已启用摇一摇自动记账'); + print('🔄 正在重新启用摇一摇检测...'); + await shakeBilling.enableShakeBilling(); + print('✅ 摇一摇自动记账已成功恢复'); + } else { + print('ℹ️ 用户未启用摇一摇自动记账,跳过恢复'); + } + } catch (e) { + print('📳 恢复摇一摇自动记账失败: $e'); } } diff --git a/lib/pages/automation/shake_billing_page.dart b/lib/pages/automation/shake_billing_page.dart new file mode 100644 index 000000000..07f1e3d75 --- /dev/null +++ b/lib/pages/automation/shake_billing_page.dart @@ -0,0 +1,789 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../widgets/ui/primary_header.dart'; +import '../../widgets/ui/toast.dart'; + +import '../../providers.dart'; +import '../../services/platform/shake_billing_service.dart'; +import '../../l10n/app_localizations.dart'; + +/// 摇一摇自动记账设置页面 +class ShakeBillingPage extends ConsumerStatefulWidget { + const ShakeBillingPage({super.key}); + + @override + ConsumerState createState() => _ShakeBillingPageState(); +} + +class _ShakeBillingPageState extends ConsumerState with WidgetsBindingObserver { + late final ShakeBillingService _shakeBilling; + bool _isShakeBillingEnabled = false; + bool _isAccessibilityServiceEnabled = false; + bool _isLoading = true; + bool _isInitialized = false; + + /// 保活综合状态(由 Kotlin 端返回) + Map _keepAliveStatus = {}; + + /// 摇一摇记账结果通知渠道信息 + Map _shakeResultChannelInfo = {}; + + /// 通知渠道是否满足横幅弹窗条件 + bool get _notifChannelOk => + _shakeResultChannelInfo['isEnabled'] == true && + (_shakeResultChannelInfo['importance'] == 'high' || + _shakeResultChannelInfo['importance'] == 'max'); + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addObserver(this); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + if (!_isInitialized) { + final container = ProviderScope.containerOf(context); + _shakeBilling = ShakeBillingService(container); + _loadMonitorStatus(); + _isInitialized = true; + } + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + super.didChangeAppLifecycleState(state); + if (state == AppLifecycleState.resumed) { + _loadMonitorStatus(); + } + } + + Future _loadMonitorStatus() async { + final shakeEnabled = await _shakeBilling.isShakeBillingEnabled(); + + bool accessibilityEnabled = false; + try { + accessibilityEnabled = await _shakeBilling.checkAccessibilityServiceEnabled(); + } catch (e) { + print('检查无障碍服务状态失败: $e'); + } + + Map keepAliveStatus = {}; + try { + keepAliveStatus = await _shakeBilling.getKeepAliveStatus(); + } catch (e) { + print('查询保活状态失败: $e'); + } + + // 查询摇一摇记账结果通知渠道信息 + Map channelInfo = {}; + try { + channelInfo = await _shakeBilling.getNotificationChannelInfo('shake_result'); + print('📢 通知渠道 shake_result: $channelInfo'); + } catch (e) { + print('查询通知渠道信息失败: $e'); + } + + setState(() { + _isShakeBillingEnabled = shakeEnabled; + _isAccessibilityServiceEnabled = accessibilityEnabled; + _keepAliveStatus = keepAliveStatus; + _shakeResultChannelInfo = channelInfo; + _isLoading = false; + }); + } + + Future _toggleShakeBilling(bool value) async { + final l10n = AppLocalizations.of(context); + + try { + if (value) { + await _shakeBilling.enableShakeBilling(); + setState(() => _isShakeBillingEnabled = true); + if (mounted) showToast(context, l10n.enableSuccess); + + final enabled = await _shakeBilling.checkAccessibilityServiceEnabled(); + setState(() => _isAccessibilityServiceEnabled = enabled); + + _loadMonitorStatus(); + + if (!enabled && mounted) { + showToast(context, '请前往系统设置 - 无障碍 - 已安装的应用 - 蜜蜂记账,开启摇一摇自动记账服务', duration: const Duration(seconds: 4)); + } + } else { + await _shakeBilling.disableShakeBilling(); + setState(() => _isShakeBillingEnabled = false); + _loadMonitorStatus(); + if (mounted) showToast(context, l10n.disableSuccess); + } + } catch (e) { + if (mounted) { + showToast(context, '${l10n.enableFailed}: $e', duration: const Duration(seconds: 3)); + } + } + } + + @override + void dispose() { + WidgetsBinding.instance.removeObserver(this); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final primaryColor = ref.watch(primaryColorProvider); + final l10n = AppLocalizations.of(context); + + return Scaffold( + backgroundColor: theme.colorScheme.surface, + body: Column( + children: [ + PrimaryHeader( + title: '摇一摇自动记账', + showBack: true, + leadingIcon: Icons.sensors, + leadingPlain: true, + ), + Expanded( + child: ListView( + padding: const EdgeInsets.all(16), + children: [ + // ── 功能介绍 ── + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(Icons.info_outline, color: primaryColor, size: 24), + const SizedBox(width: 8), + Text( + '功能介绍', + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + ], + ), + const SizedBox(height: 12), + Text( + '摇动手机,通过无障碍服务截取当前屏幕,自动识别账单信息并完成记账。\n\n' + '• 摇动手机即可触发自动记账\n' + '• 所有版本均可用(包括 Google Play 版)\n' + '• 需开启无障碍服务权限', + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurface.withValues(alpha: 0.7), + height: 1.5, + ), + ), + ], + ), + ), + ), + + const SizedBox(height: 16), + + // ── 摇动示例图 ── + _ShakeGuideIllustration(), + + const SizedBox(height: 20), + + // ── 摇一摇自动记账开关 ── + Padding( + padding: const EdgeInsets.only(left: 4, bottom: 8), + child: Text( + '摇一摇自动记账', + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, + color: primaryColor, + ), + ), + ), + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: primaryColor.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(12), + ), + child: Icon(Icons.sensors, color: primaryColor, size: 28), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '摇一摇自动记账', + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 4), + Text( + _isShakeBillingEnabled + ? (_isAccessibilityServiceEnabled ? '已开启' : '已开启(无障碍未授权)') + : '已关闭', + style: theme.textTheme.bodySmall?.copyWith( + color: _isShakeBillingEnabled + ? primaryColor + : theme.colorScheme.onSurface.withValues(alpha: 0.6), + ), + ), + ], + ), + ), + Switch( + value: _isShakeBillingEnabled, + onChanged: _isLoading ? null : _toggleShakeBilling, + ), + ], + ), + ), + ), + + // 无障碍服务引导卡片 + if (_isShakeBillingEnabled && !_isAccessibilityServiceEnabled) ...[ + const SizedBox(height: 12), + Card( + color: Colors.orange.withValues(alpha: 0.08), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(Icons.accessibility_new, color: Colors.orange, size: 24), + const SizedBox(width: 8), + Text( + '需要无障碍服务权限', + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + ], + ), + const SizedBox(height: 12), + Text( + '启用摇一摇自动记账需要在系统无障碍设置中开启「蜜蜂记账」服务。\n\n' + '操作步骤:\n' + '1. 点击下方按钮打开无障碍设置\n' + '2. 找到「已安装的应用」或「已安装服务」\n' + '3. 点击「蜜蜂记账」\n' + '4. 打开服务开关并确认启用', + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurface.withValues(alpha: 0.8), + height: 1.5, + ), + ), + const SizedBox(height: 16), + SizedBox( + width: double.infinity, + child: FilledButton.icon( + onPressed: () async { + await _shakeBilling.openAccessibilitySettings(); + await Future.delayed(const Duration(seconds: 1)); + if (mounted) { + final enabled = await _shakeBilling.checkAccessibilityServiceEnabled(); + setState(() => _isAccessibilityServiceEnabled = enabled); + } + }, + icon: const Icon(Icons.settings), + label: const Text('打开无障碍设置'), + ), + ), + ], + ), + ), + ), + ], + + const SizedBox(height: 24), + + // ── 后台保活状态 ── + Padding( + padding: const EdgeInsets.only(left: 4, bottom: 8), + child: Text( + '后台保活', + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, + color: primaryColor, + ), + ), + ), + if (_isShakeBillingEnabled) + _buildKeepAliveStatusCard(context, primaryColor, l10n) + else + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: Colors.grey.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(12), + ), + child: Icon(Icons.shield_outlined, color: Colors.grey, size: 28), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '保活服务已关闭', + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 4), + Text( + '开启摇一摇自动记账后自动启用保活,无需手动操作', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurface.withValues(alpha: 0.6), + height: 1.4, + ), + ), + ], + ), + ), + ], + ), + ), + ), + + const SizedBox(height: 24), + ], + ), + ), + ], + ), + ); + } + + Widget _buildKeepAliveStatusCard( + BuildContext context, + Color primaryColor, + AppLocalizations l10n, + ) { + final theme = Theme.of(context); + final batteryOpt = _keepAliveStatus['isIgnoringBatteryOptimizations'] == true; + final accessibilityRunning = _keepAliveStatus['accessibilityServiceRunning'] == true; + final keepAliveRunning = _keepAliveStatus['keepAliveServiceRunning'] == true; + + final importance = _shakeResultChannelInfo['importance'] as String? ?? ''; + final notifChannelDescription = _notifChannelOk + ? '渠道重要性 $importance,可弹出横幅通知' + : '重要性 $importance,需设为高才能弹出横幅通知'; + + final healthItems = <_HealthItem>[ + _HealthItem( + icon: Icons.accessible, + label: '无障碍服务', + description: '系统级权限,后台常驻不被系统杀死', + ok: accessibilityRunning, + okText: '运行中', + failText: '未运行', + ), + _HealthItem( + icon: Icons.battery_std, + label: '电池优化', + description: '豁免后系统不会在后台限制应用活动', + ok: batteryOpt, + okText: '已豁免', + failText: '未优化', + ), + _HealthItem( + icon: Icons.shield, + label: '保活前台服务', + description: '前台通知保活,关闭摇一摇后自动停止', + ok: keepAliveRunning, + okText: '运行中', + failText: '未运行', + ), + _HealthItem( + icon: Icons.notifications_active, + label: '记账结果横幅', + description: notifChannelDescription, + ok: _notifChannelOk, + okText: '已开启', + failText: '需设置', + ), + ]; + + final okCount = healthItems.where((e) => e.ok).length; + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: (okCount == healthItems.length ? Colors.green : Colors.orange).withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(12), + ), + child: Icon( + okCount == healthItems.length ? Icons.shield : Icons.shield_outlined, + color: okCount == healthItems.length ? Colors.green : Colors.orange, + size: 28, + ), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '保活健康度 $okCount/${healthItems.length}', + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 4), + Text( + okCount == healthItems.length + ? '所有保活机制运行正常' + : '部分保活项未就绪,建议优化', + style: theme.textTheme.bodySmall?.copyWith( + color: okCount == healthItems.length ? Colors.green : Colors.orange, + ), + ), + ], + ), + ), + ], + ), + const SizedBox(height: 16), + // Health status list + ...healthItems.map((item) => Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Row( + children: [ + Icon(item.icon, size: 20, color: item.ok ? Colors.green : Colors.grey), + const SizedBox(width: 8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(item.label, style: theme.textTheme.bodyMedium), + Text( + item.description, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurface.withValues(alpha: 0.5), + fontSize: 11, + ), + ), + ], + ), + ), + const SizedBox(width: 8), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: (item.ok ? Colors.green : Colors.grey).withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(4), + ), + child: Text( + item.ok ? item.okText : item.failText, + style: theme.textTheme.bodySmall?.copyWith( + color: item.ok ? Colors.green : Colors.grey, + fontSize: 11, + ), + ), + ), + ], + ), + )), + // 通知横幅设置引导 + if (!_notifChannelOk) ...[ + const SizedBox(height: 8), + SizedBox( + width: double.infinity, + child: OutlinedButton.icon( + onPressed: () async { + await _shakeBilling.openNotificationChannelSettings('shake_result'); + await Future.delayed(const Duration(seconds: 2)); + _loadMonitorStatus(); + }, + icon: const Icon(Icons.notifications, size: 18), + label: const Text('前往通知设置,开启横幅通知'), + style: OutlinedButton.styleFrom( + foregroundColor: Colors.orange, + side: const BorderSide(color: Colors.orange), + ), + ), + ), + const SizedBox(height: 4), + Padding( + padding: const EdgeInsets.only(left: 4), + child: Text( + '部分手机默认关闭横幅通知,需手动开启。请进入「通知」→「摇一摇记账结果」→ 打开「通知横幅/悬浮通知」', + style: theme.textTheme.bodySmall?.copyWith( + color: Colors.orange[700], + fontSize: 11, + height: 1.4, + ), + ), + ), + ], + // Action buttons for non-optimized items + if (!batteryOpt) ...[ + const SizedBox(height: 8), + SizedBox( + width: double.infinity, + child: OutlinedButton.icon( + onPressed: () async { + await _shakeBilling.openBatteryOptimizationSettings(); + }, + icon: const Icon(Icons.battery_std, size: 18), + label: const Text('前往电池优化设置'), + style: OutlinedButton.styleFrom( + foregroundColor: Colors.orange, + side: const BorderSide(color: Colors.orange), + ), + ), + ), + ], + if (!accessibilityRunning) ...[ + const SizedBox(height: 8), + SizedBox( + width: double.infinity, + child: OutlinedButton.icon( + onPressed: () async { + await _shakeBilling.openAccessibilitySettings(); + }, + icon: const Icon(Icons.accessibility_new, size: 18), + label: const Text('前往无障碍设置'), + ), + ), + ], + const SizedBox(height: 8), + SizedBox( + width: double.infinity, + child: OutlinedButton.icon( + onPressed: () async { + final opened = await _shakeBilling.openAutoStartSettings(); + if (!opened && context.mounted) { + _showAutoStartGuideDialog(context); + } + }, + icon: const Icon(Icons.power_settings_new, size: 18), + label: const Text('自启动管理'), + ), + ), + const SizedBox(height: 4), + Text( + '无法自动检测自启动状态,不同手机路径不同:设置 → 应用管理 → BeeCount → 自启动', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurface.withValues(alpha: 0.4), + fontSize: 10, + ), + ), + const SizedBox(height: 12), + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.blue.withValues(alpha: 0.08), + borderRadius: BorderRadius.circular(8), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(Icons.info_outline, color: Colors.blue, size: 18), + const SizedBox(width: 8), + Expanded( + child: Text( + '保活前台服务会在通知栏显示一条静默通知,保持应用在后台不被系统清理。开启摇一摇自动记账后自动启用,关闭后自动停止,无需手动操作。', + style: theme.textTheme.bodySmall?.copyWith( + color: Colors.blue[700], + height: 1.4, + ), + ), + ), + ], + ), + ), + ], + ), + ), + ); + } +} + +/// 自启动引导弹窗 +Future _showAutoStartGuideDialog(BuildContext context) async { + await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('开启自启动'), + content: const Text( + '当前设备不支持自动跳转,请手动操作:\n\n' + '1. 打开手机「设置」\n' + '2. 进入「应用管理」或「应用设置」\n' + '3. 找到「BeeCount」\n' + '4. 开启「自启动」或「允许自启动」权限\n\n' + '开启后保活服务更稳定,不会被系统后台清理。', + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(ctx).pop(), + child: const Text('知道了'), + ), + ], + ), + ); +} + +/// 摇动手机示例图 — 带动画提示如何摇手机 +class _ShakeGuideIllustration extends StatefulWidget { + @override + State<_ShakeGuideIllustration> createState() => _ShakeGuideIllustrationState(); +} + +class _ShakeGuideIllustrationState extends State<_ShakeGuideIllustration> + with SingleTickerProviderStateMixin { + late final AnimationController _ctrl; + late final Animation _shakeAnim; + + @override + void initState() { + super.initState(); + _ctrl = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 600), + )..repeat(reverse: true); + _shakeAnim = Tween(begin: -16.0, end: 16.0).animate( + CurvedAnimation(parent: _ctrl, curve: Curves.easeInOut), + ); + } + + @override + void dispose() { + _ctrl.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final primaryColor = theme.colorScheme.primary; + + // 运动轨迹点 — 显示在手机左右两侧的短线 + Widget _trailDots({required bool left}) { + return AnimatedBuilder( + animation: _shakeAnim, + builder: (_, __) { + final progress = (_shakeAnim.value + 16) / 32; // 0→1 + final opacity = left ? (1 - progress) : progress; + return Row( + mainAxisSize: MainAxisSize.min, + children: List.generate(3, (i) { + final dotOpacity = ((left ? (2 - i) : i) / 2.0 * opacity) + .clamp(0.0, 0.7); + return Container( + width: 4, + height: 4, + margin: const EdgeInsets.symmetric(horizontal: 2), + decoration: BoxDecoration( + color: primaryColor.withValues(alpha: dotOpacity), + shape: BoxShape.circle, + ), + ); + }), + ); + }, + ); + } + + return Card( + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + side: BorderSide(color: primaryColor.withValues(alpha: 0.15)), + ), + color: primaryColor.withValues(alpha: 0.04), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 24, horizontal: 16), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // 动画手机 + 轨迹点 + SizedBox( + height: 80, + child: Stack( + alignment: Alignment.center, + children: [ + // 左侧轨迹点 + Positioned(left: 0, child: _trailDots(left: true)), + // 右侧轨迹点 + Positioned(right: 0, child: _trailDots(left: false)), + // 手机图标 — 左右平移 + 轻微摆头模拟手腕弧线 + AnimatedBuilder( + animation: _shakeAnim, + builder: (_, __) => Transform.translate( + offset: Offset(_shakeAnim.value, 0), + child: Transform.rotate( + angle: _shakeAnim.value * 0.012, + child: Icon(Icons.phone_android, + color: primaryColor, size: 48), + ), + ), + ), + ], + ), + ), + const SizedBox(height: 12), + Text( + '握住手机两侧,以手腕为轴快速摇动 5~6 次', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurface.withValues(alpha: 0.6), + ), + textAlign: TextAlign.center, + ), + ], + ), + ), + ); + } +} + +/// 保活健康项数据模型 +class _HealthItem { + final IconData icon; + final String label; + + /// 简短说明(如"前台通知保活,关闭摇一摇后自动停止") + final String description; + + final bool ok; + final String okText; + final String failText; + + _HealthItem({ + required this.icon, + required this.label, + required this.description, + required this.ok, + required this.okText, + required this.failText, + }); +} diff --git a/lib/pages/main/mine_page.dart b/lib/pages/main/mine_page.dart index bf0655718..fa48f4ef0 100644 --- a/lib/pages/main/mine_page.dart +++ b/lib/pages/main/mine_page.dart @@ -27,7 +27,6 @@ import '../transaction/recurring_transaction_page.dart'; import '../settings/reminder_settings_page.dart'; import '../settings/language_settings_page.dart'; import '../settings/widget_management_page.dart'; -import '../automation/auto_billing_settings_page.dart'; import '../ai/ai_settings_page.dart'; import '../cloud/cloud_sync_page.dart'; import '../cloud/beecount_cloud_sync_page.dart'; diff --git a/lib/pages/settings/smart_billing_page.dart b/lib/pages/settings/smart_billing_page.dart index fb2155e69..532ce5853 100644 --- a/lib/pages/settings/smart_billing_page.dart +++ b/lib/pages/settings/smart_billing_page.dart @@ -9,6 +9,7 @@ import '../../providers/smart_billing_providers.dart'; import '../../providers/theme_providers.dart'; import '../ai/ai_settings_page.dart'; import '../automation/auto_billing_settings_page.dart'; +import '../automation/shake_billing_page.dart'; import 'shortcuts_guide_page.dart'; import '../../l10n/app_localizations.dart'; @@ -264,6 +265,20 @@ class SmartBillingPage extends ConsumerWidget { ), BeeTokens.cardDivider(context), ], + // 摇一摇自动记账(Android) + if (Platform.isAndroid && !_isGooglePlayBuild) ...[ + AppListTile( + leading: Icons.sensors, + title: '摇一摇自动记账', + subtitle: '摇动手机,无障碍截屏并记账', + onTap: () async { + await Navigator.of(context).push( + MaterialPageRoute(builder: (_) => const ShakeBillingPage()), + ); + }, + ), + BeeTokens.cardDivider(context), + ], // 快捷指令 AppListTile( leading: Icons.app_shortcut, diff --git a/lib/providers/keep_alive_provider.dart b/lib/providers/keep_alive_provider.dart new file mode 100644 index 000000000..bb4e5cf20 --- /dev/null +++ b/lib/providers/keep_alive_provider.dart @@ -0,0 +1,17 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +/// 保活开关(默认关闭) +final keepAliveProvider = StateProvider((ref) => false); + +/// 保活开关持久化初始化 +final keepAliveInitProvider = FutureProvider((ref) async { + final prefs = await SharedPreferences.getInstance(); + final saved = prefs.getBool('keep_alive_enabled'); + if (saved != null) { + ref.read(keepAliveProvider.notifier).state = saved; + } + ref.listen(keepAliveProvider, (prev, next) async { + await prefs.setBool('keep_alive_enabled', next); + }); +}); diff --git a/lib/services/automation/auto_billing_service.dart b/lib/services/automation/auto_billing_service.dart index 14af99e19..677c71599 100644 --- a/lib/services/automation/auto_billing_service.dart +++ b/lib/services/automation/auto_billing_service.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'dart:io'; import 'dart:ui'; +import 'package:flutter/services.dart'; import 'package:flutter_local_notifications/flutter_local_notifications.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:shared_preferences/shared_preferences.dart'; @@ -21,19 +22,45 @@ import 'auto_billing_config.dart'; class AutoBillingService { static const _ledgerIdKey = 'current_ledger_id'; static const _processedScreenshotsKey = 'processed_screenshots'; + static const _shakeBillingControlChannel = + MethodChannel('com.tntlikely.beecount/shake_billing_control'); final ProviderContainer _container; final FlutterLocalNotificationsPlugin _notificationsPlugin = FlutterLocalNotificationsPlugin(); + /// AI 调用超时后尚未收到延迟响应的截图数 + int _pendingTimeoutCount = 0; + + /// 记录 Dart 关键阶段(闪退后仍可通过日志查看) + void _logStage(String stage) { + logger.info('DartStage', stage); + print('🐛 [DartStage] $stage'); + } + // 防重复处理 final Set _processedPaths = {}; String? _lastProcessedPath; int _lastProcessedTime = 0; + /// 兜底本地化文案(后台恢复期取不到 PlatformDispatcher.locale 时使用) + late final AppLocalizations _defaultL10n = lookupAppLocalizations( + const Locale('zh', 'CN')); + + /// 安全获取本地化文案。后台 Activity 重建过渡期可能取不到 locale,兜底用默认值。 + AppLocalizations _safeL10n() { + try { + return lookupAppLocalizations(PlatformDispatcher.instance.locale); + } catch (_) { + return _defaultL10n; + } + } + AutoBillingService(this._container) { _initNotifications(); _loadProcessedScreenshots(); + // 恢复 Engine 挂起时未能送达的通知 + flushPendingNotification(); } /// 解析当前账本 ID(Provider → SharedPreferences → 数据库默认)。 @@ -110,11 +137,15 @@ class AutoBillingService { /// 核心:处理截图并自动记账 /// [imagePath] 截图文件路径 /// [showNotification] 是否显示通知(默认true) + /// [showProgressNotifications] 是否显示中间进度通知(默认true)。 + /// 摇一摇路径传 false,只保留最终结果通知横幅。 /// 返回:交易记录ID,失败返回null Future processScreenshot( String imagePath, { bool showNotification = true, + bool showProgressNotifications = true, }) async { + _logStage('process_screenshot_start'); final totalStartTime = DateTime.now().millisecondsSinceEpoch; print('📸 [AutoBilling] 开始处理截图: $imagePath'); logger.info('AutoBilling', '开始处理截图', imagePath); @@ -139,11 +170,13 @@ class AutoBillingService { _lastProcessedPath = imagePath; _lastProcessedTime = now; - try { - const notificationId = 1001; - // 最终结果(成功/失败)用独立 ID,避免 iOS 把它当成对 1001 的静默更新 - const resultNotificationId = 1101; + const notificationId = 1001; + const resultNotificationId = 1101; + // 超时后仍需等待 AI 延迟响应,故在 try 外保留引用 + Future? pendingAiFuture; + + try { // 检查文件是否存在 final file = File(imagePath); @@ -153,9 +186,9 @@ class AutoBillingService { logger.info('AutoBilling', '文件尚未就绪,开始等待', '路径=$imagePath, 超时=${AutoBillingConfig.fileWaitTimeout}ms'); - if (showNotification) { + if (showNotification && showProgressNotifications) { final l10n = - lookupAppLocalizations(PlatformDispatcher.instance.locale); + _safeL10n(); await _showNotification( id: notificationId, title: l10n.autoBillingNotifyDetectedTitle, @@ -181,11 +214,11 @@ class AutoBillingService { logger.error('AutoBilling', '截图文件等待超时', '路径=$imagePath, 等待时间=${waitTime}ms, 文件存在=${await file.exists()}'); if (showNotification) { - final l10n = - lookupAppLocalizations(PlatformDispatcher.instance.locale); + final l10n = _safeL10n(); await _showFinalNotification( progressId: notificationId, - finalId: resultNotificationId, + finalId: 1101, + capturePath: imagePath, title: l10n.autoBillingNotifyFileUnavailableTitle, body: l10n.autoBillingNotifyFileUnavailableBody, ); @@ -204,11 +237,11 @@ class AutoBillingService { AICapabilityType.vision)) { logger.warning('AutoBilling', 'AI vision 未配置,跳过自动记账'); if (showNotification) { - final l10n = lookupAppLocalizations( - PlatformDispatcher.instance.locale); + final l10n = _safeL10n(); await _showFinalNotification( progressId: notificationId, - finalId: resultNotificationId, + finalId: 1101, + capturePath: imagePath, title: l10n.aiNotConfiguredNotificationTitle, body: l10n.aiNotConfiguredNotificationBody, ); @@ -216,10 +249,10 @@ class AutoBillingService { return null; } - // 更新通知:开始识别 - if (showNotification) { + // 更新通知:开始识别(摇一摇路径不显示进度通知) + if (showNotification && showProgressNotifications) { final l10n = - lookupAppLocalizations(PlatformDispatcher.instance.locale); + _safeL10n(); await _showNotification( id: notificationId, title: l10n.autoBillingNotifyRecognizingScreenshotTitle, @@ -232,11 +265,11 @@ class AutoBillingService { if (ledgerId == null) { logger.error('AutoBilling', '无可用账本'); if (showNotification) { - final l10n = - lookupAppLocalizations(PlatformDispatcher.instance.locale); + final l10n = _safeL10n(); await _showFinalNotification( progressId: notificationId, - finalId: resultNotificationId, + finalId: 1101, + capturePath: imagePath, title: l10n.autoBillingNotifyNoLedgerTitle, body: l10n.autoBillingNotifyNoLedgerBody, ); @@ -245,46 +278,53 @@ class AutoBillingService { return null; } + _logStage('ai_vision_start'); final aiStartTime = DateTime.now().millisecondsSinceEpoch; logger.info('AutoBilling', '开始 AI 视觉识别 + 落库'); final autoAddAttachment = _container.read(smartBillingAutoAttachmentProvider); - final result = await _container.read(aiBookkeeperProvider).fromImage( + final autoAddAttachmentFn = autoAddAttachment + ? (txId, _) async { + try { + final attachmentService = + _container.read(attachmentServiceProvider); + await attachmentService.saveAttachment( + transactionId: txId, + sourceFile: file, + index: 0, + urgent: true, + ); + _container + .read(attachmentListRefreshProvider.notifier) + .state++; + } catch (e, st) { + logger.error('AutoBilling', '保存截图附件失败', e, st); + } + } + : null; + + // 分离 AI Future 引用,超时后仍需等待其延迟响应 + pendingAiFuture = _container.read(aiBookkeeperProvider).fromImage( image: file, ledgerId: ledgerId, billingTypes: const [ TagSeedService.billingTypeImage, TagSeedService.billingTypeAi, ], - l10n: lookupAppLocalizations(PlatformDispatcher.instance.locale), + l10n: _safeL10n(), // 多笔截图(罕见,但 AI 可能识别出一张账单页里的多笔)时,每笔都挂 // 同一张原图,与相册路径行为对齐。 // // 走 urgent 模式:跳过 FlutterImageCompress(platform channel,后台冻 // 结时会卡)和 _getImageInfo,用 sync File.copy 几十 ms 内完成。 // 这样 attachment 在 perform() return 前就写完,不依赖用户开 app。 - onSaved: autoAddAttachment - ? (txId, _) async { - try { - final attachmentService = - _container.read(attachmentServiceProvider); - await attachmentService.saveAttachment( - transactionId: txId, - sourceFile: file, - index: 0, - urgent: true, - ); - _container - .read(attachmentListRefreshProvider.notifier) - .state++; - } catch (e, st) { - logger.error('AutoBilling', '保存截图附件失败', e, st); - } - } - : null, + onSaved: autoAddAttachmentFn, ); + final result = await pendingAiFuture!.timeout(const Duration(seconds: 15)); + + _logStage('ai_vision_complete'); final aiElapsed = DateTime.now().millisecondsSinceEpoch - aiStartTime; logger.info('AutoBilling', 'AI 识别 + 落库完成', '耗时=${aiElapsed}ms, 成功=${result.savedCount} 笔, 失败=${result.failedCount}'); @@ -294,11 +334,11 @@ class AutoBillingService { if (!result.success) { if (showNotification) { - final l10n = - lookupAppLocalizations(PlatformDispatcher.instance.locale); + final l10n = _safeL10n(); await _showFinalNotification( progressId: notificationId, finalId: resultNotificationId, + capturePath: imagePath, title: l10n.autoBillingNotifyRecognizeFailedTitle, body: l10n.autoBillingNotifyRecognizeFailedBody, ); @@ -306,15 +346,18 @@ class AutoBillingService { return null; } + _logStage('post_processor_start'); _container.read(statsRefreshProvider.notifier).state++; await PostProcessor.runC(_container, ledgerId: ledgerId, tags: true); + _logStage('post_processor_done'); if (showNotification) { - final l10n = - lookupAppLocalizations(PlatformDispatcher.instance.locale); + _logStage('show_final_notification'); + final l10n = _safeL10n(); await _showFinalNotification( progressId: notificationId, finalId: resultNotificationId, + capturePath: imagePath, title: _successTitle(result, l10n), body: _successBody(result, l10n), ); @@ -322,13 +365,48 @@ class AutoBillingService { logger.info('AutoBilling', '自动记账成功', 'ids=${result.transactionIds}, 总金额=${result.totalAbsAmount}'); return result.firstTransactionId; + } on TimeoutException { + _logStage('ai_vision_timeout'); + logger.warning('AutoBilling', 'AI 识别超时', '等待延迟响应: $imagePath'); + if (showNotification) { + await _showFinalNotification( + progressId: notificationId, + finalId: resultNotificationId, + capturePath: imagePath, + title: '蜜蜂记账', + body: '⏳ 识别耗时较长,请稍等!', + ); + } + _pendingTimeoutCount++; + _handleDelayedResponse( + imagePath: imagePath, + aiFuture: pendingAiFuture!, + finalId: resultNotificationId, + showNotification: showNotification, + ); + return null; } catch (e, stackTrace) { + _logStage('process_screenshot_error'); print('❌ 处理截图失败: $e'); logger.error('AutoBilling', '处理截图失败', { 'path': imagePath, 'error': e.toString(), 'stage': '未知阶段', }, stackTrace); + if (showNotification) { + final l10n = _safeL10n(); + try { + await _showFinalNotification( + progressId: notificationId, + finalId: resultNotificationId, + capturePath: imagePath, + title: l10n.autoBillingNotifyProcessFailedTitle, + body: l10n.autoBillingNotifyProcessFailedBody(e.toString()), + ); + } catch (_) { + // 通知失败不影响流程 + } + } return null; } finally { final totalElapsed = @@ -340,10 +418,13 @@ class AutoBillingService { /// 核心:直接处理文本并自动记账(快捷指令推荐方式) /// [text] 快捷指令传递的识别文本 /// [showNotification] 是否显示通知(默认true) + /// [showProgressNotifications] 是否显示中间进度通知(默认true)。 + /// 摇一摇路径传 false,只保留最终结果通知横幅。 /// 返回:交易记录ID,失败返回null Future processText( String text, { bool showNotification = true, + bool showProgressNotifications = true, }) async { final totalStartTime = DateTime.now().millisecondsSinceEpoch; print('📝 [AutoBilling] 开始处理文本: $text'); @@ -351,7 +432,7 @@ class AutoBillingService { try { const notificationId = 1002; const resultNotificationId = 1102; - final l10n = lookupAppLocalizations(PlatformDispatcher.instance.locale); + final l10n = _safeL10n(); // 兜底:AI text 未配置 → 系统通知,引导用户去配置 if (!await AIProviderManager.isCapabilityConfigured( @@ -367,8 +448,8 @@ class AutoBillingService { return null; } - // 显示"正在识别"通知 - if (showNotification) { + // 显示"正在识别"通知(摇一摇路径不显示进度通知) + if (showNotification && showProgressNotifications) { await _showNotification( id: notificationId, title: l10n.autoBillingNotifyRecognizingTextTitle, @@ -428,7 +509,7 @@ class AutoBillingService { logger.error('AutoBilling', '文本处理失败', e); if (showNotification) { final l10n = - lookupAppLocalizations(PlatformDispatcher.instance.locale); + _safeL10n(); await _showNotification( id: 1002, title: l10n.autoBillingNotifyProcessFailedTitle, @@ -464,6 +545,85 @@ class AutoBillingService { : l10n.autoBillingNotifySuccessSingleBodyDefault; } + /// 取消摇一摇进度横幅(Android native 已发,Flutter 负责在结果到来时移除) + Future cancelShakeProgressBanner() async { + await _notificationsPlugin.cancel(9101); + } + + /// 处理 AI 超时后的延迟响应。 + /// + /// [aiFuture] 是 [fromImage] 的原生 Future,即便外层 .timeout() 已触发, + /// 它仍在后台运行。等 AI 最终响应后发送结果通知。 + /// + /// 如果有多个待处理的延迟响应,通知正文末尾追加「(剩余 N 张待识别)」。 + void _handleDelayedResponse({ + required String imagePath, + required Future aiFuture, + required int finalId, + required bool showNotification, + }) { + // 安全网:60 秒后如果 AI 仍无响应(Dio 20s 超时意外失效的极端情况), + // 强制完成并通知用户,避免「⏳ 识别耗时较长」永久挂起 + aiFuture + .timeout(const Duration(seconds: 60)) + .then((result) async { + _pendingTimeoutCount--; + await _markAsProcessed(imagePath); + + if (showNotification) { + final l10n = _safeL10n(); + final suffix = _pendingTimeoutCount > 0 + ? '(剩余 $_pendingTimeoutCount 张待识别)' + : ''; + + if (result.success) { + // 刷新统计 & 触发同步(fromImage 已落库,PostProcessor 需手动跑) + try { + final ledgerId = await _resolveLedgerId(); + if (ledgerId != null) { + // ignore: invalid_use_of_visible_for_testing_member + _container.read(statsRefreshProvider.notifier).state++; + await PostProcessor.runC(_container, + ledgerId: ledgerId, tags: true); + } + } catch (_) {} + } + + await _showFinalNotification( + progressId: finalId, + finalId: finalId, + capturePath: imagePath, + title: result.success + ? _successTitle(result, l10n) + : l10n.autoBillingNotifyRecognizeFailedTitle, + body: (result.success + ? _successBody(result, l10n) + : l10n.autoBillingNotifyRecognizeFailedBody) + + suffix, + ); + } + + if (result.success) { + logger.info('AutoBilling', '延迟响应:自动记账成功', + 'ids=${result.transactionIds}'); + } + }).catchError((e, st) { + _pendingTimeoutCount--; + logger.error('AutoBilling', '延迟响应处理失败', '$e', st); + // 连兜底也失败了 → 不能再让「⏳ 识别耗时较长」挂在那 + if (showNotification) { + final l10n = _safeL10n(); + _showFinalNotification( + progressId: finalId, + finalId: finalId, + capturePath: imagePath, + title: l10n.autoBillingNotifyProcessFailedTitle, + body: l10n.autoBillingNotifyProcessFailedBody('识别超时,请稍后重试'), + ); + } + }); + } + /// 显示通知。 /// /// 通知失败**绝不向外抛**:通知只是进度提示,记账主流程不能因它中断。 @@ -481,6 +641,8 @@ class AutoBillingService { channelDescription: '截图自动识别通知', importance: Importance.high, priority: Priority.high, + enableVibration: true, + playSound: true, ); const iosDetails = DarwinNotificationDetails(); @@ -498,24 +660,96 @@ class AutoBillingService { } } - /// 显示「最终结果」通知。 + /// 显示「最终结果」通知(横幅弹出)。 /// - /// iOS 上,**用同一 ID 重复 `show()` 只会静默更新通知中心条目,不会重新弹 - /// banner**。所以「正在识别 → 成功/失败」如果共用 ID,用户只能看到第一条 - /// banner,直到进通知中心才看到结果。 - /// - /// 这个方法用**新 ID** 发结果通知,iOS 把它当作新通知重新弹 banner。 - /// 不 cancel 旧的「正在识别」—— 实测在 AppIntent background-launch 状态下 - /// cancel + show 紧挨着的组合 iOS 会把它当成一次「替换」处理,banner 不弹; - /// 留着旧的反而能保证新的作为独立通知正常弹出(旧的在结果通知出现后用户可自 - /// 行清理或自然过期)。 + /// [capturePath] 为截屏文件路径。传入后优先通过原生 MethodChannel 发送, + /// 同时取消原生侧对应截图的 15s 超时定时器。不传时走原有降级链路。 Future _showFinalNotification({ required int progressId, required int finalId, required String title, required String body, + String? capturePath, }) async { - await _showNotification(id: finalId, title: title, body: body); + // 优先走 captureResult(取消原生超时 + 原生通知) + if (capturePath != null) { + try { + await _shakeBillingControlChannel.invokeMethod('captureResult', { + 'path': capturePath, + 'title': title, + 'body': body, + }); + logger.info('AutoBilling', '原生结果通知已发送', 'title=$title'); + return; + } catch (e) { + logger.debug('AutoBilling', '原生通知发送失败,降级到 Flutter 通知: $e'); + } + } + + // 降级:Flutter 本地通知 + try { + const androidDetails = AndroidNotificationDetails( + 'shake_result', + '摇一摇记账结果', + channelDescription: '摇一摇自动记账最终结果通知', + importance: Importance.high, + priority: Priority.high, + enableVibration: true, + playSound: true, + ); + + const iosDetails = DarwinNotificationDetails(); + + const details = NotificationDetails( + android: androidDetails, + iOS: iosDetails, + ); + + await _notificationsPlugin.show(finalId, title, body, details); + return; + } catch (e) { + logger.warning('AutoBilling', 'Flutter 通知也失败,保存待发送通知', '$e'); + } + + // 兜底:Engine 被 ROM 挂起时两种方式都失败, + // 保存到 SharedPreferences,下次启动时恢复展示 + try { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString('pending_notification_title', title); + await prefs.setString('pending_notification_body', body); + logger.info('AutoBilling', '待发送通知已保存到 SharedPreferences'); + } catch (e) { + logger.error('AutoBilling', '保存待发送通知失败', e); + } + } + + /// 检查并显示待发送的通知(应用启动时调用) + Future flushPendingNotification() async { + try { + final prefs = await SharedPreferences.getInstance(); + final title = prefs.getString('pending_notification_title'); + final body = prefs.getString('pending_notification_body'); + if (title == null || body == null) return; + + await prefs.remove('pending_notification_title'); + await prefs.remove('pending_notification_body'); + logger.info('AutoBilling', '恢复待发送通知: $title'); + + await _notificationsPlugin.show( + 9102, title, body, const NotificationDetails( + android: AndroidNotificationDetails( + 'shake_result', + '摇一摇记账结果', + channelDescription: '摇一摇自动记账最终结果通知', + importance: Importance.high, + priority: Priority.high, + ), + iOS: DarwinNotificationDetails(), + ), + ); + } catch (e) { + logger.error('AutoBilling', '恢复待发送通知失败', e); + } } /// 释放资源(AI 服务无 native handle,不需要 dispose,保留方法以备后续添加) diff --git a/lib/services/platform/shake_billing_service.dart b/lib/services/platform/shake_billing_service.dart new file mode 100644 index 000000000..c02147090 --- /dev/null +++ b/lib/services/platform/shake_billing_service.dart @@ -0,0 +1,360 @@ +import 'dart:async'; +import 'dart:io'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import '../automation/auto_billing_service.dart'; + +/// 摇一摇自动记账服务(仅Android) +/// +/// 管理无障碍截屏的接收、队列处理、保活服务联动,以及与系统无障碍设置的交互。 +/// 与 [ScreenshotMonitorService] 职责分离 — 截图监听走 ContentObserver, +/// 摇一摇走 AccessibilityService,互不干扰。 +class ShakeBillingService { + static const _accessibilityChannel = MethodChannel('com.tntlikely.beecount/accessibility_billing'); + static const _keepAliveChannel = MethodChannel('com.tntlikely.beecount/keep_alive'); + static const _shakeBillingControlChannel = MethodChannel('com.tntlikely.beecount/shake_billing_control'); + // 共享截图通道,用于无障碍设置查询(Android 端注册在 MainActivity) + static const _screenshotChannel = MethodChannel('com.tntlikely.beecount/screenshot'); + + static const _shakeBillingEnabledKey = 'shake_billing_enabled'; + static const _keepAliveEnabledKey = 'keep_alive_enabled'; + + final ProviderContainer _container; + late final AutoBillingService _autoBillingService; + + /// 保活服务是否已启用(内存状态,与摇一摇开关联动) + bool _isKeepAliveEnabled = false; + + /// 摇一摇自动记账是否已启用(内存状态,避免后台异步事件中的竞态条件) + bool _isShakeBillingEnabled = false; + + // ── 顺序处理队列(避免后台攒的多个截图同时触发 AI 造成通知轰炸) ── + final _captureQueue = []; + bool _isProcessingCapture = false; + + // 单例模式 + static ShakeBillingService? _instance; + + factory ShakeBillingService(ProviderContainer container) { + _instance ??= ShakeBillingService._internal(container); + return _instance!; + } + + ShakeBillingService._internal(this._container) { + _autoBillingService = AutoBillingService(_container); + _setupMethodCallHandler(); + } + + /// 设置方法调用处理器 + void _setupMethodCallHandler() { + print('📳 [ShakeBilling] 初始化方法调用处理器'); + + // 摇一摇无障碍截屏监听通道 + _accessibilityChannel.setMethodCallHandler((call) async { + print('📳 [ShakeBilling] 收到无障碍方法调用: ${call.method}'); + if (call.method == 'onAccessibilityCapture') { + final data = call.arguments as String; + print('📳 [ShakeBilling] 无障碍截屏数据: $data'); + await _handleAccessibilityCapture(data); + } else if (call.method == 'onAccessibilityServiceInterrupted') { + print('⚠️ [ShakeBilling] 无障碍服务被系统中断'); + // Flutter 侧无需特殊处理,UI 会在 App resume 时刷新状态 + } + }); + } + + // ── 摇一摇自动记账开关 ── + + /// 摇一摇自动记账是否已启用 + /// + /// 注意:摇一摇使用无障碍截图存私有目录,不依赖相册权限, + /// 因此不受 Google Play 构建限制,与截图监听独立判断。 + Future isShakeBillingEnabled() async { + final prefs = await SharedPreferences.getInstance(); + return prefs.getBool(_shakeBillingEnabledKey) ?? false; + } + + /// 启用摇一摇自动记账(同时自动开启保活服务) + Future enableShakeBilling() async { + if (!Platform.isAndroid) { + throw UnsupportedError('仅支持 Android 平台'); + } + + // 先设置内存状态(同步操作,无竞态风险) + _isShakeBillingEnabled = true; + + try { + await _shakeBillingControlChannel.invokeMethod('enableShake'); + } catch (e) { + print('⚠️ [ShakeBilling] 通知 Android 启用摇一摇失败: $e'); + } + + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool(_shakeBillingEnabledKey, true); + print('✅ [ShakeBilling] 摇一摇自动记账已启用'); + + // 同步启用保活服务(五层保活机制) + try { + await enableKeepAlive(); + _isKeepAliveEnabled = true; + } catch (e) { + print('⚠️ [ShakeBilling] 启用保活服务失败(不影响摇一摇): $e'); + } + } + + /// 禁用摇一摇自动记账(同步关闭保活服务) + Future disableShakeBilling() async { + // 先设置内存状态(同步操作,阻止后续无障碍截屏入队) + _isShakeBillingEnabled = false; + + try { + await _shakeBillingControlChannel.invokeMethod('disableShake'); + } catch (e) { + print('⚠️ [ShakeBilling] 通知 Android 禁用摇一摇失败: $e'); + } + + // 清空待处理的截屏队列,取消正在进行的处理 + _captureQueue.clear(); + _isProcessingCapture = false; + print('🧹 [ShakeBilling] 已清空截屏处理队列'); + + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool(_shakeBillingEnabledKey, false); + print('✅ [ShakeBilling] 摇一摇自动记账已禁用'); + + try { + await disableKeepAlive(); + _isKeepAliveEnabled = false; + } catch (e) { + print('⚠️ [ShakeBilling] 关闭保活服务失败: $e'); + } + } + + /// 处理无障碍截屏数据(摇一摇路径) + /// + /// [data] 有两种格式: + /// - 文件路径(API 31+ takeScreenshot) → 走 processScreenshot + /// - "text:" 前缀(API 30- 节点文本) → 走 processText + /// + /// 数据先入顺序队列,由 [_processCaptureQueue] 逐个处理,间隔 2 秒, + /// 防止 App 切后台期间攒的多个截图同时触发 AI 造成通知轰炸。 + Future _handleAccessibilityCapture(String data) async { + // 内存状态检查 — Engine 重启后状态会丢失,回查持久化配置恢复 + if (!_isShakeBillingEnabled) { + try { + final prefs = await SharedPreferences.getInstance(); + _isShakeBillingEnabled = prefs.getBool(_shakeBillingEnabledKey) ?? false; + } catch (_) {} + if (!_isShakeBillingEnabled) { + print('⏭️ [ShakeBilling] 摇一摇已禁用,忽略无障碍截屏'); + _logStage('shake_disabled_ignore'); + return; + } + print('♻️ [ShakeBilling] 从持久化配置恢复摇一摇启用状态'); + } + + _captureQueue.add(data); + if (!_isProcessingCapture) { + _isProcessingCapture = true; + await _processCaptureQueue(); + } + } + + /// 记录 Dart 关键阶段到原生 Logcat(闪退后仍可通过日志查看) + void _logStage(String stage) { + print('🐛 [DartStage] $stage'); + try { + _shakeBillingControlChannel.invokeMethod('logStage', {'stage': stage}); + } catch (_) {} + } + + /// 顺序消费 [_captureQueue],每次处理完一个等待 2 秒再处理下一个。 + Future _processCaptureQueue() async { + _logStage('capture_queue_start'); + while (_captureQueue.isNotEmpty) { + _logStage('capture_queue_process_item'); + final data = _captureQueue.removeAt(0); + try { + if (data.startsWith('text:')) { + _logStage('process_text'); + final text = data.substring(5); + if (text.trim().isEmpty) { + continue; + } + await _autoBillingService.processText(text, showProgressNotifications: false); + } else { + _logStage('process_screenshot'); + await _autoBillingService.processScreenshot(data, showProgressNotifications: false); + // 清理私有缓存文件(无障碍截图存在 cacheDir,无存储权限限制) + try { + final file = File(data); + if (await file.exists()) { + await file.delete(); + } + } catch (_) {} + } + _logStage('cancel_progress_notification'); + // AI 出结果了,关闭之前 Kotlin 发的进度通知 + try { + await _keepAliveChannel.invokeMethod('cancelProgressNotification'); + } catch (_) {} + _logStage('queue_delay'); + // 每个结果间隔 2 秒,避免 AI 调用和通知同时弹出 + await Future.delayed(const Duration(seconds: 2)); + } catch (e) { + _logStage('capture_queue_error'); + print('❌ [ShakeBilling] 处理截屏失败: $e'); + // 即使出错也关闭进度通知 + try { + await _keepAliveChannel.invokeMethod('cancelProgressNotification'); + } catch (_) {} + } + } + _logStage('capture_queue_done'); + _isProcessingCapture = false; + } + + // ── 后台保活开关 ── + + /// 保活是否已启用 + Future isKeepAliveEnabled() async { + final prefs = await SharedPreferences.getInstance(); + return prefs.getBool(_keepAliveEnabledKey) ?? false; + } + + /// 启用保活(启动前台保活服务 + 持久化状态) + Future enableKeepAlive() async { + if (!Platform.isAndroid) { + throw UnsupportedError('仅支持 Android 平台'); + } + try { + await _keepAliveChannel.invokeMethod('startKeepAlive'); + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool(_keepAliveEnabledKey, true); + print('✅ [ShakeBilling] 保活服务已启用'); + } catch (e) { + print('❌ [ShakeBilling] 启用保活服务失败: $e'); + rethrow; + } + } + + /// 禁用保活(停止前台保活服务 + 持久化状态) + Future disableKeepAlive() async { + if (!Platform.isAndroid) return; + try { + await _keepAliveChannel.invokeMethod('stopKeepAlive'); + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool(_keepAliveEnabledKey, false); + print('✅ [ShakeBilling] 保活服务已禁用'); + } catch (e) { + print('❌ [ShakeBilling] 禁用保活服务失败: $e'); + rethrow; + } + } + + /// 保活前台服务是否在运行中 + Future isKeepAliveRunning() async { + try { + final result = await _keepAliveChannel.invokeMethod('isKeepAliveRunning'); + return result == true; + } catch (e) { + print('❌ [ShakeBilling] 检查保活服务状态失败: $e'); + return false; + } + } + + /// 是否已忽略电池优化 + Future isIgnoringBatteryOptimizations() async { + try { + final result = await _keepAliveChannel.invokeMethod('isIgnoringBatteryOptimizations'); + return result == true; + } catch (e) { + print('❌ [ShakeBilling] 检查电池优化状态失败: $e'); + return false; + } + } + + /// 打开系统电池优化设置页面 + Future openBatteryOptimizationSettings() async { + try { + await _keepAliveChannel.invokeMethod('openBatteryOptimizationSettings'); + } catch (e) { + print('❌ [ShakeBilling] 打开电池优化设置失败: $e'); + } + } + + /// 打开自启动管理页面(各 ROM 适配) + Future openAutoStartSettings() async { + try { + final result = await _keepAliveChannel.invokeMethod('openAutoStartSettings'); + return result == true; + } catch (e) { + print('❌ [ShakeBilling] 打开自启动设置失败: $e'); + return false; + } + } + + /// 查询保活综合状态 + Future> getKeepAliveStatus() async { + try { + final result = await _keepAliveChannel.invokeMethod('getKeepAliveStatus'); + if (result is Map) { + return Map.from(result); + } + } catch (e) { + print('❌ [ShakeBilling] 查询保活状态失败: $e'); + } + return {}; + } + + // ── 无障碍服务查询(与截图服务共用系统通道) ── + + /// 检查无障碍服务是否已启用 + Future checkAccessibilityServiceEnabled() async { + try { + final result = await _screenshotChannel.invokeMethod('isAccessibilityServiceEnabled'); + return result == true; + } catch (e) { + print('📳 [ShakeBilling] 检查无障碍服务状态失败: $e'); + return false; + } + } + + /// 打开系统无障碍设置页面 + Future openAccessibilitySettings() async { + try { + await _screenshotChannel.invokeMethod('openAccessibilitySettings'); + } catch (e) { + print('📳 [ShakeBilling] 打开无障碍设置失败: $e'); + } + } + + // ── 通知渠道查询 ── + + /// 获取指定通知渠道的详细信息(用于判断横幅弹窗条件等) + Future> getNotificationChannelInfo(String channelId) async { + try { + final result = await _keepAliveChannel.invokeMethod('getNotificationChannelInfo', { + 'channelId': channelId, + }); + if (result is Map) { + return Map.from(result); + } + } catch (e) { + print('📢 [ShakeBilling] 查询通知渠道信息失败: $e'); + } + return {}; + } + + /// 打开指定通知渠道的系统设置页面 + Future openNotificationChannelSettings(String channelId) async { + try { + await _keepAliveChannel.invokeMethod('openNotificationChannelSettings', { + 'channelId': channelId, + }); + } catch (e) { + print('📢 [ShakeBilling] 打开通知渠道设置失败: $e'); + } + } +}