Files
xianyan/lib/core/services/auth/permission_service.dart
Developer 0da8906f5d chore: 完成v6.5.58版本迭代更新
本次更新包含多项功能优化与bug修复:
1. 新增flutter_keyboard_visibility依赖替代MediaQuery轮询获取键盘状态
2. 添加远程功能标志API支持与FeatureFlag服务
3. 重构壁纸背景渲染组件,统一全局壁纸展示逻辑
4. 延迟初始化壁纸源健康检测至用户同意协议后
5. 修复预测返回/长按预览锁定问题并移除相关配置项
6. 优化日志输出控制,release模式仅保留错误日志
7. 新增进度模块多语言翻译与相关UI字段
8. 优化稍后读功能,取消时同步删除聊天消息
9. 更新权限说明文档,移除冗余的存储写入权限配置
10. 重构部分UI组件减少参数传递,优化性能
2026-05-30 05:30:49 +08:00

636 lines
20 KiB
Dart
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/// ============================================================
/// 闲言APP — 权限管理服务
/// 创建时间: 2026-04-23
/// 更新时间: 2026-05-30
/// 作用: 统一管理应用权限请求,支持相机/相册/通知/位置/蓝牙/附近设备/麦克风/存储/网络/剪贴板/分享权限
/// 上次更新: 更新存储权限描述(区分READ/WRITE的API级别必要性)
/// ============================================================
import 'dart:io';
import 'package:flutter/cupertino.dart';
import 'package:permission_handler/permission_handler.dart';
import '../../storage/kv_storage.dart';
import '../device/shake_detector.dart';
/// 权限状态枚举
enum AppPermissionStatus {
granted('已授权', true),
denied('未授权', false),
permanentlyDenied('已拒绝', false),
notDetermined('未请求', false),
restricted('受限', false);
const AppPermissionStatus(this.label, this.isGranted);
final String label;
final bool isGranted;
static AppPermissionStatus fromPermissionStatus(PermissionStatus status) {
return switch (status) {
PermissionStatus.granted => AppPermissionStatus.granted,
PermissionStatus.limited => AppPermissionStatus.granted,
PermissionStatus.denied => AppPermissionStatus.denied,
PermissionStatus.permanentlyDenied =>
AppPermissionStatus.permanentlyDenied,
PermissionStatus.restricted => AppPermissionStatus.restricted,
PermissionStatus.provisional => AppPermissionStatus.granted,
};
}
}
/// 权限分组枚举
enum PermissionGroup {
required('必要权限', CupertinoIcons.lock_shield_fill),
optional('可选权限', CupertinoIcons.hand_raised_fill),
system('系统权限', CupertinoIcons.gear_solid);
const PermissionGroup(this.label, this.icon);
final String label;
final IconData icon;
}
/// 权限类型枚举
enum AppPermission {
camera(
'相机',
Permission.camera,
CupertinoIcons.camera_fill,
'用于拍照制作壁纸、扫描二维码、文件传输扫码配对。仅在您主动使用相关功能时请求,不会后台调用。',
Color(0xFF34C759),
isRequired: true,
group: PermissionGroup.required,
usageScenes: ['壁纸制作 — 拍照添加图片', '二维码 — 扫描登录/配对', '文件传输 — 扫码连接设备'],
),
photos(
'相册与存储',
Permission.photos,
CupertinoIcons.photo_fill,
'用于选择图片制作壁纸、保存作品到本地相册、设置头像。仅访问您选中的图片,不会读取全部相册。',
Color(0xFF6C63FF),
isRequired: true,
group: PermissionGroup.required,
usageScenes: ['壁纸制作 — 选择图片', '保存卡片 — 保存到相册', '个人中心 — 设置头像'],
),
notification(
'通知',
Permission.notification,
CupertinoIcons.bell_fill,
'用于推送每日推荐句子、签到提醒、系统公告、文件传输状态和互动消息。您可以随时在系统设置中关闭。',
Color(0xFFFF3B30),
isRequired: true,
group: PermissionGroup.required,
usageScenes: ['每日推荐 — 定时推送', '签到提醒 — 每日提醒', '文件传输 — 传输状态', '互动消息 — 点赞评论'],
),
location(
'位置(粗略)',
Permission.location,
CupertinoIcons.location_fill,
'用于获取天气信息和节气提醒,仅使用粗略位置(城市级),不获取精确位置,不会后台追踪。',
Color(0xFF007AFF),
usageScenes: ['天气信息 — 当前城市天气', '节气提醒 — 当地节气推送'],
),
bluetooth(
'蓝牙',
Permission.bluetooth,
CupertinoIcons.bluetooth,
'用于文件传输助手的蓝牙配对和设备发现,仅在您使用文件传输功能时请求。',
Color(0xFF5AC8FA),
usageScenes: ['文件传输 — 蓝牙配对', '设备发现 — 附近设备搜索'],
),
nearbyDevices(
'附近设备',
Permission.nearbyWifiDevices,
CupertinoIcons.antenna_radiowaves_left_right,
'用于文件传输助手的局域网设备发现和连接,仅在您使用文件传输功能时请求。',
Color(0xFF64D2FF),
usageScenes: ['文件传输 — 局域网发现', '设备连接 — WiFi直连'],
),
microphone(
'麦克风',
Permission.microphone,
CupertinoIcons.mic_fill,
'用于语音朗读句子、语音搜索、AI对话语音输入。仅在您主动使用语音功能时请求不会后台录音。',
Color(0xFFFF3B30),
usageScenes: ['语音朗读 — 朗读句子', '语音搜索 — 语音输入关键词', 'AI对话 — 语音输入消息'],
),
storage(
'存储空间',
Permission.storage,
CupertinoIcons.folder_fill,
'用于保存编辑的卡片、壁纸到本地导出字体文件和数据。Android 12及以下(API≤32)需要读取权限Android 13+由相册权限替代;写入操作使用分区存储,无需额外写入权限。',
Color(0xFFFF9500),
usageScenes: [
'保存卡片 — 导出到本地',
'壁纸设置 — 保存壁纸',
'字体管理 — 下载字体文件',
'数据导出 — 导出用户数据',
],
),
network(
'网络连接',
Permission.notification,
CupertinoIcons.wifi,
'闲言需要网络连接来获取句子、同步数据和推送通知。请在系统设置中确保网络权限已开启。',
Color(0xFF007AFF),
isRequired: true,
isVirtual: true,
group: PermissionGroup.system,
usageScenes: ['句子获取 — 加载每日推荐', '数据同步 — 云端同步', '推送通知 — 接收消息'],
),
clipboard(
'剪贴板',
Permission.notification,
CupertinoIcons.doc_on_clipboard_fill,
'用于复制句子到剪贴板、粘贴文本到编辑器。应用仅在您主动操作时访问剪贴板,不会自动读取。',
Color(0xFFAF52DE),
isVirtual: true,
group: PermissionGroup.system,
usageScenes: ['复制句子 — 一键复制', '编辑器 — 粘贴文本', '搜索 — 粘贴关键词'],
),
share(
'分享能力',
Permission.notification,
CupertinoIcons.share,
'允许应用通过系统分享面板将内容分享到其他应用',
Color(0xFF007AFF),
isVirtual: true,
group: PermissionGroup.system,
usageScenes: ['句子分享 — 分享到微信/QQ', '卡片分享 — 分享到社交平台', '日志导出 — 分享日志文件'],
),
shake(
'摇一摇',
Permission.notification,
CupertinoIcons.arrow_counterclockwise,
'摇晃手机触发特定功能,如换句、刷新等',
Color(0xFF5856D6),
isVirtual: true,
usageScenes: ['切换每日推荐句子', '刷新内容', '互动彩蛋'],
);
const AppPermission(
this.label,
this.permission,
this.icon,
this.description,
this.color, {
this.isRequired = false,
this.isVirtual = false,
this.group = PermissionGroup.optional,
this.usageScenes = const [],
});
final String label;
final Permission permission;
final IconData icon;
final String description;
final Color color;
final bool isRequired;
final bool isVirtual;
final PermissionGroup group;
final List<String> usageScenes;
/// Android 13+ 不需要 storage 权限(由 photos 替代)
bool get isPlatformRelevant {
if (this == AppPermission.storage) {
if (!Platform.isAndroid) return false;
final sdkInt = _androidSdkInt;
return sdkInt != null && sdkInt <= 32;
}
return true;
}
static int? get _androidSdkInt {
try {
return int.tryParse(Platform.version.split('.').first);
} catch (_) {
return null;
}
}
}
/// 权限管理服务 — iOS 风格权限请求
class PermissionService {
static final _log = _PermissionLogger();
// ============================================================
// 权限使用统计
// ============================================================
static const _usageStatsKey = 'permission_usage_stats';
static const _shakeEnabledKey = 'shake_enabled';
static bool get isShakeEnabled =>
KvStorage.getBool(_shakeEnabledKey, box: HiveBoxNames.userPrefs) ?? true;
static Future<void> setShakeEnabled(bool enabled) async {
await KvStorage.setBool(
_shakeEnabledKey,
enabled,
box: HiveBoxNames.userPrefs,
);
if (!enabled) {
try {
ShakeDetector.instance.stop();
} catch (_) {}
}
}
/// 记录权限使用
static void recordUsage(AppPermission permission) {
try {
final stats = getUsageStats();
final key = permission.name;
final existing = stats[key];
if (existing != null) {
existing['count'] = (existing['count'] as int) + 1;
existing['lastUsed'] = DateTime.now().toIso8601String();
} else {
stats[key] = {
'count': 1,
'lastUsed': DateTime.now().toIso8601String(),
'firstUsed': DateTime.now().toIso8601String(),
};
}
KvStorage.setString(
_usageStatsKey,
_encodeStats(stats),
box: HiveBoxNames.userPrefs,
);
_log.i('权限使用记录: ${permission.label}');
} catch (e) {
_log.e('权限使用记录失败', e);
}
}
/// 获取使用统计
static Map<String, Map<String, dynamic>> getUsageStats() {
try {
final raw = KvStorage.getString(
_usageStatsKey,
box: HiveBoxNames.userPrefs,
);
if (raw == null || raw.isEmpty) return {};
return _decodeStats(raw);
} catch (e) {
_log.e('获取权限使用统计失败', e);
return {};
}
}
/// 获取单个权限的使用统计
static PermissionUsageStat? getPermissionUsage(AppPermission permission) {
final stats = getUsageStats();
final data = stats[permission.name];
if (data == null) return null;
return PermissionUsageStat.fromJson(data);
}
/// 获取使用频率标签
static String getFrequencyLabel(AppPermission permission) {
final stat = getPermissionUsage(permission);
if (stat == null) return '未使用';
final count = stat.count;
if (count <= 2) return '';
if (count <= 10) return '';
return '';
}
/// 获取使用频率等级 (0-3)
static int getFrequencyLevel(AppPermission permission) {
final stat = getPermissionUsage(permission);
if (stat == null) return 0;
final count = stat.count;
if (count <= 2) return 1;
if (count <= 10) return 2;
return 3;
}
/// 清除使用统计
static void clearUsageStats() {
KvStorage.remove(_usageStatsKey, box: HiveBoxNames.userPrefs);
}
static String _encodeStats(Map<String, Map<String, dynamic>> stats) {
final buffer = StringBuffer();
stats.forEach((key, value) {
if (buffer.isNotEmpty) buffer.write(';');
buffer.write(
'$key=${value['count']},${value['lastUsed']},${value['firstUsed'] ?? ''}',
);
});
return buffer.toString();
}
static Map<String, Map<String, dynamic>> _decodeStats(String raw) {
final result = <String, Map<String, dynamic>>{};
if (raw.isEmpty) return result;
final entries = raw.split(';');
for (final entry in entries) {
final parts = entry.split('=');
if (parts.length != 2) continue;
final key = parts[0];
final values = parts[1].split(',');
if (values.length < 2) continue;
result[key] = {
'count': int.tryParse(values[0]) ?? 0,
'lastUsed': values[1],
'firstUsed': values.length > 2 ? values[2] : '',
};
}
return result;
}
/// 检查单个权限状态
static Future<AppPermissionStatus> checkStatus(AppPermission perm) async {
if (perm.isVirtual) {
return _checkVirtualStatus(perm);
}
try {
final status = await perm.permission.status;
return AppPermissionStatus.fromPermissionStatus(status);
} catch (e) {
_log.e('权限状态查询异常', e);
return AppPermissionStatus.notDetermined;
}
}
/// 虚拟权限状态检查
static Future<AppPermissionStatus> _checkVirtualStatus(
AppPermission perm,
) async {
switch (perm) {
case AppPermission.network:
return AppPermissionStatus.granted;
case AppPermission.clipboard:
return AppPermissionStatus.granted;
case AppPermission.share:
return AppPermissionStatus.granted;
case AppPermission.shake:
return isShakeEnabled
? AppPermissionStatus.granted
: AppPermissionStatus.denied;
default:
return AppPermissionStatus.granted;
}
}
/// 批量查询所有权限状态(过滤平台不相关权限)
static Future<Map<AppPermission, AppPermissionStatus>>
checkAllStatus() async {
final results = <AppPermission, AppPermissionStatus>{};
for (final perm in AppPermission.values) {
if (!perm.isPlatformRelevant) continue;
results[perm] = await checkStatus(perm);
}
return results;
}
/// 检查并请求单个权限
/// 修复:请求失败或被永久拒绝时,引导用户跳转系统设置
static Future<bool> requestPermission(
BuildContext context,
AppPermission perm, {
String? rationale,
}) async {
if (perm.isVirtual) return true;
try {
final status = await perm.permission.status;
if (status.isGranted) return true;
if (status.isPermanentlyDenied) {
if (context.mounted) {
_showSettingsDialog(context, perm);
}
return false;
}
final result = await perm.permission.request();
if (result.isGranted) {
_log.i('${perm.label} 权限已授予');
return true;
}
if (result.isPermanentlyDenied) {
_log.w('⚠️ ${perm.label} 权限被永久拒绝,引导用户前往系统设置');
if (context.mounted) {
_showSettingsDialog(context, perm);
}
return false;
}
if (result.isDenied) {
_log.w('⚠️ ${perm.label} 权限被拒绝');
if (context.mounted) {
_showDeniedDialog(context, perm, rationale);
}
return false;
}
if (result.isRestricted) {
_log.w('⚠️ ${perm.label} 权限受限');
if (context.mounted) {
_showSettingsDialog(context, perm);
}
return false;
}
if (context.mounted) {
_showDeniedDialog(context, perm, rationale);
}
return false;
} catch (e) {
_log.e('权限请求异常', e);
if (context.mounted) {
_showSettingsDialog(context, perm);
}
return false;
}
}
/// 批量检查并请求多个权限
static Future<Map<AppPermission, bool>> requestPermissions(
BuildContext context,
List<AppPermission> permissions, {
Map<AppPermission, String>? rationales,
}) async {
final results = <AppPermission, bool>{};
for (final perm in permissions) {
if (perm.isVirtual) {
results[perm] = true;
continue;
}
results[perm] = await requestPermission(
context,
perm,
rationale: rationales?[perm],
);
}
return results;
}
/// 快捷方法: 请求相机权限
static Future<bool> requestCamera(BuildContext context) => requestPermission(
context,
AppPermission.camera,
rationale: '需要相机权限才能拍照添加图片',
);
/// 快捷方法: 请求相册权限
static Future<bool> requestPhotos(BuildContext context) => requestPermission(
context,
AppPermission.photos,
rationale: '需要相册权限才能选择图片',
);
/// 快捷方法: 请求通知权限
static Future<bool> requestNotification(BuildContext context) =>
requestPermission(
context,
AppPermission.notification,
rationale: '需要通知权限才能推送每日推荐',
);
/// 快捷方法: 请求位置权限
static Future<bool> requestLocation(BuildContext context) =>
requestPermission(
context,
AppPermission.location,
rationale: '需要位置权限才能提供天气和节气信息',
);
/// 快捷方法: 请求麦克风权限
static Future<bool> requestMicrophone(BuildContext context) =>
requestPermission(
context,
AppPermission.microphone,
rationale: '需要麦克风权限才能使用语音功能',
);
/// 打开系统设置
static Future<bool> openSettings() => openAppSettings();
static void _showSettingsDialog(BuildContext context, AppPermission perm) {
showCupertinoDialog<void>(
context: context,
builder: (_) => CupertinoAlertDialog(
title: Text('${perm.label}权限被拒绝'),
content: Text('请在系统设置中开启${perm.label}权限,以使用此功能'),
actions: [
CupertinoDialogAction(
child: const Text('取消'),
onPressed: () => Navigator.pop(context),
),
CupertinoDialogAction(
isDefaultAction: true,
child: const Text('去设置'),
onPressed: () {
Navigator.pop(context);
openAppSettings();
},
),
],
),
);
}
static void _showDeniedDialog(
BuildContext context,
AppPermission perm,
String? rationale,
) {
showCupertinoDialog<void>(
context: context,
builder: (_) => CupertinoAlertDialog(
title: Text('${perm.label}权限请求'),
content: Text(rationale ?? '请允许${perm.label}权限以继续'),
actions: [
CupertinoDialogAction(
child: const Text('取消'),
onPressed: () => Navigator.pop(context),
),
CupertinoDialogAction(
child: const Text('去设置'),
onPressed: () {
Navigator.pop(context);
openAppSettings();
},
),
CupertinoDialogAction(
isDefaultAction: true,
child: const Text('再次请求'),
onPressed: () {
Navigator.pop(context);
requestPermission(context, perm, rationale: rationale);
},
),
],
),
);
}
}
/// 简易日志工具
class _PermissionLogger {
void i(String msg) => debugPrint('[PermissionService] $msg');
void w(String msg) => debugPrint('[PermissionService⚠] $msg');
void e(String msg, [Object? error]) =>
debugPrint('[PermissionService❌] $msg $error');
}
/// 权限使用统计数据
class PermissionUsageStat {
const PermissionUsageStat({
required this.count,
required this.lastUsed,
this.firstUsed,
});
final int count;
final String lastUsed;
final String? firstUsed;
DateTime? get lastUsedDateTime {
try {
return DateTime.parse(lastUsed);
} catch (_) {
return null;
}
}
DateTime? get firstUsedDateTime {
if (firstUsed == null || firstUsed!.isEmpty) return null;
try {
return DateTime.parse(firstUsed!);
} catch (_) {
return null;
}
}
String get frequencyLabel {
if (count <= 2) return '';
if (count <= 10) return '';
return '';
}
String get relativeLastUsed {
final dt = lastUsedDateTime;
if (dt == null) return '未知';
final diff = DateTime.now().difference(dt);
if (diff.inMinutes < 1) return '刚刚';
if (diff.inHours < 1) return '${diff.inMinutes}分钟前';
if (diff.inDays < 1) return '${diff.inHours}小时前';
if (diff.inDays < 7) return '${diff.inDays}天前';
return '${dt.month}${dt.day}';
}
factory PermissionUsageStat.fromJson(Map<String, dynamic> json) {
return PermissionUsageStat(
count: json['count'] as int? ?? 0,
lastUsed: json['lastUsed'] as String? ?? '',
firstUsed: json['firstUsed'] as String?,
);
}
}