You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

207 lines
7.7 KiB

import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:get/get.dart';
import 'package:get_storage/get_storage.dart';
import '../../core/theme/app_palette.dart';
import '../../core/utils/logger.dart';
/// 「后台自动连接设备」的用户授权。
///
/// 为什么需要这个:连接支架/耳机时,原生侧会向系统注册
/// `BluetoothLeScanner.startScan(filters, settings, pendingIntent)` ——
/// 也就是「以后系统扫到这台设备就把 App 唤醒」。App 被唤醒后
/// [BleWakeReceiver] 会拉起前台服务接管连接与录音。
///
/// 这在应用商店的合规检测里属于**自启动 / 关联启动**行为(小米检测项
/// 「APP频繁自启动和关联启动」),必须先向用户明示用途并取得同意才能注册。
/// 所以三条蓝牙链路在注册后台扫连之前,都要先过这里。
///
/// 用户没同意时:App 依然可以在前台手动连接使用,只是设备开机/靠近时
/// 不会自动唤醒 App 回连。用户可以在「设置」里随时开关。
class BackgroundLaunchConsent {
BackgroundLaunchConsent._();
static const String _key = 'background_launch_consent';
static const String _timeKey = 'background_launch_consent_time';
static const String _tag = 'BgLaunchConsent';
static final GetStorage _storage = GetStorage();
/// 用户是否已同意后台自动连接(未表态视为不同意)
static bool get granted => _storage.read<bool>(_key) ?? false;
/// 是否已经问过(用于区分「拒绝过」和「还没问」)
static bool get asked => _storage.hasData(_key);
/// 同意时间戳,合规留痕用
static int get grantedAt => _storage.read<int>(_timeKey) ?? 0;
static Future<void> setGranted(bool value) async {
await _storage.write(_key, value);
if (value) {
await _storage.write(_timeKey, DateTime.now().millisecondsSinceEpoch);
}
Logger.i(_tag, '后台自动连接授权: $value');
}
/// 用户明确拒绝过(弹过窗且点了「暂不开启」)
static bool get declined => asked && !granted;
/// 取得授权。
///
/// [userInitiated] 为 true 时才允许弹窗——也就是只有用户**亲手点了连接**
/// 才会看到明示弹窗。自动路径(进页面自动连、冷启动回连)一律只读取已有
/// 授权状态,绝不弹窗。
///
/// 拒绝之后的行为(应用商店合规场景 1/2 + 不骚扰用户):
/// - [declined] 是落盘状态(GetStorage),不是内存标记,App 重启后依然生效;
/// - 只要 [declined] 为 true 就永久不再自动弹这个授权窗——不止「同一次使用」,
/// 而是**此后任何一次启动、任何一次点连接**都不再弹,只静默返回 false;
/// - 拒绝当下([_showDialog] 里点「暂不开启」)会紧接着在同一次交互里弹一条
/// 「已关闭,可去设置重新开启」的一次性提示,把「问一次 + 告知后果」在
/// 一次操作里问完,之后彻底不再有任何弹窗;
/// - 想重新开启,只能用户自己去「设置 → 设备自动连接」打开,
/// 那条路径走 [requestFromSettings]。
static Future<bool> ensure({bool userInitiated = false}) async {
if (granted) return true;
if (!userInitiated) return false;
if (declined) return false;
final ok = await _showDialog();
await setGranted(ok);
if (!ok) await _showDeclinedNotice();
return ok;
}
/// 设置页里用户主动打开开关:这是明确的开启意图,允许再次弹明示窗。
///
/// 这里不追加 [_showDeclinedNotice]——用户本来就站在设置页,开关本身
/// 就是"还能再开"的可见状态,不用再告诉他去设置页。
static Future<bool> requestFromSettings() async {
if (granted) return true;
final ok = await _showDialog();
await setGranted(ok);
return ok;
}
/// 拒绝后紧接着弹的一次性告知——不是再问一次,是告诉用户「已经关了、
/// 去哪开」。与上面的授权询问算同一次交互,不算合规意义上的「二次索要」。
/// 只在 [ensure] 的自动流程(非设置页)里追加,理由见上。
static Future<void> _showDeclinedNotice() async {
final bool isDark = Get.isDarkMode;
await Get.dialog<void>(
AlertDialog(
backgroundColor: AppPalette.surface(isDark),
shape:
RoundedRectangleBorder(borderRadius: BorderRadius.circular(16.r)),
content: Text(
'autoConnectReenableHint'.tr,
style: TextStyle(
fontSize: 13.sp,
height: 1.6,
color: AppPalette.textSecondary(isDark),
),
),
actions: [
TextButton(
onPressed: () => Get.back(),
child: Text('gotIt'.tr,
style: TextStyle(
fontSize: 14.sp,
fontWeight: FontWeight.w600,
color: AppPalette.accent)),
),
],
),
barrierDismissible: false,
);
}
static Future<bool> _showDialog() async {
final bool isDark = Get.isDarkMode;
final result = await Get.dialog<bool>(
AlertDialog(
backgroundColor: AppPalette.surface(isDark),
shape:
RoundedRectangleBorder(borderRadius: BorderRadius.circular(16.r)),
title: Text(
'autoConnectConsentTitle'.tr,
style: TextStyle(
fontSize: 17.sp,
fontWeight: FontWeight.w600,
color: AppPalette.textPrimary(isDark),
),
),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'autoConnectConsentIntro'.tr,
style: TextStyle(
fontSize: 13.sp,
height: 1.6,
color: AppPalette.textSecondary(isDark),
),
),
SizedBox(height: 10.h),
_bullet('autoConnectConsentB1'.tr, isDark),
_bullet('autoConnectConsentB2'.tr, isDark),
_bullet('autoConnectConsentB3'.tr, isDark),
_bullet('autoConnectConsentB4'.tr, isDark),
],
),
actions: [
TextButton(
onPressed: () => Get.back(result: false),
child: Text('autoConnectLater'.tr,
style: TextStyle(
fontSize: 14.sp, color: AppPalette.textHint(isDark))),
),
TextButton(
onPressed: () => Get.back(result: true),
child: Text('autoConnectAgree'.tr,
style: TextStyle(
fontSize: 14.sp,
fontWeight: FontWeight.w600,
color: AppPalette.accent)),
),
],
),
barrierDismissible: false,
);
return result ?? false;
}
static Widget _bullet(String text, bool isDark) {
return Padding(
padding: EdgeInsets.only(bottom: 6.h),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: EdgeInsets.only(top: 6.h, right: 6.w),
child: Container(
width: 4.w,
height: 4.w,
decoration: BoxDecoration(
color: AppPalette.accent,
shape: BoxShape.circle,
),
),
),
Expanded(
child: Text(
text,
style: TextStyle(
fontSize: 12.sp,
height: 1.5,
color: AppPalette.textSecondary(isDark),
),
),
),
],
),
);
}
}