From f49d208042f2e3962778e0e6fe905d6adecd152d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9E=97=E9=94=8B?= <2535831261@qq.com> Date: Thu, 4 Jun 2026 18:25:58 +0800 Subject: [PATCH] =?UTF-8?q?1.=E5=BC=80=E5=A7=8B=E5=BD=95=E5=88=B6=E3=80=81?= =?UTF-8?q?=E7=BB=93=E6=9D=9F=E5=BD=95=E5=88=B6=E5=A2=9E=E5=8A=A0=202.=20?= =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E7=94=B5=E9=87=8F=E6=A3=80=E6=B5=8B=E3=80=81?= =?UTF-8?q?=E5=86=85=E5=AD=98=E6=A3=80=E6=9F=A5=EF=BC=8C=E6=98=AF=E5=90=A6?= =?UTF-8?q?=E4=BD=8E=E4=BA=8E=2010%?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../kotlin/com/qxy/dronex/MainActivity.kt | 30 +++++++ ios/Runner/PlatformInfoPlugin.swift | 26 ++++++ lib/core/platform/app_platform_info.dart | 8 ++ lib/core/platform/device_health_checker.dart | 25 ++++++ lib/core/platform/device_health_snapshot.dart | 30 +++++++ lib/features/recording/recording_page.dart | 17 ++++ .../recording_session_controller.dart | 23 +++++ lib/shared/widgets/app_dialog.dart | 20 +++++ .../platform/device_health_checker_test.dart | 88 +++++++++++++++++++ 9 files changed, 267 insertions(+) create mode 100644 lib/core/platform/device_health_checker.dart create mode 100644 lib/core/platform/device_health_snapshot.dart create mode 100644 test/core/platform/device_health_checker_test.dart diff --git a/android/app/src/main/kotlin/com/qxy/dronex/MainActivity.kt b/android/app/src/main/kotlin/com/qxy/dronex/MainActivity.kt index f9f3e90..968a229 100644 --- a/android/app/src/main/kotlin/com/qxy/dronex/MainActivity.kt +++ b/android/app/src/main/kotlin/com/qxy/dronex/MainActivity.kt @@ -1,7 +1,11 @@ package com.qxy.dronex +import android.content.Context import android.content.pm.ApplicationInfo +import android.os.BatteryManager import android.os.Build +import android.os.Environment +import android.os.StatFs import androidx.camera.view.PreviewView import com.qxy.dronex.recording.RecordingPlatformHandler import com.qxy.dronex.recording.RecordingPreviewFactory @@ -33,6 +37,7 @@ class MainActivity : FlutterActivity() { when (call.method) { "packageInfo" -> result.success(packageInfoMap()) "deviceInfo" -> result.success(deviceInfoMap()) + "deviceHealth" -> result.success(deviceHealthMap()) else -> result.notImplemented() } } @@ -112,4 +117,29 @@ class MainActivity : FlutterActivity() { "isPhysicalDevice" to !isEmulator, ) } + + private fun deviceHealthMap(): Map { + val batteryLevelPercent = readBatteryLevelPercent() + val storageAvailablePercent = readStorageAvailablePercent() + return mapOf( + "batteryLevelPercent" to batteryLevelPercent, + "storageAvailablePercent" to storageAvailablePercent, + ) + } + + private fun readBatteryLevelPercent(): Int? { + val batteryManager = getSystemService(Context.BATTERY_SERVICE) as? BatteryManager + ?: return null + val level = + batteryManager.getIntProperty(BatteryManager.BATTERY_PROPERTY_CAPACITY) + return if (level in 0..100) level else null + } + + private fun readStorageAvailablePercent(): Double { + val stat = StatFs(Environment.getDataDirectory().path) + val totalBytes = stat.totalBytes + if (totalBytes <= 0L) return 100.0 + val availableBytes = stat.availableBytes + return availableBytes.toDouble() / totalBytes.toDouble() * 100.0 + } } diff --git a/ios/Runner/PlatformInfoPlugin.swift b/ios/Runner/PlatformInfoPlugin.swift index 87329f4..38e3b3e 100644 --- a/ios/Runner/PlatformInfoPlugin.swift +++ b/ios/Runner/PlatformInfoPlugin.swift @@ -17,6 +17,8 @@ final class PlatformInfoPlugin: NSObject, FlutterPlugin { result(packageInfoMap()) case "deviceInfo": result(deviceInfoMap()) + case "deviceHealth": + result(deviceHealthMap()) default: result(FlutterMethodNotImplemented) } @@ -32,6 +34,30 @@ final class PlatformInfoPlugin: NSObject, FlutterPlugin { ] } + private func deviceHealthMap() -> [String: Any?] { + let device = UIDevice.current + device.isBatteryMonitoringEnabled = true + + var batteryLevelPercent: Int? + let batteryLevel = device.batteryLevel + if batteryLevel >= 0 { + batteryLevelPercent = Int((batteryLevel * 100).rounded()) + } + + var storageAvailablePercent = 100.0 + if let attrs = try? FileManager.default.attributesOfFileSystem(forPath: NSHomeDirectory()), + let free = attrs[.systemFreeSize] as? NSNumber, + let total = attrs[.systemSize] as? NSNumber, + total.doubleValue > 0 { + storageAvailablePercent = free.doubleValue / total.doubleValue * 100.0 + } + + return [ + "batteryLevelPercent": batteryLevelPercent, + "storageAvailablePercent": storageAvailablePercent, + ] + } + private func deviceInfoMap() -> [String: Any] { let device = UIDevice.current return [ diff --git a/lib/core/platform/app_platform_info.dart b/lib/core/platform/app_platform_info.dart index 7e3bcde..f429300 100644 --- a/lib/core/platform/app_platform_info.dart +++ b/lib/core/platform/app_platform_info.dart @@ -1,6 +1,7 @@ import 'dart:io'; import 'package:flutter/services.dart'; +import 'package:recording_tool/core/platform/device_health_snapshot.dart'; class AppPackageInfo { const AppPackageInfo({ @@ -75,4 +76,11 @@ class AppPlatformInfo { ); return AppDeviceInfo.fromMap(result ?? const {}); } + + static Future deviceHealth() async { + final result = await _channel.invokeMapMethod( + 'deviceHealth', + ); + return DeviceHealthSnapshot.fromMap(result ?? const {}); + } } diff --git a/lib/core/platform/device_health_checker.dart b/lib/core/platform/device_health_checker.dart new file mode 100644 index 0000000..2d6f470 --- /dev/null +++ b/lib/core/platform/device_health_checker.dart @@ -0,0 +1,25 @@ +import 'package:recording_tool/core/platform/device_health_snapshot.dart'; + +class DeviceHealthChecker { + DeviceHealthChecker._(); + + static const int thresholdPercent = 10; + + static const String lowBatteryMessage = '电量低于10%,请充电'; + static const String lowStorageMessage = '内存低于10%,请清理内存'; + + static List warningLines(DeviceHealthSnapshot snapshot) { + final lines = []; + + final battery = snapshot.batteryLevelPercent; + if (battery != null && battery < thresholdPercent) { + lines.add(lowBatteryMessage); + } + + if (snapshot.storageAvailablePercent < thresholdPercent) { + lines.add(lowStorageMessage); + } + + return lines; + } +} diff --git a/lib/core/platform/device_health_snapshot.dart b/lib/core/platform/device_health_snapshot.dart new file mode 100644 index 0000000..3f1ec40 --- /dev/null +++ b/lib/core/platform/device_health_snapshot.dart @@ -0,0 +1,30 @@ +class DeviceHealthSnapshot { + const DeviceHealthSnapshot({ + this.batteryLevelPercent, + required this.storageAvailablePercent, + }); + + factory DeviceHealthSnapshot.fromMap(Map map) { + final batteryRaw = map['batteryLevelPercent']; + int? batteryLevelPercent; + if (batteryRaw is int) { + batteryLevelPercent = batteryRaw; + } else if (batteryRaw is num) { + batteryLevelPercent = batteryRaw.round(); + } + + final storageRaw = map['storageAvailablePercent']; + final storageAvailablePercent = switch (storageRaw) { + final num value => value.toDouble(), + _ => 100.0, + }; + + return DeviceHealthSnapshot( + batteryLevelPercent: batteryLevelPercent, + storageAvailablePercent: storageAvailablePercent, + ); + } + + final int? batteryLevelPercent; + final double storageAvailablePercent; +} diff --git a/lib/features/recording/recording_page.dart b/lib/features/recording/recording_page.dart index dbe6e1c..b266fc1 100644 --- a/lib/features/recording/recording_page.dart +++ b/lib/features/recording/recording_page.dart @@ -6,6 +6,8 @@ import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:permission_handler/permission_handler.dart'; +import 'package:recording_tool/core/platform/app_platform_info.dart'; +import 'package:recording_tool/core/platform/device_health_checker.dart'; import 'package:recording_tool/core/utils/date_time_formatter.dart'; import 'package:recording_tool/features/recording/model/model_recording.dart'; import 'package:recording_tool/features/recording/recording_display_name.dart'; @@ -33,7 +35,20 @@ class _RecordingPageState extends ConsumerState { WidgetsBinding.instance.addPostFrameCallback((_) => _bootstrap()); } + Future _checkAndShowDeviceHealthAlerts() async { + final snapshot = await AppPlatformInfo.deviceHealth(); + if (!mounted) return; + + final lines = DeviceHealthChecker.warningLines(snapshot); + if (lines.isEmpty) return; + + await AppDialog.deviceHealthAlert(context, lines: lines); + } + Future _bootstrap() async { + await _checkAndShowDeviceHealthAlerts(); + if (!mounted) return; + final clipboardResult = await ref .read(recordingViewModelProvider.notifier) .getClipboardContent(); @@ -95,6 +110,8 @@ class _RecordingPageState extends ConsumerState { await _showNoPlayerInfoDialog(); return; } + await _checkAndShowDeviceHealthAlerts(); + if (!mounted) return; await ref.read(recordingSessionControllerProvider.notifier).startRecording(); } diff --git a/lib/features/recording/recording_session_controller.dart b/lib/features/recording/recording_session_controller.dart index 0f30b31..91daaaf 100644 --- a/lib/features/recording/recording_session_controller.dart +++ b/lib/features/recording/recording_session_controller.dart @@ -4,6 +4,7 @@ import 'dart:io'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:recording_tool/core/permission/permission_service.dart'; +import 'package:recording_tool/core/utils/rate_limiter.dart'; import 'package:recording_tool/features/recording/recording_display_name.dart'; import 'package:recording_tool/features/recording/recording_platform.dart'; import 'package:recording_tool/features/recording/view-model/view_model_recording.dart'; @@ -95,7 +96,12 @@ final recordingSessionControllerProvider = ); class RecordingSessionController extends Notifier { + static const Duration _recordingActionInterval = Duration(milliseconds: 300); + static const Object _startRecordingThrottleKey = 'recording.session.start'; + static const Object _stopRecordingThrottleKey = 'recording.session.stop'; + StreamSubscription? _statusSubscription; + final _rateLimit = RateLimitHub(); @override RecordingSessionState build() { @@ -207,7 +213,21 @@ class RecordingSessionController extends Notifier { return _galleryPermissions().isEmpty; } + bool _tryAcquireRecordingAction(Object key) { + var executed = false; + _rateLimit.throttle( + key: key, + value: null, + duration: _recordingActionInterval, + options: const ThrottleOptions(leading: true, trailing: false), + onCallback: (_) => executed = true, + ); + return executed; + } + Future startRecording({bool enableDoNotDisturb = true}) async { + if (!_tryAcquireRecordingAction(_startRecordingThrottleKey)) return; + if (!state.isPreviewReady || state.isRecording || state.isStartingRecording) { @@ -239,6 +259,8 @@ class RecordingSessionController extends Notifier { } Future stopRecording() async { + if (!_tryAcquireRecordingAction(_stopRecordingThrottleKey)) return; + if (!state.isRecording) return; try { @@ -302,6 +324,7 @@ class RecordingSessionController extends Notifier { } Future _dispose() async { + _rateLimit.clear(); await _statusSubscription?.cancel(); } } diff --git a/lib/shared/widgets/app_dialog.dart b/lib/shared/widgets/app_dialog.dart index edff2a4..7a9ab8f 100644 --- a/lib/shared/widgets/app_dialog.dart +++ b/lib/shared/widgets/app_dialog.dart @@ -30,4 +30,24 @@ class AppDialog { }, ); } + + static Future deviceHealthAlert( + BuildContext context, { + required List lines, + }) { + return showDialog( + context: context, + builder: (dialogContext) { + return AlertDialog( + content: Text(lines.join('\n')), + actions: [ + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(), + child: const Text('确定'), + ), + ], + ); + }, + ); + } } diff --git a/test/core/platform/device_health_checker_test.dart b/test/core/platform/device_health_checker_test.dart new file mode 100644 index 0000000..ba4373b --- /dev/null +++ b/test/core/platform/device_health_checker_test.dart @@ -0,0 +1,88 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:recording_tool/core/platform/device_health_checker.dart'; +import 'package:recording_tool/core/platform/device_health_snapshot.dart'; + +void main() { + group('DeviceHealthChecker.warningLines', () { + test('returns empty when battery and storage are healthy', () { + const snapshot = DeviceHealthSnapshot( + batteryLevelPercent: 50, + storageAvailablePercent: 50, + ); + + expect(DeviceHealthChecker.warningLines(snapshot), isEmpty); + }); + + test('returns low battery message only', () { + const snapshot = DeviceHealthSnapshot( + batteryLevelPercent: 9, + storageAvailablePercent: 50, + ); + + expect( + DeviceHealthChecker.warningLines(snapshot), + [DeviceHealthChecker.lowBatteryMessage], + ); + }); + + test('returns low storage message only', () { + const snapshot = DeviceHealthSnapshot( + batteryLevelPercent: 50, + storageAvailablePercent: 9.9, + ); + + expect( + DeviceHealthChecker.warningLines(snapshot), + [DeviceHealthChecker.lowStorageMessage], + ); + }); + + test('returns both messages when battery and storage are low', () { + const snapshot = DeviceHealthSnapshot( + batteryLevelPercent: 5, + storageAvailablePercent: 5, + ); + + expect( + DeviceHealthChecker.warningLines(snapshot), + [ + DeviceHealthChecker.lowBatteryMessage, + DeviceHealthChecker.lowStorageMessage, + ], + ); + }); + + test('does not warn at exactly threshold percent', () { + const snapshot = DeviceHealthSnapshot( + batteryLevelPercent: 10, + storageAvailablePercent: 10, + ); + + expect(DeviceHealthChecker.warningLines(snapshot), isEmpty); + }); + + test('skips battery warning when level is unknown', () { + const snapshot = DeviceHealthSnapshot( + batteryLevelPercent: null, + storageAvailablePercent: 5, + ); + + expect( + DeviceHealthChecker.warningLines(snapshot), + [DeviceHealthChecker.lowStorageMessage], + ); + }); + }); + + group('DeviceHealthSnapshot.fromMap', () { + test('parses native map fields', () { + final snapshot = DeviceHealthSnapshot.fromMap({ + 'batteryLevelPercent': 42, + 'storageAvailablePercent': 12.5, + }); + + expect(snapshot.batteryLevelPercent, 42); + expect(snapshot.storageAvailablePercent, 12.5); + }); + }); +}