Files
xianyan/lib/core/services/auth/permission_service.dart
2026-06-06 06:54:22 +08:00

784 lines
24 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-06-06
/// 作用: 统一管理应用权限请求,支持相机/相册/通知/位置/蓝牙/附近设备/麦克风/存储/网络/剪贴板/分享权限 + iOS ATT授权
/// 上次更新: 新增requestTrackingPermission()方法支持iOS App Tracking Transparency授权
/// ============================================================
import 'dart:io';
import 'package:flutter/cupertino.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:app_tracking_transparency/app_tracking_transparency.dart';
import 'package:permission_handler/permission_handler.dart';
import '../../storage/kv_storage.dart';
import '../../utils/platform/platform_utils.dart' as pu;
import '../../../l10n/translation_resolver.dart';
import '../../../l10n/types/t_settings_permission.dart';
import '../device/shake_detector.dart';
/// 从 BuildContext 获取权限翻译
TSettingsPermission _permOf(BuildContext context) {
final container = ProviderScope.containerOf(context, listen: false);
return container.read(translationsProvider).settings.permission;
}
/// 权限状态枚举
enum AppPermissionStatus {
granted(true),
denied(false),
permanentlyDenied(false),
notDetermined(false),
restricted(false);
const AppPermissionStatus(this.isGranted);
final bool isGranted;
/// 从翻译系统获取状态标签
String label(BuildContext context) {
final t = _permOf(context);
return switch (this) {
AppPermissionStatus.granted => t.statusGranted,
AppPermissionStatus.denied => t.statusDenied,
AppPermissionStatus.permanentlyDenied => t.statusPermanentlyDenied,
AppPermissionStatus.notDetermined => t.statusNotDetermined,
AppPermissionStatus.restricted => t.statusRestricted,
};
}
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.icon);
final IconData icon;
/// 从翻译系统获取分组标签
String label(BuildContext context) {
final t = _permOf(context);
return switch (this) {
PermissionGroup.required => t.badgeRequired,
PermissionGroup.optional => t.badgeOptional,
PermissionGroup.system => t.badgeSystem,
};
}
}
/// 权限类型枚举
enum AppPermission {
camera(
Permission.camera,
CupertinoIcons.camera_fill,
Color(0xFF34C759),
isRequired: true,
group: PermissionGroup.required,
),
photos(
Permission.photos,
CupertinoIcons.photo_fill,
Color(0xFF6C63FF),
isRequired: true,
group: PermissionGroup.required,
),
notification(
Permission.notification,
CupertinoIcons.bell_fill,
Color(0xFFFF3B30),
isRequired: true,
group: PermissionGroup.required,
),
location(
Permission.location,
CupertinoIcons.location_fill,
Color(0xFF007AFF),
),
nearbyDevices(
Permission.nearbyWifiDevices,
CupertinoIcons.antenna_radiowaves_left_right,
Color(0xFF64D2FF),
),
microphone(Permission.microphone, CupertinoIcons.mic_fill, Color(0xFFFF3B30)),
storage(Permission.storage, CupertinoIcons.folder_fill, Color(0xFFFF9500)),
network(
Permission.notification,
CupertinoIcons.wifi,
Color(0xFF007AFF),
isRequired: true,
isVirtual: true,
group: PermissionGroup.system,
),
clipboard(
Permission.notification,
CupertinoIcons.doc_on_clipboard_fill,
Color(0xFFAF52DE),
isVirtual: true,
group: PermissionGroup.system,
),
share(
Permission.notification,
CupertinoIcons.share,
Color(0xFF007AFF),
isVirtual: true,
group: PermissionGroup.system,
),
shake(
Permission.notification,
CupertinoIcons.arrow_counterclockwise,
Color(0xFF5856D6),
isVirtual: true,
);
const AppPermission(
this.permission,
this.icon,
this.color, {
this.isRequired = false,
this.isVirtual = false,
this.group = PermissionGroup.optional,
});
final Permission permission;
final IconData icon;
final Color color;
final bool isRequired;
final bool isVirtual;
final PermissionGroup group;
/// 从翻译系统获取权限名称
String label(BuildContext context) {
final t = _permOf(context);
return switch (this) {
AppPermission.camera => t.permCameraLabel,
AppPermission.photos => t.permPhotosLabel,
AppPermission.notification => t.permNotificationLabel,
AppPermission.location => t.permLocationLabel,
AppPermission.nearbyDevices => t.permNearbyDevicesLabel,
AppPermission.microphone => t.permMicrophoneLabel,
AppPermission.storage => t.permStorageLabel,
AppPermission.network => t.permNetworkLabel,
AppPermission.clipboard => t.permClipboardLabel,
AppPermission.share => t.permShareLabel,
AppPermission.shake => t.permShakeLabel,
};
}
/// 从翻译系统获取权限描述
String description(BuildContext context) {
final t = _permOf(context);
return switch (this) {
AppPermission.camera => t.permCameraDesc,
AppPermission.photos => t.permPhotosDesc,
AppPermission.notification => t.permNotificationDesc,
AppPermission.location => t.permLocationDesc,
AppPermission.nearbyDevices => t.permNearbyDevicesDesc,
AppPermission.microphone => t.permMicrophoneDesc,
AppPermission.storage => t.permStorageDesc,
AppPermission.network => t.permNetworkDesc,
AppPermission.clipboard => t.permClipboardDesc,
AppPermission.share => t.permShareDesc,
AppPermission.shake => t.permShakeDesc,
};
}
/// 从翻译系统获取使用场景列表(管道符分隔)
List<String> usageScenes(BuildContext context) {
final t = _permOf(context);
final raw = switch (this) {
AppPermission.camera => t.permCameraUsage,
AppPermission.photos => t.permPhotosUsage,
AppPermission.notification => t.permNotificationUsage,
AppPermission.location => t.permLocationUsage,
AppPermission.nearbyDevices => t.permNearbyDevicesUsage,
AppPermission.microphone => t.permMicrophoneUsage,
AppPermission.storage => t.permStorageUsage,
AppPermission.network => t.permNetworkUsage,
AppPermission.clipboard => t.permClipboardUsage,
AppPermission.share => t.permShareUsage,
AppPermission.shake => t.permShakeUsage,
};
if (raw.isEmpty) return const [];
return raw.split('|');
}
/// 从翻译系统获取拒绝影响说明
String denialImpact(BuildContext context) {
final t = _permOf(context);
return switch (this) {
AppPermission.camera => t.permCameraDenial,
AppPermission.photos => t.permPhotosDenial,
AppPermission.notification => t.permNotificationDenial,
AppPermission.location => t.permLocationDenial,
AppPermission.nearbyDevices => t.permNearbyDevicesDenial,
AppPermission.microphone => t.permMicrophoneDenial,
AppPermission.storage => t.permStorageDenial,
AppPermission.network => t.permNetworkDenial,
AppPermission.clipboard => t.permClipboardDenial,
AppPermission.share => t.permShareDenial,
AppPermission.shake => t.permShakeDenial,
};
}
/// Android 13+ 不需要 storage 权限(由 photos 替代)
bool get isPlatformRelevant {
if (this == AppPermission.storage) {
if (!pu.isAndroid) return false;
final sdkInt = _androidSdkInt;
return sdkInt != null && sdkInt <= 32;
}
return true;
}
static int? get _androidSdkInt {
try {
return int.tryParse(pu.platformVersion.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 (_) {}
} else {
// 重新启用摇一摇时重启检测器
try {
ShakeDetector.instance.start();
} 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.name}');
} 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;
}
if (context.mounted) {
final userConfirmed = await _showRationaleDialog(
context,
perm,
rationale: rationale,
);
if (!userConfirmed) return false;
}
final result = await perm.permission.request();
if (result.isGranted) {
_log.i('${perm.name} 权限已授予');
return true;
}
if (result.isPermanentlyDenied) {
_log.w('⚠️ ${perm.name} 权限被永久拒绝,引导用户前往系统设置');
if (context.mounted) {
_showSettingsDialog(context, perm);
}
return false;
}
if (result.isDenied) {
_log.w('⚠️ ${perm.name} 权限被拒绝');
if (context.mounted) {
_showDeniedDialog(context, perm, rationale);
}
return false;
}
if (result.isRestricted) {
_log.w('⚠️ ${perm.name} 权限受限');
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);
/// 快捷方法: 请求相册权限
static Future<bool> requestPhotos(BuildContext context) =>
requestPermission(context, AppPermission.photos);
/// 快捷方法: 请求通知权限
static Future<bool> requestNotification(BuildContext context) =>
requestPermission(context, AppPermission.notification);
/// 快捷方法: 请求位置权限
static Future<bool> requestLocation(BuildContext context) =>
requestPermission(context, AppPermission.location);
/// 快捷方法: 请求麦克风权限
static Future<bool> requestMicrophone(BuildContext context) =>
requestPermission(context, AppPermission.microphone);
/// 打开系统设置
static Future<bool> openSettings() => openAppSettings();
// ============================================================
// iOS App Tracking Transparency (ATT)
// ============================================================
/// 请求App Tracking Transparency授权仅iOS
///
/// 在iOS 14.5+上请求用户授权追踪其他平台直接返回true。
/// 应在用户同意隐私协议后调用。
/// 返回true表示用户授权追踪false表示拒绝或非iOS平台。
static Future<bool> requestTrackingPermission() async {
if (!Platform.isIOS) return true;
try {
final status =
await AppTrackingTransparency.requestTrackingAuthorization();
_log.i('ATT授权状态: $status');
return status == TrackingStatus.authorized;
} catch (e) {
_log.e('ATT请求失败', e);
return false;
}
}
/// 检查当前ATT授权状态仅iOS
static Future<TrackingStatus> getTrackingStatus() async {
if (!Platform.isIOS) return TrackingStatus.authorized;
try {
return await AppTrackingTransparency.trackingAuthorizationStatus;
} catch (e) {
_log.e('ATT状态查询失败', e);
return TrackingStatus.notSupported;
}
}
static void _showSettingsDialog(BuildContext context, AppPermission perm) {
final permLabel = perm.label(context);
showCupertinoDialog<void>(
context: context,
builder: (_) => CupertinoAlertDialog(
title: Text('$permLabel权限被拒绝'),
content: Text('请在系统设置中开启$permLabel权限,以使用此功能'),
actions: [
CupertinoDialogAction(
child: const Text('取消'),
onPressed: () => Navigator.pop(context),
),
CupertinoDialogAction(
isDefaultAction: true,
child: const Text('去设置'),
onPressed: () {
Navigator.pop(context);
openAppSettings();
},
),
],
),
);
}
/// 权限申请前的说明对话框
static Future<bool> _showRationaleDialog(
BuildContext context,
AppPermission perm, {
String? rationale,
}) async {
final permLabel = perm.label(context);
final purpose = rationale ?? perm.description(context);
final impact = perm.denialImpact(context);
final scenes = perm.usageScenes(context);
return showCupertinoDialog<bool>(
context: context,
builder: (_) => CupertinoAlertDialog(
title: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(perm.icon, size: 20, color: perm.color),
const SizedBox(width: 8),
Text('申请$permLabel权限'),
],
),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 8),
const Text(
'📋 用途',
style: TextStyle(fontWeight: FontWeight.w600, fontSize: 13),
),
const SizedBox(height: 4),
Text(purpose, style: const TextStyle(fontSize: 13)),
if (scenes.isNotEmpty) ...[
const SizedBox(height: 8),
const Text(
'🔑 使用场景',
style: TextStyle(fontWeight: FontWeight.w600, fontSize: 13),
),
const SizedBox(height: 4),
...scenes
.take(3)
.map(
(s) => Padding(
padding: const EdgeInsets.only(left: 8, bottom: 2),
child: Text('$s', style: const TextStyle(fontSize: 12)),
),
),
],
const SizedBox(height: 8),
const Text(
'⚠️ 拒绝影响',
style: TextStyle(fontWeight: FontWeight.w600, fontSize: 13),
),
const SizedBox(height: 4),
Text(impact, style: const TextStyle(fontSize: 13)),
],
),
actions: [
CupertinoDialogAction(
child: const Text('暂不'),
onPressed: () => Navigator.pop(context, false),
),
CupertinoDialogAction(
isDefaultAction: true,
child: const Text('允许'),
onPressed: () => Navigator.pop(context, true),
),
],
),
).then((v) => v ?? false);
}
static void _showDeniedDialog(
BuildContext context,
AppPermission perm,
String? rationale,
) {
final permLabel = perm.label(context);
showCupertinoDialog<void>(
context: context,
builder: (_) => CupertinoAlertDialog(
title: Text('$permLabel权限请求'),
content: Text(rationale ?? '请允许$permLabel权限以继续'),
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?,
);
}
}