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
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),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|