diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 6a87534b7..9b63b731d 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -26,8 +26,8 @@ - + @@ -58,7 +58,7 @@ android:label="DeapSound" android:name="${applicationName}" android:icon="@mipmap/ic_launcher" - android:requestLegacyExternalStorage="true" + android:networkSecurityConfig="@xml/network_security_config" android:usesCleartextTraffic="true" android:enableOnBackInvokedCallback="true" diff --git a/lib/core/utils/permission_util.dart b/lib/core/utils/permission_util.dart index e51aafb3f..0961e7120 100644 --- a/lib/core/utils/permission_util.dart +++ b/lib/core/utils/permission_util.dart @@ -1,21 +1,40 @@ import 'package:flutter/material.dart'; import 'package:permission_handler/permission_handler.dart'; import 'package:app_settings/app_settings.dart'; -import 'dart:developer'; import 'logger.dart'; -// class Logger { -// static void e(String tag, String message) { -// debugPrint('[$tag] ERROR: $message'); -// } -// } +/// 权限请求工具类 +/// +/// 提供统一的权限请求流程处理: +/// 1. 初始权限状态检查 +/// 2. 权限解释横幅显示(悬浮在UI顶部的解释性UI) +/// 3. 权限请求执行 +/// 4. 永久拒绝后的设置引导 +/// +/// 使用单例模式:`PermissionUtil.instance` class PermissionUtil { + /// 单例实例 static final PermissionUtil instance = PermissionUtil._(); + + /// 私有构造函数 PermissionUtil._(); + /// 权限解释横幅的叠加层入口 OverlayEntry? _explanationOverlayEntry; + /// 请求指定权限 + /// + /// 参数: + /// [context]: BuildContext,用于UI显示和导航 + /// [permissionType]: 要请求的权限类型(Permission.microphone等) + /// [permissionName]: 权限的用户友好名称(如"麦克风"、"定位") + /// [explanationText]: 权限用途的详细解释文本 + /// [permanentDenialText]: 永久拒绝后引导用户去设置的文案 + /// [settingsButtonText]: "去设置"按钮的文本(可选,默认"前往设置") + /// [cancelButtonText]: "取消"按钮的文本(可选,默认"取消") + /// + /// 返回值:`Future` 权限是否最终授予 Future requestPermission({ required BuildContext context, required Permission permissionType, @@ -26,79 +45,108 @@ class PermissionUtil { String cancelButtonText = '取消', }) async { try { + // 检查初始权限状态 final PermissionStatus status = await permissionType.status; - log('初始权限状态 for $permissionName: $status'); + Logger.d('PermissionUtil', '初始权限状态 [$permissionName]: $status'); // 处理已授权状态(包括授予状态和临时通知权限) if (status == PermissionStatus.granted || status == PermissionStatus.provisional) { - log('$permissionName 权限已授予或临时授权。'); + Logger.i('PermissionUtil', '$permissionName 权限已授予或临时授权'); return true; } - // 处理永久拒绝状态 + // 显示权限解释横幅(覆盖所有状态的处理) + _showExplanationOverlay( + context: context, + permissionName: permissionName, + explanationText: explanationText, + ); + + // 处理永久拒绝状态(先显示说明横幅,再引导用户去设置) if (status == PermissionStatus.permanentlyDenied) { - log('$permissionName 权限被永久拒绝,显示引导对话框。'); - await _showPermanentDenialDialog( + Logger.w('PermissionUtil', '$permissionName 权限被永久拒绝,显示引导对话框'); + + // 显示永久拒绝对话框(在解释横幅下方显示) + final result = await _showPermanentDenialDialog( context: context, permissionName: permissionName, permanentDenialText: permanentDenialText, settingsButtonText: settingsButtonText, cancelButtonText: cancelButtonText, ); - return false; + // 移除解释横幅 + _hideExplanationOverlay(); + return result; } // 处理初始拒绝/未决定状态 if (status == PermissionStatus.denied) { - log('$permissionName 权限被拒绝或未决定,显示说明横幅并请求。'); - _showExplanationOverlay( - context: context, - permissionName: permissionName, - explanationText: explanationText, - ); - + Logger.d('PermissionUtil', '$permissionName 权限被拒绝或未决定,显示说明横幅并请求'); + // _showExplanationOverlay( + // context: context, + // permissionName: permissionName, + // explanationText: explanationText, + // ); +//请求权限 final PermissionStatus newStatus = await permissionType.request(); - log('请求后 $permissionName 的新权限状态: $newStatus'); + Logger.d('PermissionUtil', '请求后 $permissionName 的新权限状态: $newStatus'); - _hideExplanationOverlay(); + //_hideExplanationOverlay(); // 处理请求后的状态 if (newStatus == PermissionStatus.granted || newStatus == PermissionStatus.provisional) { - log('$permissionName 权限在请求后被授予或临时授权。'); + Logger.i('PermissionUtil', '$permissionName 权限在请求后被授予'); + _hideExplanationOverlay(); return true; - } else if (newStatus == PermissionStatus.permanentlyDenied) { - log('$permissionName 权限在请求后被永久拒绝,再次显示引导对话框。'); - await _showPermanentDenialDialog( + } + // 处理请求后变为永久拒绝 + else if (newStatus == PermissionStatus.permanentlyDenied) { + Logger.w('PermissionUtil', '$permissionName 权限在请求后被永久拒绝'); + final result = await _showPermanentDenialDialog( context: context, permissionName: permissionName, permanentDenialText: permanentDenialText, settingsButtonText: settingsButtonText, cancelButtonText: cancelButtonText, ); + _hideExplanationOverlay(); + return result; } + // 其他拒绝状态 + _hideExplanationOverlay(); return false; } // 处理受限状态 if (status == PermissionStatus.restricted || status == PermissionStatus.limited) { - log('$permissionName 权限受限或受限访问: $status'); + Logger.w('PermissionUtil', '$permissionName 权限受限或受限访问: $status'); await _showRestrictedPermissionDialog(context, permissionName); + _hideExplanationOverlay(); return false; } // 其他未知状态处理 - log('$permissionName 处于未知状态: $status'); + Logger.e('PermissionUtil', '$permissionName 处于未知状态: $status'); + _hideExplanationOverlay(); return false; - } catch (e) { - Logger.e('PermissionUtil', '请求 $permissionName 权限失败: $e'); + } catch (e, stackTrace) { + Logger.e( + 'PermissionUtil', + '请求 $permissionName 权限失败: $e', + e, + stackTrace, + ); _hideExplanationOverlay(); return false; } } + /// 显示权限解释横幅(漂浮在UI顶部的说明性UI) + /// + /// 在请求权限前显示,向用户解释为什么需要这个权限 void _showExplanationOverlay({ required BuildContext context, required String permissionName, @@ -113,8 +161,9 @@ class PermissionUtil { right: 16, child: Material( borderRadius: BorderRadius.circular(12), - elevation: 4, - color: Colors.white, + elevation: 8, + color: Theme.of(context).colorScheme.background, + shadowColor: Colors.black.withOpacity(0.3), child: Padding( padding: const EdgeInsets.all(16), child: Column( @@ -143,24 +192,33 @@ class PermissionUtil { Future.delayed(Duration.zero, () { if (_explanationOverlayEntry != null) { Overlay.of(context).insert(_explanationOverlayEntry!); - log('权限说明叠加层已显示。'); + Logger.i('PermissionUtil', '权限说明叠加层已显示 ($permissionName)'); } }); } + /// 隐藏权限解释横幅 void _hideExplanationOverlay() { - _explanationOverlayEntry?.remove(); - _explanationOverlayEntry = null; + if (_explanationOverlayEntry != null) { + _explanationOverlayEntry?.remove(); + _explanationOverlayEntry = null; + Logger.d('PermissionUtil', '权限说明叠加层已移除'); + } } - Future _showPermanentDenialDialog({ + /// 显示永久拒绝后的设置引导对话框 + /// + /// 在权限被永久拒绝时调用,提示用户前往设置手动开启权限 + Future _showPermanentDenialDialog({ required BuildContext context, required String permissionName, required String permanentDenialText, required String settingsButtonText, required String cancelButtonText, }) async { - await showDialog( + Logger.i('PermissionUtil', '显示永久拒绝对话框 ($permissionName)'); + + bool? userChoice = await showDialog( context: context, barrierDismissible: false, builder: (context) => AlertDialog( @@ -168,23 +226,39 @@ class PermissionUtil { content: Text(permanentDenialText), actions: [ TextButton( - onPressed: () => Navigator.pop(context), + onPressed: () { + Logger.d('PermissionUtil', '用户取消设置 ($permissionName)'); + Navigator.pop(context, false); + }, child: Text(cancelButtonText), ), ElevatedButton( onPressed: () { - Navigator.pop(context); - AppSettings.openAppSettings(); + Logger.d('PermissionUtil', '用户同意前往设置 ($permissionName)'); + Navigator.pop(context, true); }, child: Text(settingsButtonText), ), ], ), ); + + // 用户选择前往设置 + if (userChoice == true) { + AppSettings.openAppSettings(); + return false; // 不能立即获取权限,需要用户手动设置 + } + + // 用户取消 + return false; } + /// 显示受限权限对话框 + /// + /// 在权限受限状态(如iOS上的受限照片访问)时调用 Future _showRestrictedPermissionDialog( BuildContext context, String permissionName) async { + Logger.w('PermissionUtil', '显示受限权限对话框 ($permissionName)'); await showDialog( context: context, builder: (context) => AlertDialog( @@ -192,7 +266,10 @@ class PermissionUtil { content: const Text('此功能在当前设备上不可用或受限,请检查设备设置或系统限制'), actions: [ TextButton( - onPressed: () => Navigator.pop(context), + onPressed: () { + Logger.d('PermissionUtil', '用户确认受限权限 ($permissionName)'); + Navigator.pop(context); + }, child: const Text('确定'), ), ], diff --git a/lib/data/services/music_manager.dart b/lib/data/services/music_manager.dart index af7e91481..7e19942a6 100644 --- a/lib/data/services/music_manager.dart +++ b/lib/data/services/music_manager.dart @@ -2,6 +2,8 @@ import 'package:deep_voice/core/utils/logger.dart'; import 'package:get/get.dart'; import 'package:music_service/music_service.dart'; import 'package:permission_handler/permission_handler.dart'; +import '../../core/utils/permission_util.dart'; +import 'package:flutter/material.dart'; //音乐播放管理 class MusicManager extends GetxService { @@ -14,16 +16,35 @@ class MusicManager extends GetxService { @override void onInit() { super.onInit(); - requestNotificationPermission(); + + final context = Get.context; + if (context == null) { + Logger.error('无法获取 BuildContext'); + return; + } + requestNotificationPermission(context); Logger.info('初始化权限请求'); } - Future requestNotificationPermission() async { - if (await Permission.notification.isDenied) { - await Permission.notification.request(); - } + Future requestNotificationPermission(BuildContext context) async { + final granted = await PermissionUtil.instance.requestPermission( + context: context, + permissionType: Permission.notification, + permissionName: '通知', + explanationText: '需要通知权限来显示音乐播放状态。', + permanentDenialText: '您已永久拒绝通知权限,请前往设置开启权限。', + settingsButtonText: '去设置', + cancelButtonText: '取消', + ); + + if (!granted) { + Logger.warning('通知权限未被授予'); + return; } + Logger.info('通知权限已授予'); +} + /// 释放资源 @override void onClose() { diff --git a/lib/modules/login/controllers/login_controller.dart b/lib/modules/login/controllers/login_controller.dart index 1ac7e8af7..82c246def 100644 --- a/lib/modules/login/controllers/login_controller.dart +++ b/lib/modules/login/controllers/login_controller.dart @@ -276,6 +276,10 @@ class LoginController extends GetxController { ), ), ), + const TextSpan( + text: ',点击“同意并进入”表示您已充分阅读并同意上述协议。点击“不同意”将退出应用。', + style: TextStyle(fontSize: 14), + ), ], ), textAlign: TextAlign.center, @@ -293,10 +297,12 @@ class LoginController extends GetxController { style: TextButton.styleFrom( foregroundColor: Colors.black54, ), - child: const Text('取消'), + child: const Text('不同意'), ), ElevatedButton( onPressed: () { + _storage.write( + 'hasAcceptedPrivacy', true); // 存储用户已接受隐私政策 // 用户同意,关闭弹窗并继续 _hasAcceptedPrivacy.value = true; Get.back(); diff --git a/lib/modules/meeting/controllers/meeting_controller.dart b/lib/modules/meeting/controllers/meeting_controller.dart index 8322dc380..b191326b7 100644 --- a/lib/modules/meeting/controllers/meeting_controller.dart +++ b/lib/modules/meeting/controllers/meeting_controller.dart @@ -13,6 +13,7 @@ import 'package:permission_handler/permission_handler.dart'; import '../../../core/utils/logger.dart'; import '../../../core/utils/upload_oss.dart'; import '../../../data/services/db/sqflite_api.dart'; +import '../../../core/utils/permission_util.dart'; class MeetingController extends GetxController { final ScrollController listController = ScrollController(); @@ -41,7 +42,8 @@ class MeetingController extends GetxController { void onInit() { super.onInit(); listController.addListener(_onScroll); - Permission.storage.request(); + _requestStoragePermission(); + //Permission.storage.request(); _headers = { 'Authorization': 'Bearer; $_token', 'Content-Type': 'application/json', @@ -83,6 +85,28 @@ class MeetingController extends GetxController { isLoading = false; } + // 请求录音权限 + void _requestStoragePermission() async { + final BuildContext context = Get.overlayContext!; + + PermissionUtil.instance + .requestPermission( + context: context, + permissionType: Permission.storage, + permissionName: "存储", + explanationText: "我们需要访问存储权限,以便您可以选择并上传音频文件。", + permanentDenialText: "检测到您已永久拒绝存储权限。如需继续使用此功能,请前往应用设置中手动开启存储权限。", + ) + .then((granted) { + if (granted) { + Logger.d("Permission", "用户授予了存储权限"); + } else { + Logger.d("Permission", "用户未授予存储权限"); + // 可以提示用户无法进行文件操作 + } + }); + } + void addAudio() async { EasyLoading.show(); FilePickerResult? result = await FilePicker.platform.pickFiles(