From c5a85e05a3bb50cd4ea7de0da2db94c867eb7ddd Mon Sep 17 00:00:00 2001 From: lxh2875931338 Date: Sat, 4 Jul 2026 20:57:29 +0800 Subject: [PATCH] =?UTF-8?q?SAF=E5=88=9D=E6=AD=A5=E4=BF=AE=E6=94=B9?= =?UTF-8?q?=EF=BC=8C=E7=AD=89=E5=BE=85=E9=AA=8C=E8=AF=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- android/app/src/main/AndroidManifest.xml | 2 +- lib/main.dart | 8 +- lib/models/saf_permission_state.dart | 63 ++++++ lib/pages/battery_health_page.dart | 181 +++++++++++++----- lib/services/saf_storage_service.dart | 171 +++++++++++++++++ macos/Flutter/GeneratedPluginRegistrant.swift | 2 + pubspec.lock | 56 ++++++ pubspec.yaml | 2 + 8 files changed, 432 insertions(+), 53 deletions(-) create mode 100644 lib/models/saf_permission_state.dart create mode 100644 lib/services/saf_storage_service.dart diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 1fb10b1..15dc0cc 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,6 +1,6 @@ - + double _maxVolt = 0; double _minVolt = 0; bool _hasData = false; - final String _filePath = '/storage/emulated/0/mbattery_charger/'; // ---------- 趋势图数据 ---------- List> _trendData = []; // ---------- 充电状态 ---------- bool _isCharging = false; - String _chargeType = ''; // "EPP", "PPS", "SDP", "无线", "MZ_EPP" 等 + String _chargeType = ''; double _chargePower = 0.0; double _chargeVolt = 0.0; double _chargeCurr = 0.0; int? _wiredLevel; bool _isWireless = false; - - // 🟢 1.3.0-dev.3: 充电开始时间(UTC),用于时间窗口判断 DateTime? _chargingStartTime; + // ---------- SAF 状态 ---------- + final SafStorageService _safService = SafStorageService(); + SafPermissionState _safState = SafPermissionState.unauthorized; + // 动画控制 late AnimationController _chargeAnimationController; late Animation _bottomSlide; @@ -150,22 +153,25 @@ class _BatteryHealthPageState extends State } } - // ---------- 扫描目录 ---------- + // ---------- 扫描目录 (SAF 模式) ---------- Future _scanAndUpdateDB() async { - Directory dir = Directory(_filePath); - if (!await dir.exists()) return; + // 检查 SAF 授权状态 + _safState = await _safService.getPermissionState(); + if (_safState != SafPermissionState.authorized) { + _showSafAuthorizationDialog(); + return; + } - List files = await dir.list().toList(); - RegExp filePattern = RegExp(r'mbattery_charger_log_(\d{4}_\d{2}_\d{2})$'); + try { + final files = await _safService.listFiles(); + RegExp filePattern = RegExp(r'mbattery_charger_log_(\d{4}_\d{2}_\d{2})$'); - for (FileSystemEntity entity in files) { - if (entity is File) { - String fileName = path.basename(entity.path); + for (String fileName in files) { Match? match = filePattern.firstMatch(fileName); if (match != null) { String dateStr = match.group(1)!.replaceAll('_', '-'); try { - List lines = await entity.readAsLines(); + List lines = await _safService.readLogFile(fileName); if (lines.isEmpty) continue; List validLines = lines @@ -195,10 +201,12 @@ class _BatteryHealthPageState extends State } catch (_) {} } } + } catch (e) { + dev.log('[_scanAndUpdateDB] ❌ 扫描异常: $e', name: 'BatteryHealth'); } } - // ---------- 1.3.0-dev.1: 使用 UTC 生成文件名 ---------- + // ---------- UTC 文件名 ---------- String getTodayFileName() { DateTime nowUtc = DateTime.now().toUtc(); String year = nowUtc.year.toString(); @@ -208,7 +216,6 @@ class _BatteryHealthPageState extends State } // ---------- 时间戳工具方法 ---------- - /// 解析日志行中的时间戳,强制解析为 UTC DateTime? _parseLogTimestamp(String line) { RegExp reg = RegExp(r'\[(\d{4}-\d{2}-\d{2}) (\d{2}:\d{2}:\d{2})\]'); Match? match = reg.firstMatch(line); @@ -231,20 +238,81 @@ class _BatteryHealthPageState extends State } } - /// 🟢 1.3.0-dev.3: 时间窗口以插入时刻为基准,只向前看 bool _isLogTimestampValid(DateTime? logTime) { if (logTime == null) return false; if (_chargingStartTime == null) { - // 边缘情况:回退到当前时间 ±3 分钟 DateTime nowUtc = DateTime.now().toUtc(); Duration diff = nowUtc.difference(logTime); return diff.abs().inMinutes <= 3; } - // 日志时间必须在 [充电开始时间, 充电开始时间 + 3 分钟] 范围内 DateTime endTime = _chargingStartTime!.add(const Duration(minutes: 3)); return logTime.isAfter(_chargingStartTime!) && logTime.isBefore(endTime); } + // ---------- SAF 授权引导 ---------- + void _showSafAuthorizationDialog() { + // 避免重复弹窗 + if (!mounted) return; + + showDialog( + context: context, + barrierDismissible: false, + builder: (context) => AlertDialog( + title: const Text('需要文件夹访问权限'), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: const [ + Text('请授权访问日志文件夹,以读取电池数据。'), + SizedBox(height: 8), + Text( + '目标路径:\n/storage/emulated/0/mbattery_charger/', + style: TextStyle(fontSize: 12, color: Colors.grey), + ), + SizedBox(height: 8), + Text('点击"选择文件夹"后,请在系统文件管理器中找到并选中上述文件夹。'), + ], + ), + actions: [ + TextButton( + onPressed: () { + Navigator.pop(context); + // 用户取消后,保持当前状态,但不阻塞后续操作 + setState(() { + _safState = SafPermissionState.denied; + }); + }, + child: const Text('取消'), + ), + ElevatedButton( + onPressed: () async { + final ctx = context; // 保存引用 + Navigator.pop(ctx); + final result = await _safService.requestPermission(); + if (result.success) { + if (!mounted) return; // 先检查 State 是否还在 + setState(() { + _safState = SafPermissionState.authorized; + }); + readBatteryCapacity(); + } else { + if (!mounted) return; + ScaffoldMessenger.of(ctx).showSnackBar( + SnackBar( + content: Text(result.errorMessage ?? '授权失败,请重试'), + backgroundColor: Colors.red, + ), + ); + // 重新弹窗 + _showSafAuthorizationDialog(); + } + }, + ), + ], + ), + ); + } + // ---------- 核心:系统API定调 ---------- void _applySystemChargeState(bool isCharging, String systemChargeType) { dev.log( @@ -263,12 +331,11 @@ class _BatteryHealthPageState extends State _chargeVolt = 0.0; _chargeCurr = 0.0; _wiredLevel = null; - _chargingStartTime = null; // 🟢 清空开始时间 + _chargingStartTime = null; _updateChargeState(); return; } - // 🟢 1.3.0-dev.3: 记录充电开始时刻(仅当从断开状态变为充电状态时) if (_chargingStartTime == null) { _chargingStartTime = DateTime.now().toUtc(); dev.log( @@ -297,7 +364,6 @@ class _BatteryHealthPageState extends State _isWireless = false; _chargeType = "SDP"; } else { - // UNKNOWN 时清空旧类型,等待日志修正 dev.log( '[_applySystemChargeState] ⚠️ 未知类型: $systemChargeType,清空类型等待日志', name: 'BatteryHealth', @@ -324,7 +390,6 @@ class _BatteryHealthPageState extends State name: 'BatteryHealth', ); - // ---------- 1. 收集所有在有效时间窗口内的数据行 ---------- List> validLines = []; for (String line in lines) { DateTime? timestamp = _parseLogTimestamp(line); @@ -349,10 +414,8 @@ class _BatteryHealthPageState extends State return; } - // 按时间戳排序(从新到旧) validLines.sort((a, b) => b['timestamp'].compareTo(a['timestamp'])); - // 取最新的一条 Map latest = validLines.first; String latestLine = latest['line']; String latestType = latest['type']; @@ -363,7 +426,7 @@ class _BatteryHealthPageState extends State name: 'BatteryHealth', ); - // ---------- 2. 无线处理 ---------- + // ---------- 无线 ---------- if (latestType == 'wls') { dev.log('[_parseChargeDataFromLog] 处理无线数据', name: 'BatteryHealth'); @@ -390,7 +453,7 @@ class _BatteryHealthPageState extends State return; } - // ---------- 3. 有线处理 ---------- + // ---------- 有线 ---------- if (latestType == 'usb') { dev.log('[_parseChargeDataFromLog] 处理有线数据', name: 'BatteryHealth'); @@ -411,7 +474,6 @@ class _BatteryHealthPageState extends State _isWireless = false; - // 🟢 1.3.0-dev.2: real_type 优先级最高 if (realType != null && realType == "PPS") { _chargeType = "PPS"; } else if (realType != null && @@ -438,14 +500,13 @@ class _BatteryHealthPageState extends State } } - // 🟢 1.3.0-dev.3: 增加超时保护 + // ---------- SAF 文件读取 ---------- Future _fetchChargeDataFromLog() async { if (!_isCharging) { dev.log('[_fetchChargeDataFromLog] 未充电,跳过读取', name: 'BatteryHealth'); return; } - // 超时检查:如果插入超过 5 分钟还没读到数据,不再频繁报错 if (_chargingStartTime != null) { Duration elapsed = DateTime.now().toUtc().difference(_chargingStartTime!); if (elapsed.inMinutes > 5) { @@ -457,24 +518,35 @@ class _BatteryHealthPageState extends State } } - String fullPath = _filePath + getTodayFileName(); - dev.log('[_fetchChargeDataFromLog] 尝试读取: $fullPath', name: 'BatteryHealth'); + // 检查 SAF 授权 + _safState = await _safService.getPermissionState(); + if (_safState != SafPermissionState.authorized) { + if (mounted) _showSafAuthorizationDialog(); + return; + } - File file = File(fullPath); - if (!await file.exists()) { + final fileName = getTodayFileName(); + dev.log('[_fetchChargeDataFromLog] 尝试读取: $fileName', name: 'BatteryHealth'); + + final exists = await _safService.logFileExists(fileName); + if (!exists) { dev.log( - '[_fetchChargeDataFromLog] ⚠️ 文件不存在: $fullPath', + '[_fetchChargeDataFromLog] ⚠️ 文件不存在: $fileName', name: 'BatteryHealth', ); return; } + try { - List lines = await file.readAsLines(); + final lines = await _safService.readLogFile(fileName); + if (lines.isEmpty) { + dev.log('[_fetchChargeDataFromLog] 文件为空', name: 'BatteryHealth'); + return; + } dev.log( '[_fetchChargeDataFromLog] ✅ 读取成功,共 ${lines.length} 行', name: 'BatteryHealth', ); - if (lines.isEmpty) return; _parseChargeDataFromLog(lines); } catch (e) { dev.log('[_fetchChargeDataFromLog] ❌ 读取异常: $e', name: 'BatteryHealth'); @@ -535,19 +607,20 @@ class _BatteryHealthPageState extends State // ---------- 读取当前数据 ---------- Future readBatteryCapacity() async { - var status = await Permission.manageExternalStorage.request(); - if (!status.isGranted) { - setState(() => _hasData = false); + // 检查 SAF 授权 + _safState = await _safService.getPermissionState(); + if (_safState != SafPermissionState.authorized) { + if (mounted) _showSafAuthorizationDialog(); return; } await _getDeviceInfo(); await _scanAndUpdateDB(); - String fullPath = _filePath + getTodayFileName(); - File file = File(fullPath); + final fileName = getTodayFileName(); + final exists = await _safService.logFileExists(fileName); - if (!await file.exists()) { + if (!exists) { List> recent = await _queryRecentData(1); if (recent.isNotEmpty) { setState(() { @@ -567,7 +640,7 @@ class _BatteryHealthPageState extends State } try { - List lines = await file.readAsLines(); + final lines = await _safService.readLogFile(fileName); if (lines.isEmpty) { setState(() => _hasData = false); if (_isCharging) _fetchChargeDataFromLog(); @@ -700,8 +773,16 @@ class _BatteryHealthPageState extends State } }); - WidgetsBinding.instance.addPostFrameCallback((_) { - readBatteryCapacity(); + // 延迟初始化 SAF 并读取数据 + WidgetsBinding.instance.addPostFrameCallback((_) async { + await _safService.init(); + _safState = await _safService.getPermissionState(); + if (_safState == SafPermissionState.unauthorized || + _safState == SafPermissionState.expired) { + if (mounted) _showSafAuthorizationDialog(); + } else { + readBatteryCapacity(); + } }); } @@ -799,9 +880,7 @@ class _BatteryHealthPageState extends State fontSize: 40, fontWeight: FontWeight.w500, height: 1.0, - color: healthPercent < 70 - ? Colors.red - : Colors.black, // 🟢 动态颜色 + color: healthPercent < 70 ? Colors.red : Colors.black, ), ), const SizedBox(width: 2), @@ -813,7 +892,7 @@ class _BatteryHealthPageState extends State height: 1.0, color: healthPercent < 70 ? Colors.red - : Colors.grey.shade600, // 🟢 动态颜色 + : Colors.grey.shade600, ), ), ], @@ -1047,7 +1126,7 @@ class _BatteryHealthPageState extends State } else if (value >= 70) { barColor = kHealthYellow; } else { - barColor = Colors.red; // 🟢 小于 70 显示红色 + barColor = Colors.red; } } else { barColor = Colors.grey.shade300; diff --git a/lib/services/saf_storage_service.dart b/lib/services/saf_storage_service.dart new file mode 100644 index 0000000..7b5e10c --- /dev/null +++ b/lib/services/saf_storage_service.dart @@ -0,0 +1,171 @@ +import 'dart:io'; +import 'package:flutter/foundation.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:flutter_saf/flutter_saf.dart'; +import '../models/saf_permission_state.dart'; + +/// SAF 存储服务 +/// 负责管理通过 SAF 授权的文件夹访问 +class SafStorageService { + static final SafStorageService _instance = SafStorageService._internal(); + factory SafStorageService() => _instance; + SafStorageService._internal(); + + static const String _prefKeySafUri = 'saf_storage_uri'; + static const String _prefKeySafPermission = 'saf_permission_uri'; + + /// 存储的授权 URI + String? _cachedUriString; + + /// 初始化,从 SharedPreferences 恢复授权状态 + Future init() async { + final prefs = await SharedPreferences.getInstance(); + _cachedUriString = prefs.getString(_prefKeySafUri); + if (kDebugMode) { + print('[SAF] 初始化,缓存 URI: $_cachedUriString'); + } + } + + /// 获取当前授权状态 + Future getPermissionState() async { + if (_cachedUriString == null || _cachedUriString!.isEmpty) { + return SafPermissionState.unauthorized; + } + + try { + final uri = Uri.parse(_cachedUriString!); + // 检查 Uri 是否仍然有效 + final isValid = await FlutterSaf.isDirectory(uri); + if (!isValid) { + _clearCache(); + return SafPermissionState.expired; + } + return SafPermissionState.authorized; + } catch (e) { + _clearCache(); + return SafPermissionState.expired; + } + } + + /// 请求 SAF 授权(引导用户选择文件夹) + /// 返回授权结果 + Future requestPermission() async { + try { + // 调用系统文件夹选择器 + // 引导用户选择 /storage/emulated/0/mbattery_charger/ + // 或者用户可以选择任何目录,但我们建议选择具体路径 + final uri = await FlutterSaf.openDocumentTree(); + + if (uri == null) { + return SafAuthorizationResult.denied(); + } + + // 获取持久化 URI 权限 + await FlutterSaf.takePersistableUriPermission(uri); + + // 验证是否为目录 + final isValid = await FlutterSaf.isDirectory(uri); + if (!isValid) { + return SafAuthorizationResult.error('选择的路径不是有效目录'); + } + + // 保存 URI + final uriString = uri.toString(); + _cachedUriString = uriString; + await _saveUri(uriString); + + if (kDebugMode) { + print('[SAF] 授权成功,URI: $uriString'); + } + + return SafAuthorizationResult.success(uriString); + } catch (e) { + if (kDebugMode) { + print('[SAF] 授权失败: $e'); + } + return SafAuthorizationResult.error('授权失败: $e'); + } + } + + /// 检查指定日期的日志文件是否存在 + Future logFileExists(String fileName) async { + final state = await getPermissionState(); + if (state != SafPermissionState.authorized) { + return false; + } + + try { + final uri = Uri.parse(_cachedUriString!); + final fileUri = await FlutterSaf.getFile(uri, fileName); + return fileUri != null && await FlutterSaf.isFile(fileUri); + } catch (e) { + return false; + } + } + + /// 读取日志文件内容 + /// 返回文件行列表,如果文件不存在或读取失败返回空列表 + Future> readLogFile(String fileName) async { + final state = await getPermissionState(); + if (state != SafPermissionState.authorized) { + return []; + } + + try { + final uri = Uri.parse(_cachedUriString!); + final fileUri = await FlutterSaf.getFile(uri, fileName); + if (fileUri == null || !await FlutterSaf.isFile(fileUri)) { + return []; + } + + final content = await FlutterSaf.readTextFile(fileUri); + return content.split('\n'); + } catch (e) { + if (kDebugMode) { + print('[SAF] 读取文件失败: $e'); + } + return []; + } + } + + /// 获取文件夹中的文件列表 + Future> listFiles() async { + final state = await getPermissionState(); + if (state != SafPermissionState.authorized) { + return []; + } + + try { + final uri = Uri.parse(_cachedUriString!); + final files = await FlutterSaf.listFiles(uri); + return files.where((f) => f.isFile).map((f) => f.name).toList(); + } catch (e) { + return []; + } + } + + /// 检查授权是否有效,如果失效则清除缓存 + Future validatePermission() async { + final state = await getPermissionState(); + return state == SafPermissionState.authorized; + } + + /// 清除授权缓存 + void _clearCache() { + _cachedUriString = null; + _saveUri(null); + } + + /// 保存 URI 到 SharedPreferences + Future _saveUri(String? uriString) async { + final prefs = await SharedPreferences.getInstance(); + if (uriString != null && uriString.isNotEmpty) { + await prefs.setString(_prefKeySafUri, uriString); + } else { + await prefs.remove(_prefKeySafUri); + } + } + + /// 获取缓存的 URI(用于调试) + String? get cachedUri => _cachedUriString; +} diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index 0a7b7cb..c92c456 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -7,10 +7,12 @@ import Foundation import device_info_plus import path_provider_foundation +import shared_preferences_foundation import sqflite_darwin func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin")) PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) + SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin")) } diff --git a/pubspec.lock b/pubspec.lock index e440b90..69bba3a 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -336,6 +336,62 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.8" + shared_preferences: + dependency: "direct main" + description: + name: shared_preferences + sha256: c3025c5534b01739267eb7d76959bbc25a6d10f6988e1c2a3036940133dd10bf + url: "https://pub.dev" + source: hosted + version: "2.5.5" + shared_preferences_android: + dependency: transitive + description: + name: shared_preferences_android + sha256: e8d4762b1e2e8578fc4d0fd548cebf24afd24f49719c08974df92834565e2c53 + url: "https://pub.dev" + source: hosted + version: "2.4.23" + shared_preferences_foundation: + dependency: transitive + description: + name: shared_preferences_foundation + sha256: "4e7eaffc2b17ba398759f1151415869a34771ba11ebbccd1b0145472a619a64f" + url: "https://pub.dev" + source: hosted + version: "2.5.6" + shared_preferences_linux: + dependency: transitive + description: + name: shared_preferences_linux + sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shared_preferences_platform_interface: + dependency: transitive + description: + name: shared_preferences_platform_interface + sha256: "649dc798a33931919ea356c4305c2d1f81619ea6e92244070b520187b5140ef9" + url: "https://pub.dev" + source: hosted + version: "2.4.2" + shared_preferences_web: + dependency: transitive + description: + name: shared_preferences_web + sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019 + url: "https://pub.dev" + source: hosted + version: "2.4.3" + shared_preferences_windows: + dependency: transitive + description: + name: shared_preferences_windows + sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1" + url: "https://pub.dev" + source: hosted + version: "2.4.1" sky_engine: dependency: transitive description: flutter diff --git a/pubspec.yaml b/pubspec.yaml index 207abaf..e2b1c73 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -37,6 +37,8 @@ dependencies: path_provider: ^2.1.0 path: ^1.9.0 flutter_svg: ^2.0.10+1 + shared_preferences: ^2.2.0 + flutter_saf: ^0.1.0 # The following adds the Cupertino Icons font to your application. # Use with the CupertinoIcons class for iOS style icons.