324 lines
12 KiB
Dart
324 lines
12 KiB
Dart
import 'dart:convert';
|
||
import 'dart:io';
|
||
|
||
import 'package:crypto/crypto.dart';
|
||
import 'package:device_identity/device_identity.dart';
|
||
import 'package:device_info_plus/device_info_plus.dart';
|
||
import 'package:flutter/services.dart';
|
||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||
import 'package:hgdj/extension/extensions.dart';
|
||
import 'package:package_info_plus/package_info_plus.dart';
|
||
import 'package:path_provider/path_provider.dart';
|
||
import 'package:shared_preferences/shared_preferences.dart';
|
||
import 'package:uuid/uuid.dart';
|
||
|
||
import '../config/config.dart';
|
||
import '../hj_utils/light_model.dart';
|
||
import '../hj_utils/store_keys.dart';
|
||
import '../hj_utils/text_util.dart';
|
||
|
||
//设备信息管理类
|
||
class DeviceInfoService {
|
||
/// iOS Keychain 存 DevID。同一个 bundle id 卸载重装后 Keychain 项仍然存在(iOS
|
||
/// 沙盒清理不动 Keychain) → 同账号身份。**没声明 Keychain Sharing access group,
|
||
/// 不同 bundle id 马甲互相读不到对方的 DevID(各自独立),这是产品取舍。**
|
||
static const _kKeychainDevIdKey = 'hgdj_device_id_v1';
|
||
static const _secureStorage = FlutterSecureStorage(
|
||
iOptions: IOSOptions(
|
||
accessibility: KeychainAccessibility.first_unlock,
|
||
),
|
||
);
|
||
|
||
static final DeviceInfoPlugin _infoPlugin = DeviceInfoPlugin();
|
||
static String? _brand;
|
||
static String? _model;
|
||
static String? _deviceId;
|
||
static String? _deviceOS;
|
||
static String? _systemVersion;
|
||
static String? _devType;
|
||
static String? _ua;
|
||
static String? _buildID;
|
||
|
||
//剪切板缓存(iOS 每次读取会弹系统提示,全局只读一次;缓存 Future 防并发重复触发)
|
||
static Future<String?>? _clipboardFuture;
|
||
|
||
/// 获取剪切板文本,全局只触发一次系统读取
|
||
static Future<String?> getClipboardTextCached() {
|
||
return _clipboardFuture ??= _readClipboardOnce();
|
||
}
|
||
|
||
static Future<String?> _readClipboardOnce() async {
|
||
try {
|
||
final data = await Clipboard.getData(Clipboard.kTextPlain);
|
||
return data?.text;
|
||
} catch (_) {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/// 获取品牌
|
||
static String get brand => _brand ?? "";
|
||
|
||
/// 获取型号
|
||
static String get model => _model ?? "";
|
||
|
||
/// 设备唯一 ID(APP 内保持不变)
|
||
static String get deviceId => _deviceId ?? "";
|
||
|
||
/// 设备平台
|
||
static String get deviceOS => _deviceOS ?? "";
|
||
|
||
//系统版本
|
||
static String get systemVersion => _systemVersion ?? "";
|
||
|
||
//拼接参数
|
||
static String get devType => _devType ?? "";
|
||
static String get ua => _ua ?? "";
|
||
|
||
/// 修正后的 pkgName:iOS 用 TF 内部包名常量,Android 用真实包名
|
||
static String get buildID => _buildID ?? "";
|
||
|
||
/// ios tf 的内部包名
|
||
/// note_this:不要修改这个值
|
||
static const iosBundleId = "yinse_opera";
|
||
static const naticeChannel = 'com.yinse/device'; //原生通道(设备/视频/编解码信息)
|
||
static const requestChannel =
|
||
'com.yinse/request'; //原生系统桥(现只剩换启动图标 changeIcon)
|
||
|
||
/// 外部调用入口
|
||
static Future<void> init() async {
|
||
// buildID 初始化一次,后续都用缓存
|
||
if (Platform.isIOS) {
|
||
_buildID = iosBundleId;
|
||
} else {
|
||
final pkg = await PackageInfo.fromPlatform();
|
||
_buildID = pkg.packageName;
|
||
}
|
||
// 设备ID:多重持久化(SP + 私有目录文件),任一存在即复用
|
||
_deviceId = await _resolveDeviceId();
|
||
|
||
if (Platform.isAndroid) {
|
||
final android = await _infoPlugin.androidInfo;
|
||
_brand = android.brand;
|
||
_model = android.model;
|
||
_deviceOS = "Android";
|
||
_systemVersion = android.version.release;
|
||
_devType = "${android.device}:${android.version.sdkInt}:id=${android.id}";
|
||
} else if (Platform.isIOS) {
|
||
final ios = await _infoPlugin.iosInfo;
|
||
_brand = "Apple";
|
||
_model = ios.utsname.machine; // 如 iPhone14,2
|
||
_deviceOS = "iOS";
|
||
_systemVersion = ios.utsname.release;
|
||
_devType = "${ios.systemName}:$_systemVersion";
|
||
} else {
|
||
_brand = "Unknown";
|
||
_model = "Unknown";
|
||
_deviceOS = Platform.operatingSystem;
|
||
_systemVersion = 'Unknown';
|
||
}
|
||
_ua = genUserAgent();
|
||
}
|
||
|
||
/// 拼装 UA。[deviceId] 可覆盖默认 deviceId
|
||
static String genUserAgent({String? deviceId}) {
|
||
final Map<String, String?> params = {
|
||
"DevID": (deviceId == null || deviceId.isEmpty)
|
||
? DeviceInfoService.deviceId
|
||
: deviceId,
|
||
"DevType": DeviceInfoService.devType,
|
||
"SysType": Platform.operatingSystem,
|
||
"Ver": Config.innerVersion,
|
||
"BuildID": DeviceInfoService.buildID,
|
||
'DeviceBrand': DeviceInfoService.brand,
|
||
'SystemName': DeviceInfoService.deviceOS,
|
||
'SystemVersion': DeviceInfoService.systemVersion,
|
||
'device_model': DeviceInfoService.model,
|
||
};
|
||
params.removeWhere((key, value) => value == null || value.isEmpty);
|
||
final ua = Uri.encodeComponent(
|
||
params.entries.map((e) => "${e.key}=${e.value}").join(";"));
|
||
return ua;
|
||
}
|
||
|
||
/// 获取带缓存的 UA。
|
||
/// - [deviceId] 为空:命中 KV 缓存直接返回;
|
||
/// - [deviceId] 非空:跳过缓存,按当前最新设备信息重新生成并写回 KV(用于扫码登录、设备切换等场景)。
|
||
/// 实际写入 UA 的 DevID 字段:deviceId 非空用它,否则 fallback 到 [DeviceInfoService.deviceId]。
|
||
static Future<String> getCachedUserAgent({String? deviceId}) async {
|
||
final oldUa = (await lightKV.getString(StoreKeys.UA_CACHE)) ?? '';
|
||
if (TextUtil.isEmpty(deviceId) && TextUtil.isNotEmpty(oldUa)) {
|
||
return oldUa;
|
||
}
|
||
final newUa = genUserAgent(deviceId: deviceId);
|
||
if (TextUtil.isNotEmpty(newUa))
|
||
lightKV.setString(StoreKeys.UA_CACHE, newUa);
|
||
return newUa;
|
||
}
|
||
|
||
/// 清除 UA 缓存
|
||
static Future clearCachedUserAgent() =>
|
||
lightKV.setString(StoreKeys.UA_CACHE, null);
|
||
|
||
/// 从剪切板 json 里取推广 tid,取不到返回空串
|
||
static Future<String> fetchTraceId() async {
|
||
final clipboardText = await getClipboardTextCached();
|
||
if (TextUtil.isEmpty(clipboardText)) return '';
|
||
try {
|
||
return jsonDecode(clipboardText!)['tid'] ?? '';
|
||
} catch (_) {
|
||
return '';
|
||
}
|
||
}
|
||
|
||
/// 已知 ANDROID_ID 脏值(厂商定制 ROM / 早期山寨机长期出现)
|
||
static const _dirtyAndroidIds = <String>{
|
||
'9774d56d682e549c', // Android 4.2 之前山寨机著名脏值
|
||
'0000000000000000',
|
||
'ffffffffffffffff',
|
||
'0123456789abcdef',
|
||
};
|
||
|
||
/// 解析设备 ID:按平台分两条独立路径,任一命中都回写到该平台的所有存储层。
|
||
///
|
||
/// iOS Keychain 是唯一**跨卸载 + 跨 bundle id** 持久的存储(配合 Keychain Sharing)。
|
||
/// SP/文件在 app 沙盒里,卸载就抹;放最高优先级用 Keychain 才能保住账号身份。
|
||
/// Android 没有等价机制,applicationId 一变就是新 app,账号无法共享(产品取舍)。
|
||
static Future<String> _resolveDeviceId() async {
|
||
final prefs = await SharedPreferences.getInstance();
|
||
return Platform.isIOS ? _resolveIOS(prefs) : _resolveAndroid(prefs);
|
||
}
|
||
|
||
/// iOS:Keychain → SP → 文件 → 新生成,命中即回写三层(Keychain+SP+文件)
|
||
static Future<String> _resolveIOS(SharedPreferences prefs) async {
|
||
// 1. Keychain 优先(卸载重装、马甲切换都能拿到原 DevID)
|
||
try {
|
||
final kid = await _secureStorage.read(key: _kKeychainDevIdKey);
|
||
if (TextUtil.isNotEmpty(kid)) return _persistIOS(prefs, kid!);
|
||
} catch (_) {
|
||
// Keychain 偶发失败不阻塞启动,继续走 SP/文件兜底
|
||
}
|
||
// 2. SP 命中
|
||
final spId = prefs.getString(StoreKeys.DEVICE_ID);
|
||
if (TextUtil.isNotEmpty(spId)) return _persistIOS(prefs, spId!);
|
||
// 3. 文件命中
|
||
final fileId = await _readIdFile();
|
||
if (TextUtil.isNotEmpty(fileId)) return _persistIOS(prefs, fileId!);
|
||
// 4. 全新生成
|
||
return _persistIOS(prefs, await _generateDeviceId());
|
||
}
|
||
|
||
/// Android:SP → 文件 → 新生成,命中即回写两层(SP+文件),无 Keychain 等价机制
|
||
static Future<String> _resolveAndroid(SharedPreferences prefs) async {
|
||
// 1. SP 命中
|
||
final spId = prefs.getString(StoreKeys.DEVICE_ID);
|
||
if (TextUtil.isNotEmpty(spId)) return _persistAndroid(prefs, spId!);
|
||
// 2. 文件命中
|
||
final fileId = await _readIdFile();
|
||
if (TextUtil.isNotEmpty(fileId)) return _persistAndroid(prefs, fileId!);
|
||
// 3. 全新生成
|
||
return _persistAndroid(prefs, await _generateDeviceId());
|
||
}
|
||
|
||
/// iOS 回写三层,保证 Keychain/SP/文件一致
|
||
static Future<String> _persistIOS(SharedPreferences prefs, String id) async {
|
||
await prefs.setString(StoreKeys.DEVICE_ID, id);
|
||
await _writeIdFile(id);
|
||
await _trySaveKeychain(id);
|
||
return id;
|
||
}
|
||
|
||
/// Android 回写 SP + 文件
|
||
static Future<String> _persistAndroid(
|
||
SharedPreferences prefs, String id) async {
|
||
await prefs.setString(StoreKeys.DEVICE_ID, id);
|
||
await _writeIdFile(id);
|
||
return id;
|
||
}
|
||
|
||
/// 写 Keychain 失败不抛错(权限/entitlements 配错时降级走 SP+文件,不卡启动)
|
||
static Future<void> _trySaveKeychain(String id) async {
|
||
try {
|
||
await _secureStorage.write(key: _kKeychainDevIdKey, value: id);
|
||
} catch (_) {}
|
||
}
|
||
|
||
/// 生成新设备 ID
|
||
/// Android:OAID → ANDROID_ID → UUID 兜底
|
||
/// iOS:identifierForVendor(IDFV)→ UUID 兜底
|
||
static Future<String> _generateDeviceId() async {
|
||
String raw = '';
|
||
if (Platform.isAndroid) {
|
||
try {
|
||
await DeviceIdentity.register();
|
||
raw = await DeviceIdentity.oaid;
|
||
if (_isInvalidId(raw)) {
|
||
raw = await DeviceIdentity.androidId;
|
||
}
|
||
} catch (_) {}
|
||
} else if (Platform.isIOS) {
|
||
try {
|
||
final ios = await _infoPlugin.iosInfo;
|
||
raw = ios.identifierForVendor ?? '';
|
||
} catch (_) {}
|
||
}
|
||
if (_isInvalidId(raw)) raw = const Uuid().v4();
|
||
// 业务规则:hgdj_ 前缀,去空格,限制 50 字符(日志系统约束)
|
||
return 'hgdj_${raw.replaceAll(' ', '')}'.limit(50);
|
||
}
|
||
|
||
/// 脏值检测:空 / 太短 / 全 0 / 已知脏值
|
||
static bool _isInvalidId(String? id) {
|
||
if (TextUtil.isEmpty(id)) return true;
|
||
final v = id!.toLowerCase();
|
||
if (v.length < 8) return true;
|
||
if (v.hasManyZeros()) return true;
|
||
return _dirtyAndroidIds.contains(v);
|
||
}
|
||
|
||
/// 写入应用支持目录文件(零权限,卸载会丢,但与 SP 互为冗余)
|
||
static Future<void> _writeIdFile(String id) async {
|
||
try {
|
||
final dir = await getApplicationSupportDirectory();
|
||
final file = File('${dir.path}/.device_id');
|
||
await file.writeAsString(id, flush: true);
|
||
} catch (_) {}
|
||
}
|
||
|
||
/// 读取应用支持目录文件
|
||
static Future<String?> _readIdFile() async {
|
||
try {
|
||
final dir = await getApplicationSupportDirectory();
|
||
final file = File('${dir.path}/.device_id');
|
||
if (!await file.exists()) return null;
|
||
final v = (await file.readAsString()).trim();
|
||
return v.isEmpty ? null : v;
|
||
} catch (_) {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/// 获取加密后的设备id验签
|
||
static String getDevToken(String devId) {
|
||
String a = "g24p5VJ4fJ";
|
||
String b = "5P#at%Yu";
|
||
String c = "ZPRwQuKl8YlVIr";
|
||
String s = a + b + c + devId + a + b + c;
|
||
var data = utf8.encode(s);
|
||
var chiper = sha256.convert(data);
|
||
return base64Encode(chiper.bytes);
|
||
}
|
||
|
||
///获取渠道(非 Android 或取不到都返回空串)
|
||
static Future<String> getChannel() async {
|
||
if (!Platform.isAndroid) return "";
|
||
try {
|
||
return await const MethodChannel(naticeChannel)
|
||
.invokeMethod("getChannel") ??
|
||
"";
|
||
} on PlatformException {
|
||
return "";
|
||
}
|
||
}
|
||
}
|