Initial commit: 91dj android client dev_1.0.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,346 @@
|
||||
import 'dart:convert';
|
||||
import 'package:archive/archive.dart';
|
||||
import 'dart:math';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:analytics_sdk/config/sdk_config.dart';
|
||||
import 'package:pointycastle/export.dart';
|
||||
|
||||
/// AES-GCM 加密工具类
|
||||
/// 提供 AES-256-GCM 加密/解密、GZIP 压缩/解压功能
|
||||
class AesGcmUtil {
|
||||
// AES-256 密钥长度
|
||||
static const int keySize = 32;
|
||||
|
||||
// GCM IV 长度(12 字节)
|
||||
static const int gcmIvLength = 12;
|
||||
|
||||
// GCM Tag 长度(128 位 = 16 字节)
|
||||
static const int gcmTagLength = 16;
|
||||
|
||||
// 随机数生成器
|
||||
static final Random _random = Random.secure();
|
||||
|
||||
/// 生成唯一的密钥(Base64 字符串,无填充)
|
||||
/// 返回 32 字节的 AES-256 密钥的 Base64 编码
|
||||
static String generateKey() {
|
||||
final keyBytes = Uint8List(keySize);
|
||||
for (int i = 0; i < keySize; i++) {
|
||||
keyBytes[i] = _random.nextInt(256);
|
||||
}
|
||||
return base64Encode(keyBytes)
|
||||
.replaceAll('=', '')
|
||||
.replaceAll('+', '-')
|
||||
.replaceAll('/', '_');
|
||||
}
|
||||
|
||||
/// 加密:Object → String(Base64)
|
||||
/// [keyBase64] Base64 编码的密钥
|
||||
/// [data] 要加密的数据(任意对象,会被转换为 JSON)
|
||||
/// 返回 Base64 编码的加密数据(IV + 密文)
|
||||
static String encrypt(String keyBase64, dynamic data) {
|
||||
try {
|
||||
// 解码密钥
|
||||
final key = _decodeKey(keyBase64);
|
||||
|
||||
// 将数据转换为 JSON 字符串
|
||||
final json = jsonEncode(data);
|
||||
final input = Uint8List.fromList(utf8.encode(json));
|
||||
|
||||
// 生成随机 IV(12 字节)
|
||||
final iv = Uint8List(gcmIvLength);
|
||||
for (int i = 0; i < gcmIvLength; i++) {
|
||||
iv[i] = _random.nextInt(256);
|
||||
}
|
||||
|
||||
// 执行 AES-GCM 加密
|
||||
final cipher = GCMBlockCipher(AESEngine());
|
||||
final params = AEADParameters(
|
||||
KeyParameter(key),
|
||||
gcmTagLength * 8, // Tag 长度(位)
|
||||
iv,
|
||||
Uint8List(0), // 无附加认证数据(AAD)
|
||||
);
|
||||
cipher.init(true, params);
|
||||
|
||||
final cipherText = cipher.process(input);
|
||||
|
||||
// 组合 IV + 密文
|
||||
final output = Uint8List(iv.length + cipherText.length);
|
||||
output.setRange(0, iv.length, iv);
|
||||
output.setRange(iv.length, output.length, cipherText);
|
||||
|
||||
// Base64 编码(无填充)
|
||||
return base64Encode(output)
|
||||
.replaceAll('=', '')
|
||||
.replaceAll('+', '-')
|
||||
.replaceAll('/', '_');
|
||||
} catch (e) {
|
||||
throw Exception('Encryption failed: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// 加密(带 GZIP 压缩):先压缩再加密
|
||||
/// [keyBase64] Base64 编码的密钥
|
||||
/// [data] 要加密的数据(任意对象,会被转换为 JSON)
|
||||
/// 返回 Base64 编码的加密数据
|
||||
static String encryptGzip(String keyBase64, dynamic data) {
|
||||
try {
|
||||
// 解码密钥
|
||||
final key = _decodeKey(keyBase64);
|
||||
|
||||
// 将数据转换为 JSON 字符串
|
||||
final json = jsonEncode(data);
|
||||
|
||||
// ① 先 GZIP 压缩并转 Base64
|
||||
final gzipBase64 = gzipToBase64(json);
|
||||
|
||||
// ② AES-GCM 加密这个 Base64 字符串
|
||||
final input = Uint8List.fromList(utf8.encode(gzipBase64));
|
||||
|
||||
// 生成随机 IV(12 字节)
|
||||
final iv = Uint8List(gcmIvLength);
|
||||
for (int i = 0; i < gcmIvLength; i++) {
|
||||
iv[i] = _random.nextInt(256);
|
||||
}
|
||||
|
||||
// 执行 AES-GCM 加密
|
||||
final cipher = GCMBlockCipher(AESEngine());
|
||||
final params = AEADParameters(
|
||||
KeyParameter(key),
|
||||
gcmTagLength * 8, // Tag 长度(位)
|
||||
iv,
|
||||
Uint8List(0), // 无附加认证数据(AAD)
|
||||
);
|
||||
cipher.init(true, params);
|
||||
|
||||
final cipherText = cipher.process(input);
|
||||
|
||||
// 组合 IV + 密文
|
||||
final output = Uint8List(iv.length + cipherText.length);
|
||||
output.setRange(0, iv.length, iv);
|
||||
output.setRange(iv.length, output.length, cipherText);
|
||||
|
||||
// Base64 编码(无填充)
|
||||
return base64Encode(output)
|
||||
.replaceAll('=', '')
|
||||
.replaceAll('+', '-')
|
||||
.replaceAll('/', '_');
|
||||
} catch (e) {
|
||||
throw Exception('Encryption failed: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// 解密:返回明文 JSON 字符串
|
||||
/// [keyBase64] Base64 编码的密钥
|
||||
/// [encryptedBase64] Base64 编码的加密数据(IV + 密文)
|
||||
/// 返回解密后的 JSON 字符串
|
||||
static String decryptToString(String keyBase64, String encryptedBase64) {
|
||||
try {
|
||||
// 解码 Base64(处理 URL 安全格式)
|
||||
String normalizedBase64 =
|
||||
encryptedBase64.replaceAll('-', '+').replaceAll('_', '/');
|
||||
while (normalizedBase64.length % 4 != 0) {
|
||||
normalizedBase64 += '=';
|
||||
}
|
||||
final data = base64Decode(normalizedBase64);
|
||||
return decryptFromCipherBytes(keyBase64, Uint8List.fromList(data));
|
||||
} catch (e) {
|
||||
throw Exception('Decryption failed: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// 解密:从原始字节(IV + 密文)解密为明文字符串
|
||||
/// [keyBase64] Base64 编码的密钥
|
||||
/// [cipherBytes] 密文字节:前 12 字节为 IV,后续为 AES-GCM 密文
|
||||
/// 返回解密后的 UTF-8 字符串
|
||||
///
|
||||
/// 若抛出 [InvalidCipherTextException](或 "Decryption failed: InvalidCipherTextException"),
|
||||
/// 常见原因:① 密钥与加密端不一致 ② 服务端密文格式不是「前 12 字节 IV + 密文」
|
||||
/// (如 IV 单独返回或顺序不同,需先自行截取再传入)
|
||||
static String decryptFromCipherBytes(
|
||||
String keyBase64, Uint8List cipherBytes) {
|
||||
try {
|
||||
final key = _decodeKey(keyBase64);
|
||||
if (cipherBytes.length < gcmIvLength) {
|
||||
throw ArgumentError(
|
||||
'Invalid ciphertext: too short (need at least $gcmIvLength bytes for IV)');
|
||||
}
|
||||
final iv = cipherBytes.sublist(0, gcmIvLength);
|
||||
final cipherText = cipherBytes.sublist(gcmIvLength);
|
||||
final cipher = GCMBlockCipher(AESEngine());
|
||||
final params = AEADParameters(
|
||||
KeyParameter(key),
|
||||
gcmTagLength * 8,
|
||||
iv,
|
||||
Uint8List(0),
|
||||
);
|
||||
cipher.init(false, params);
|
||||
final plainText = cipher.process(cipherText);
|
||||
return utf8.decode(plainText);
|
||||
} on InvalidCipherTextException catch (e) {
|
||||
throw Exception(
|
||||
'Decryption failed: GCM 校验失败(密钥错误、IV/密文格式不符或数据被篡改)。'
|
||||
' 请确认:① 密钥与加密端一致 ② 密文为「前 12 字节 IV + 密文」。原始: $e',
|
||||
);
|
||||
} catch (e) {
|
||||
throw Exception('Decryption failed: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// 解密:从 "[169, 246, 173, ...]" 格式的字节列表字符串解密
|
||||
/// 适用于接口返回的密文是「数字列表字符串」而非 Base64 的情况
|
||||
/// [keyBase64] Base64 编码的密钥,传 null 则使用 SdkConfig.decryptKey
|
||||
/// [byteListString] 形如 "[169, 246, 173, 197, ...]" 的字符串(前 12 字节为 IV,后续为密文)
|
||||
/// 返回解密后的 UTF-8 字符串
|
||||
static String decryptFromByteListString(String byteListString,
|
||||
{String? keyBase64}) {
|
||||
final key = keyBase64 ?? _defaultKey;
|
||||
final cipherBytes = _parseByteListString(byteListString);
|
||||
return decryptFromCipherBytes(key, cipherBytes);
|
||||
}
|
||||
|
||||
/// 将 "[169, 246, ...]" 格式的字符串解析为 Uint8List
|
||||
static Uint8List _parseByteListString(String byteListString) {
|
||||
final list = byteListString
|
||||
.replaceAll(RegExp(r'[\[\]\s]+'), ' ')
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.where((s) => s.isNotEmpty)
|
||||
.map((s) => int.parse(s))
|
||||
.toList();
|
||||
for (final b in list) {
|
||||
if (b < 0 || b > 255) throw ArgumentError('Byte value out of range: $b');
|
||||
}
|
||||
return Uint8List.fromList(list);
|
||||
}
|
||||
|
||||
/// 解码 Base64 密钥
|
||||
static Uint8List _decodeKey(String keyBase64) {
|
||||
// 处理 URL 安全格式
|
||||
String normalizedBase64 =
|
||||
keyBase64.replaceAll('-', '+').replaceAll('_', '/');
|
||||
// 添加填充
|
||||
while (normalizedBase64.length % 4 != 0) {
|
||||
normalizedBase64 += '=';
|
||||
}
|
||||
|
||||
final keyBytes = base64Decode(normalizedBase64);
|
||||
if (keyBytes.length != keySize) {
|
||||
throw ArgumentError('Key must be 32 bytes for AES-256');
|
||||
}
|
||||
return keyBytes;
|
||||
}
|
||||
|
||||
/// GZIP 压缩字节数组
|
||||
static Uint8List gzip(Uint8List data) {
|
||||
return gzipEncode(data);
|
||||
}
|
||||
|
||||
/// GZIP 压缩字符串并转 Base64
|
||||
static String gzipToBase64(String str) {
|
||||
if (str.isEmpty) return str;
|
||||
try {
|
||||
final compressed = gzipEncode(utf8.encode(str));
|
||||
return base64Encode(compressed);
|
||||
} catch (e) {
|
||||
throw Exception('Gzip 压缩失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// GZIP 解压字节数组
|
||||
static Uint8List gunzip(Uint8List data) {
|
||||
return gzipDecode(data);
|
||||
}
|
||||
|
||||
/// 从 Base64 解压 GZIP
|
||||
static String gunzipFromBase64(String base64) {
|
||||
if (base64.isEmpty) return base64;
|
||||
try {
|
||||
final compressed = base64Decode(base64);
|
||||
final decompressed = gzipDecode(compressed);
|
||||
return utf8.decode(decompressed);
|
||||
} catch (e) {
|
||||
throw Exception('Gzip 解压失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// GZIP 编码(使用 package:archive,兼容 Web)
|
||||
static Uint8List gzipEncode(List<int> data) {
|
||||
final encoder = GZipEncoder();
|
||||
final compressed = encoder.encode(data) ?? <int>[];
|
||||
return Uint8List.fromList(compressed);
|
||||
}
|
||||
|
||||
/// GZIP 解码(使用 package:archive,兼容 Web)
|
||||
static Uint8List gzipDecode(List<int> data) {
|
||||
final decoder = GZipDecoder();
|
||||
final decompressed = decoder.decodeBytes(data);
|
||||
return Uint8List.fromList(decompressed);
|
||||
}
|
||||
|
||||
/// 解密接口响应(使用 SDK 默认密钥)
|
||||
///
|
||||
/// 接口直接返回加密字符串,整段解密后返回明文。
|
||||
///
|
||||
/// [responseBody] 接口返回的加密字符串
|
||||
/// [useGzip] 是否对解密结果再做 GZIP 解压(默认:false)
|
||||
static String decryptResponseAuto(String responseBody,
|
||||
{bool useGzip = false}) {
|
||||
return decryptResponse(_defaultKey, responseBody, useGzip: useGzip);
|
||||
}
|
||||
|
||||
/// 默认密钥(从 SdkConfig 获取)
|
||||
static String get _defaultKey => SdkConfig.decryptKey;
|
||||
|
||||
/// 解密接口响应
|
||||
///
|
||||
/// 接口直接返回加密字符串,整段解密后返回明文。
|
||||
///
|
||||
/// [keyBase64] Base64 编码的密钥
|
||||
/// [responseBody] 接口返回的加密字符串
|
||||
/// [useGzip] 是否对解密结果再做 GZIP 解压(默认:false)
|
||||
static String decryptResponse(String keyBase64, String responseBody,
|
||||
{bool useGzip = false}) {
|
||||
try {
|
||||
final decrypted = decryptToString(keyBase64, responseBody.trim());
|
||||
if (useGzip) return gunzipFromBase64(decrypted);
|
||||
return decrypted;
|
||||
} catch (e) {
|
||||
if (e is Exception) rethrow;
|
||||
throw Exception('解密响应失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// 解密接口响应并解析为 Map
|
||||
/// 接口直接返回加密字符串,解密后再按 JSON 解析为 Map。
|
||||
/// [keyBase64] Base64 编码的密钥
|
||||
/// [responseBody] 接口返回的加密字符串
|
||||
/// [useGzip] 是否对解密结果再做 GZIP 解压(默认:false)
|
||||
static Map<String, dynamic> decryptResponseToMap(
|
||||
String keyBase64,
|
||||
String responseBody, {
|
||||
bool useGzip = false,
|
||||
}) {
|
||||
final decryptedJson =
|
||||
decryptResponse(keyBase64, responseBody, useGzip: useGzip);
|
||||
return jsonDecode(decryptedJson) as Map<String, dynamic>;
|
||||
}
|
||||
|
||||
/// 解密接口响应并解析为指定类型
|
||||
/// 接口直接返回加密字符串,解密后再按 JSON 解析并转为 T。
|
||||
/// [keyBase64] Base64 编码的密钥
|
||||
/// [responseBody] 接口返回的加密字符串
|
||||
/// [fromJson] JSON 解析函数
|
||||
/// [useGzip] 是否对解密结果再做 GZIP 解压(默认:false)
|
||||
static T decryptResponseToObject<T>(
|
||||
String keyBase64,
|
||||
String responseBody,
|
||||
T Function(Map<String, dynamic>) fromJson, {
|
||||
bool useGzip = false,
|
||||
}) {
|
||||
final decryptedMap =
|
||||
decryptResponseToMap(keyBase64, responseBody, useGzip: useGzip);
|
||||
return fromJson(decryptedMap);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:crypto/crypto.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:analytics_sdk/utils/uuid_util.dart';
|
||||
|
||||
const _kFingerprintFileName = 'analytics_fingerprint.txt';
|
||||
|
||||
/// 原生平台设备指纹生成
|
||||
///
|
||||
/// **生成规则**
|
||||
/// 1. 将非空因子用 `|` 拼接后做 SHA-256,取 64 位十六进制作为指纹。
|
||||
/// 因子:deviceId、device、deviceBrand、deviceModel、systemName、systemVersion。
|
||||
/// 2. 若所有因子均为空(降级场景),则生成 UUID v4 并持久化至
|
||||
/// `applicationDocumentsDirectory/analytics_fingerprint.txt`,
|
||||
/// 后续从文件读取保证稳定。
|
||||
///
|
||||
/// 任何异常均返回空字符串,不影响正常上报。
|
||||
Future<String> generateOrLoadFingerprint({
|
||||
String deviceId = '',
|
||||
String device = '',
|
||||
String deviceBrand = '',
|
||||
String deviceModel = '',
|
||||
String systemName = '',
|
||||
String systemVersion = '',
|
||||
String userAgent = '', // 原生端不使用,仅保持接口与 Web 端一致
|
||||
}) async {
|
||||
try {
|
||||
final factors = [deviceId, device, deviceBrand, deviceModel, systemName, systemVersion]
|
||||
.where((f) => f.isNotEmpty)
|
||||
.toList();
|
||||
|
||||
if (factors.isNotEmpty) {
|
||||
final digest = sha256.convert(utf8.encode(factors.join('|')));
|
||||
return digest.toString();
|
||||
}
|
||||
|
||||
// 所有因子为空时降级为持久化 UUID v4
|
||||
final dir = await getApplicationDocumentsDirectory();
|
||||
final file = File('${dir.path}/$_kFingerprintFileName');
|
||||
if (await file.exists()) {
|
||||
final existing = (await file.readAsString()).trim();
|
||||
if (existing.isNotEmpty) return existing;
|
||||
}
|
||||
final fp = generateUuidV4();
|
||||
await file.writeAsString(fp);
|
||||
return fp;
|
||||
} catch (_) {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
import 'device_fingerprint_native.dart'
|
||||
// ignore: uri_does_not_exist
|
||||
if (dart.library.html) 'device_fingerprint_web.dart';
|
||||
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:crypto/crypto.dart';
|
||||
|
||||
/// 设备指纹工具类
|
||||
///
|
||||
/// SDK 内部自动调用,业务方无需手动传入。
|
||||
///
|
||||
/// **生成规则**
|
||||
/// 1. 现行外层 `device_fingerprint` 使用 v3 规则:
|
||||
/// 基于 `sdk_init` payload 内的平台稳定字段,按固定顺序规范化后做
|
||||
/// SHA-256,输出 64 位小写十六进制。
|
||||
/// 2. 网络、电量、页面、行为等易变字段不参与 v3 指纹。
|
||||
/// 3. [getFingerprint] 保留为旧调用方兼容入口;SDK 自动上报链路使用
|
||||
/// [generateV3FromSdkInitPayload]。
|
||||
///
|
||||
/// **平台差异**
|
||||
/// - Native(Android/iOS/桌面):降级 UUID 存于 `applicationDocumentsDirectory/analytics_fingerprint.txt`
|
||||
/// - Web:降级 UUID 存于 `localStorage['analytics_fp']`
|
||||
///
|
||||
/// **失败兜底**:任何异常均返回空字符串,事件正常上报,仅 `device_fingerprint` 字段为空。
|
||||
///
|
||||
/// **注意**:`device_fingerprint` 不参与 `event_id` 的计算。
|
||||
class DeviceFingerprintUtil {
|
||||
/// 指纹生成规则版本号。
|
||||
///
|
||||
/// 每次生成规则(因子组合、哈希算法)发生变更时,此版本号必须同步递增,
|
||||
/// 以便服务端区分不同规则下生成的指纹,支持跨版本数据对比分析。
|
||||
///
|
||||
/// 版本历史:
|
||||
/// - `3.0.0`:按 sdk_init 启动快照中的平台稳定字段生成。
|
||||
static const String kVersion = '3.0.0';
|
||||
|
||||
static String? _cache;
|
||||
|
||||
/// 获取设备指纹。
|
||||
///
|
||||
/// 首次调用时根据传入的设备因子计算并内存缓存,后续直接返回缓存值。
|
||||
/// 若设备因子发生变化需重新计算,请先调用 [clearCache]。
|
||||
static Future<String> getFingerprint({
|
||||
String deviceId = '',
|
||||
String device = '',
|
||||
String deviceBrand = '',
|
||||
String deviceModel = '',
|
||||
String systemName = '',
|
||||
String systemVersion = '',
|
||||
String userAgent = '',
|
||||
}) async {
|
||||
if (_cache != null) return _cache!;
|
||||
_cache = await generateOrLoadFingerprint(
|
||||
deviceId: deviceId,
|
||||
device: device,
|
||||
deviceBrand: deviceBrand,
|
||||
deviceModel: deviceModel,
|
||||
systemName: systemName,
|
||||
systemVersion: systemVersion,
|
||||
userAgent: userAgent,
|
||||
);
|
||||
return _cache!;
|
||||
}
|
||||
|
||||
/// 清除内存缓存(通常仅测试时使用,不会删除本地存储文件)
|
||||
static void clearCache() {
|
||||
_cache = null;
|
||||
}
|
||||
|
||||
/// 按产品文档 v3 规则,基于 sdk_init payload 生成外层设备指纹。
|
||||
///
|
||||
/// 该方法只使用启动快照内相对稳定的字段;网络、电量、页面、行为等
|
||||
/// 易变字段不参与。输入缺失时保留字段名并使用文档默认值,确保同一
|
||||
/// 平台的参与字段顺序稳定。
|
||||
static String generateV3FromSdkInitPayload(Map<String, dynamic> payload) {
|
||||
try {
|
||||
final platform = _fingerprintPlatform(payload);
|
||||
final values = platform == 'web'
|
||||
? _webFingerprintValues(payload)
|
||||
: platform == 'ios'
|
||||
? _iosFingerprintValues(payload)
|
||||
: _androidFingerprintValues(payload);
|
||||
final canonical =
|
||||
'fp_v3|fingerprint_platform=$platform|${values.join('|')}';
|
||||
return sha256.convert(utf8.encode(canonical)).toString();
|
||||
} catch (_) {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
static String _fingerprintPlatform(Map<String, dynamic> payload) {
|
||||
if (_string(payload['platform']) == 'web') return 'web';
|
||||
final osFamily = _string(payload['os_family']);
|
||||
if (osFamily == 'ios') return 'ios';
|
||||
return 'android';
|
||||
}
|
||||
|
||||
static List<String> _androidFingerprintValues(Map<String, dynamic> payload) {
|
||||
final id = _selectFirstId(payload, const [
|
||||
'oaid',
|
||||
'aaid',
|
||||
'android_id',
|
||||
'guid',
|
||||
]);
|
||||
return [
|
||||
_kvString(payload, 'os_family'),
|
||||
_kvString(payload, 'brand'),
|
||||
_kvString(payload, 'manufacturer'),
|
||||
_kvString(payload, 'model'),
|
||||
_kvString(payload, 'abi'),
|
||||
_kvInt(payload, 'screen_width'),
|
||||
_kvInt(payload, 'screen_height'),
|
||||
_kvInt(payload, 'screen_dpi'),
|
||||
_kvInt(payload, 'device_pixel_ratio'),
|
||||
_kvInt(payload, 'cpu_cores'),
|
||||
_kvInt(payload, 'device_memory_gb'),
|
||||
_kvString(payload, 'build_hardware'),
|
||||
_kvString(payload, 'build_product'),
|
||||
_kvString(payload, 'build_fingerprint'),
|
||||
_kvInt(payload, 'has_baseband'),
|
||||
_kvString(payload, 'language'),
|
||||
_kvString(payload, 'timezone'),
|
||||
'id_source=${id.source}',
|
||||
'id_value=${id.value}',
|
||||
];
|
||||
}
|
||||
|
||||
static List<String> _iosFingerprintValues(Map<String, dynamic> payload) {
|
||||
final idfv = _string(payload['idfv']);
|
||||
return [
|
||||
_kvString(payload, 'os_family'),
|
||||
_kvString(payload, 'ios_device_model_raw'),
|
||||
_kvInt(payload, 'screen_width'),
|
||||
_kvInt(payload, 'screen_height'),
|
||||
_kvInt(payload, 'screen_dpi'),
|
||||
_kvInt(payload, 'device_pixel_ratio'),
|
||||
_kvInt(payload, 'cpu_cores'),
|
||||
_kvInt(payload, 'device_memory_gb'),
|
||||
_kvString(payload, 'language'),
|
||||
_kvString(payload, 'timezone'),
|
||||
'id_source=${idfv.isEmpty ? 'none' : 'idfv'}',
|
||||
'id_value=$idfv',
|
||||
];
|
||||
}
|
||||
|
||||
static List<String> _webFingerprintValues(Map<String, dynamic> payload) {
|
||||
return [
|
||||
_kvString(payload, 'os_family'),
|
||||
_kvString(payload, 'browser_kernel'),
|
||||
_kvString(payload, 'browser_platform'),
|
||||
_kvInt(payload, 'screen_width'),
|
||||
_kvInt(payload, 'screen_height'),
|
||||
_kvInt(payload, 'screen_dpi'),
|
||||
_kvInt(payload, 'device_pixel_ratio'),
|
||||
_kvInt(payload, 'color_depth'),
|
||||
_kvInt(payload, 'max_touch_points'),
|
||||
_kvInt(payload, 'hardware_concurrency'),
|
||||
_kvString(payload, 'language'),
|
||||
_kvString(payload, 'timezone'),
|
||||
_kvInt(payload, 'cookie_enabled'),
|
||||
_kvInt(payload, 'local_storage_enabled'),
|
||||
_kvInt(payload, 'session_storage_enabled'),
|
||||
_kvInt(payload, 'indexeddb_enabled'),
|
||||
_kvInt(payload, 'service_worker_enabled'),
|
||||
_kvString(payload, 'canvas_fp'),
|
||||
_kvString(payload, 'webgl_fp'),
|
||||
_kvString(payload, 'audio_fp'),
|
||||
_kvString(payload, 'fonts_hash'),
|
||||
_kvString(payload, 'plugins_hash'),
|
||||
_kvString(payload, 'mimes_hash'),
|
||||
];
|
||||
}
|
||||
|
||||
static _SelectedId _selectFirstId(
|
||||
Map<String, dynamic> payload,
|
||||
List<String> keys,
|
||||
) {
|
||||
for (final key in keys) {
|
||||
final value = _string(payload[key]);
|
||||
if (value.isNotEmpty) return _SelectedId(key, value);
|
||||
}
|
||||
return const _SelectedId('none', '');
|
||||
}
|
||||
|
||||
static String _kvString(Map<String, dynamic> payload, String key) =>
|
||||
'$key=${_string(payload[key])}';
|
||||
|
||||
static String _kvInt(Map<String, dynamic> payload, String key) =>
|
||||
'$key=${_int(payload[key])}';
|
||||
|
||||
static String _string(dynamic value) {
|
||||
if (value == null) return '';
|
||||
return value.toString().trim().toLowerCase();
|
||||
}
|
||||
|
||||
static int _int(dynamic value) {
|
||||
if (value is int) return value;
|
||||
if (value is double && value.isFinite) return value.round();
|
||||
if (value is String) {
|
||||
final text = value.trim();
|
||||
final asInt = int.tryParse(text);
|
||||
if (asInt != null) return asInt;
|
||||
final asDouble = double.tryParse(text);
|
||||
if (asDouble != null && asDouble.isFinite) return asDouble.round();
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
class _SelectedId {
|
||||
final String source;
|
||||
final String value;
|
||||
|
||||
const _SelectedId(this.source, this.value);
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
// ignore: avoid_web_libraries_in_flutter
|
||||
import 'dart:convert';
|
||||
// ignore: avoid_web_libraries_in_flutter
|
||||
import 'dart:html' as html;
|
||||
|
||||
import 'package:crypto/crypto.dart';
|
||||
import 'package:analytics_sdk/utils/uuid_util.dart';
|
||||
|
||||
const _kFingerprintKey = 'analytics_fp';
|
||||
|
||||
/// Web 平台设备指纹生成
|
||||
///
|
||||
/// **生成规则**
|
||||
/// 1. 收集宿主 App 传入字段(device、deviceBrand、deviceModel、systemName、
|
||||
/// systemVersion、userAgent)及浏览器环境信息(屏幕分辨率、语言、时区),
|
||||
/// 过滤空值后用 `|` 拼接,做 SHA-256 生成指纹。
|
||||
/// 2. 若所有因子均为空,降级生成 UUID v4 并写入 localStorage,后续复用。
|
||||
///
|
||||
/// 任何异常均返回空字符串,不影响正常上报。
|
||||
Future<String> generateOrLoadFingerprint({
|
||||
String deviceId = '',
|
||||
String device = '',
|
||||
String deviceBrand = '',
|
||||
String deviceModel = '',
|
||||
String systemName = '',
|
||||
String systemVersion = '',
|
||||
String userAgent = '',
|
||||
}) async {
|
||||
try {
|
||||
final envFactors = _collectBrowserEnv();
|
||||
final factors = [
|
||||
device, deviceBrand, deviceModel,
|
||||
systemName, systemVersion,
|
||||
userAgent,
|
||||
...envFactors,
|
||||
].where((f) => f.isNotEmpty).toList();
|
||||
|
||||
if (factors.isNotEmpty) {
|
||||
final digest = sha256.convert(utf8.encode(factors.join('|')));
|
||||
return digest.toString();
|
||||
}
|
||||
|
||||
// 所有因子为空时降级为持久化 UUID v4
|
||||
final storage = html.window.localStorage;
|
||||
final existing = storage[_kFingerprintKey] ?? '';
|
||||
if (existing.isNotEmpty) return existing;
|
||||
|
||||
final fp = generateUuidV4();
|
||||
storage[_kFingerprintKey] = fp;
|
||||
return fp;
|
||||
} catch (_) {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/// 收集浏览器环境因子:屏幕分辨率、语言、时区
|
||||
List<String> _collectBrowserEnv() {
|
||||
final factors = <String>[];
|
||||
try {
|
||||
final screen = html.window.screen;
|
||||
if (screen != null) {
|
||||
final w = screen.width;
|
||||
final h = screen.height;
|
||||
if (w != null && h != null && w > 0 && h > 0) {
|
||||
factors.add('${w}x$h');
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
try {
|
||||
final lang = html.window.navigator.language;
|
||||
if (lang.isNotEmpty) factors.add(lang);
|
||||
} catch (_) {}
|
||||
try {
|
||||
final tz = DateTime.now().timeZoneName;
|
||||
if (tz.isNotEmpty) factors.add(tz);
|
||||
} catch (_) {}
|
||||
return factors;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import 'package:analytics_sdk/utils/platform_storage.dart';
|
||||
import 'package:analytics_sdk/utils/uuid_util.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
const _kDeviceIdStorageKey = 'analytics_device_id.txt';
|
||||
const _kNativeChannel = MethodChannel('analytics_sdk');
|
||||
|
||||
Future<String> generateOrLoadDeviceId() async {
|
||||
try {
|
||||
final nativeDeviceId =
|
||||
(await _kNativeChannel.invokeMethod<String>('getDeviceId'))?.trim() ??
|
||||
'';
|
||||
if (nativeDeviceId.isNotEmpty) return nativeDeviceId;
|
||||
} on MissingPluginException {
|
||||
// Desktop platforms may not implement this method; fall back to storage.
|
||||
} catch (_) {}
|
||||
|
||||
try {
|
||||
final existing =
|
||||
(await PlatformStorage.read(_kDeviceIdStorageKey))?.trim() ?? '';
|
||||
if (existing.isNotEmpty) return existing;
|
||||
|
||||
final generated = generateUuidV4();
|
||||
await PlatformStorage.write(_kDeviceIdStorageKey, generated);
|
||||
return generated;
|
||||
} catch (_) {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import 'device_id_native.dart'
|
||||
// ignore: uri_does_not_exist
|
||||
if (dart.library.html) 'device_id_web.dart';
|
||||
|
||||
/// SDK 内部 device_id 生成工具。
|
||||
///
|
||||
/// device_id 不再信任业务方传入值:
|
||||
/// - Android/iOS 优先读取原生平台稳定 ID;
|
||||
/// - 其它端降级生成 UUID v4 并持久化;
|
||||
/// - 任何异常都返回空字符串,由事件校验链路决定是否丢弃。
|
||||
class DeviceIdUtil {
|
||||
static Future<String> getDeviceId() => generateOrLoadDeviceId();
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import 'package:analytics_sdk/utils/platform_storage.dart';
|
||||
import 'package:analytics_sdk/utils/uuid_util.dart';
|
||||
|
||||
const _kDeviceIdStorageKey = 'analytics_device_id';
|
||||
|
||||
Future<String> generateOrLoadDeviceId() async {
|
||||
try {
|
||||
final existing =
|
||||
(await PlatformStorage.read(_kDeviceIdStorageKey))?.trim() ?? '';
|
||||
if (existing.isNotEmpty) return existing;
|
||||
|
||||
final generated = generateUuidV4();
|
||||
await PlatformStorage.write(_kDeviceIdStorageKey, generated);
|
||||
return generated;
|
||||
} catch (_) {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:device_info_plus/device_info_plus.dart';
|
||||
|
||||
String? _cachedBrand;
|
||||
String? _cachedModel;
|
||||
String? _cachedVersion;
|
||||
|
||||
Future<void> initializeDeviceInfo() async {
|
||||
try {
|
||||
final plugin = DeviceInfoPlugin();
|
||||
if (Platform.isAndroid) {
|
||||
final info = await plugin.androidInfo;
|
||||
_cachedBrand = info.brand;
|
||||
_cachedModel = info.model;
|
||||
_cachedVersion = info.version.release;
|
||||
} else if (Platform.isIOS) {
|
||||
final info = await plugin.iosInfo;
|
||||
_cachedBrand = 'Apple';
|
||||
_cachedModel = info.model;
|
||||
} else if (Platform.isMacOS) {
|
||||
final info = await plugin.macOsInfo;
|
||||
_cachedBrand = 'MAC';
|
||||
_cachedModel = info.model;
|
||||
} else if (Platform.isWindows) {
|
||||
final info = await plugin.windowsInfo;
|
||||
_cachedBrand = '';
|
||||
_cachedModel = info.productName;
|
||||
}
|
||||
// Linux:无通用品牌/型号,留空
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
String getDeviceType() {
|
||||
if (Platform.isAndroid) return 'Android';
|
||||
if (Platform.isIOS) return 'iOS';
|
||||
return 'PC';
|
||||
}
|
||||
|
||||
String getSystemName() {
|
||||
if (Platform.isAndroid) return 'Android';
|
||||
if (Platform.isIOS) return 'iOS';
|
||||
if (Platform.isMacOS) return 'macOS';
|
||||
if (Platform.isWindows) return 'Windows';
|
||||
if (Platform.isLinux) return 'Linux';
|
||||
return '';
|
||||
}
|
||||
|
||||
String getSystemVersion() {
|
||||
if (_cachedVersion != null) return _cachedVersion!;
|
||||
// 非 Android 平台:从 "macOS 14.5.0" / "Windows 11 ..." 等字符串中提取版本号
|
||||
final match = RegExp(r'[\d]+(?:\.[\d]+)*').firstMatch(Platform.operatingSystemVersion);
|
||||
return match?.group(0) ?? Platform.operatingSystemVersion;
|
||||
}
|
||||
|
||||
String getDeviceBrand() => _cachedBrand ?? '';
|
||||
|
||||
String getDeviceModel() => _cachedModel ?? '';
|
||||
|
||||
String getUserAgent() => '';
|
||||
@@ -0,0 +1,36 @@
|
||||
import 'device_info_native.dart'
|
||||
// ignore: uri_does_not_exist
|
||||
if (dart.library.html) 'device_info_web.dart';
|
||||
|
||||
/// 设备信息工具类:SDK 内部自动检测,业务方无需手动传入。
|
||||
///
|
||||
/// 使用前须在 SDK 初始化时调用一次 [initialize],之后所有 getter 同步返回缓存值。
|
||||
class DeviceInfoUtil {
|
||||
static bool _initialized = false;
|
||||
|
||||
/// 异步初始化(调用 device_info_plus 获取品牌/型号并缓存)。
|
||||
/// 重复调用无副作用。
|
||||
static Future<void> initialize() async {
|
||||
if (_initialized) return;
|
||||
_initialized = true;
|
||||
await initializeDeviceInfo();
|
||||
}
|
||||
|
||||
/// 设备类型:Android / iOS / PC
|
||||
static String get deviceType => getDeviceType();
|
||||
|
||||
/// 操作系统名称:Android / iOS / macOS / Windows / Linux / Web
|
||||
static String get systemName => getSystemName();
|
||||
|
||||
/// 操作系统版本号
|
||||
static String get systemVersion => getSystemVersion();
|
||||
|
||||
/// 设备品牌(由 device_info_plus 自动获取)
|
||||
static String get deviceBrand => getDeviceBrand();
|
||||
|
||||
/// 设备型号(由 device_info_plus 自动获取)
|
||||
static String get deviceModel => getDeviceModel();
|
||||
|
||||
/// User-Agent(Web 端自动读取,Native 端返回空字符串)
|
||||
static String get userAgent => getUserAgent();
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
// ignore: avoid_web_libraries_in_flutter
|
||||
import 'dart:html' as html;
|
||||
|
||||
/// Web 端设备信息:浏览器无法稳定获取品牌/型号,系统名和版本从 UA 解析。
|
||||
Future<void> initializeDeviceInfo() async {}
|
||||
|
||||
String getDeviceType() {
|
||||
try {
|
||||
final ua = html.window.navigator.userAgent;
|
||||
if (ua.contains('iPhone') || ua.contains('iPad') || ua.contains('iPod')) {
|
||||
return 'iOS';
|
||||
}
|
||||
if (ua.contains('Android')) return 'Android';
|
||||
} catch (_) {}
|
||||
return 'PC';
|
||||
}
|
||||
|
||||
String getSystemName() => _osInfo().name;
|
||||
|
||||
String getSystemVersion() => _osInfo().version;
|
||||
|
||||
String getDeviceBrand() => '';
|
||||
|
||||
String getDeviceModel() => '';
|
||||
|
||||
String getUserAgent() {
|
||||
try {
|
||||
return html.window.navigator.userAgent;
|
||||
} catch (_) {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
_OsInfo _osInfo() {
|
||||
try {
|
||||
return _parseOsInfo(html.window.navigator.userAgent);
|
||||
} catch (_) {
|
||||
return const _OsInfo('', '');
|
||||
}
|
||||
}
|
||||
|
||||
_OsInfo _parseOsInfo(String ua) {
|
||||
if (ua.contains('iPhone') || ua.contains('iPad') || ua.contains('iPod')) {
|
||||
final match =
|
||||
RegExp(r'(?:CPU (?:iPhone )?OS|iPhone OS) ([\d_]+)').firstMatch(ua);
|
||||
if (match != null) {
|
||||
return _OsInfo('iOS', _normalizeVersion(match.group(1)));
|
||||
}
|
||||
return const _OsInfo('iOS', '');
|
||||
}
|
||||
|
||||
if (ua.contains('Android')) {
|
||||
final match = RegExp(r'Android ([\d.]+)').firstMatch(ua);
|
||||
if (match != null) return _OsInfo('Android', match.group(1) ?? '');
|
||||
return const _OsInfo('Android', '');
|
||||
}
|
||||
|
||||
final macMatch = RegExp(r'Mac OS X ([^;)]+)').firstMatch(ua);
|
||||
if (macMatch != null) {
|
||||
return _OsInfo('macOS', _normalizeVersion(macMatch.group(1)));
|
||||
}
|
||||
|
||||
final windowsMatch = RegExp(r'Windows NT ([\d.]+)').firstMatch(ua);
|
||||
if (windowsMatch != null) {
|
||||
return _OsInfo('Windows', windowsMatch.group(1) ?? '');
|
||||
}
|
||||
|
||||
final chromeOsMatch = RegExp(r'CrOS [^ ]+ ([\d.]+)').firstMatch(ua);
|
||||
if (chromeOsMatch != null) {
|
||||
return _OsInfo('ChromeOS', chromeOsMatch.group(1) ?? '');
|
||||
}
|
||||
|
||||
if (ua.contains('Linux')) return const _OsInfo('Linux', '');
|
||||
return const _OsInfo('', '');
|
||||
}
|
||||
|
||||
String _normalizeVersion(String? version) {
|
||||
return (version ?? '').replaceAll('_', '.').trim();
|
||||
}
|
||||
|
||||
class _OsInfo {
|
||||
final String name;
|
||||
final String version;
|
||||
|
||||
const _OsInfo(this.name, this.version);
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import 'dart:io' show Platform;
|
||||
import 'dart:ui' show window;
|
||||
|
||||
import 'package:battery_plus/battery_plus.dart';
|
||||
import 'package:connectivity_plus/connectivity_plus.dart';
|
||||
import 'package:package_info_plus/package_info_plus.dart';
|
||||
|
||||
/// 原生/桌面端环境字段采集。
|
||||
Future<Map<String, dynamic>> collectEnvironmentInfo() async {
|
||||
final screenInfo = _screenInfo();
|
||||
final batteryState = await _batteryState();
|
||||
final fields = <String, dynamic>{
|
||||
'language': window.locale.toLanguageTag(),
|
||||
'timezone': DateTime.now().timeZoneName,
|
||||
...screenInfo,
|
||||
'network_type': await _networkType(),
|
||||
'battery_level': await _batteryLevel(),
|
||||
'is_charging': _isChargingState(batteryState),
|
||||
'battery_state': _batteryStateName(batteryState),
|
||||
'cpu_cores': _cpuCores(),
|
||||
};
|
||||
|
||||
try {
|
||||
final info = await PackageInfo.fromPlatform();
|
||||
fields['package_name'] = info.packageName;
|
||||
} catch (_) {
|
||||
fields['package_name'] = '';
|
||||
}
|
||||
|
||||
return fields;
|
||||
}
|
||||
|
||||
Future<String> _networkType() async {
|
||||
try {
|
||||
final result = await Connectivity().checkConnectivity();
|
||||
switch (result) {
|
||||
case ConnectivityResult.wifi:
|
||||
return 'wifi';
|
||||
case ConnectivityResult.ethernet:
|
||||
return 'ethernet';
|
||||
case ConnectivityResult.mobile:
|
||||
case ConnectivityResult.none:
|
||||
case ConnectivityResult.bluetooth:
|
||||
case ConnectivityResult.vpn:
|
||||
case ConnectivityResult.other:
|
||||
return 'unknown';
|
||||
}
|
||||
} catch (_) {
|
||||
return 'unknown';
|
||||
}
|
||||
}
|
||||
|
||||
Future<int> _batteryLevel() async {
|
||||
try {
|
||||
return await Battery().batteryLevel;
|
||||
} catch (_) {
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
Future<BatteryState> _batteryState() async {
|
||||
try {
|
||||
return await Battery().batteryState;
|
||||
} catch (_) {
|
||||
return BatteryState.unknown;
|
||||
}
|
||||
}
|
||||
|
||||
int _isChargingState(BatteryState state) {
|
||||
if (state == BatteryState.charging || state == BatteryState.full) return 1;
|
||||
if (state == BatteryState.discharging) return 0;
|
||||
return -1;
|
||||
}
|
||||
|
||||
String _batteryStateName(BatteryState state) {
|
||||
switch (state) {
|
||||
case BatteryState.charging:
|
||||
return 'charging';
|
||||
case BatteryState.discharging:
|
||||
return 'discharging';
|
||||
case BatteryState.full:
|
||||
return 'full';
|
||||
case BatteryState.unknown:
|
||||
return 'unknown';
|
||||
}
|
||||
}
|
||||
|
||||
int _cpuCores() {
|
||||
try {
|
||||
return Platform.numberOfProcessors;
|
||||
} catch (_) {
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, dynamic> _screenInfo() {
|
||||
try {
|
||||
final size = window.physicalSize;
|
||||
final width = size.width.round();
|
||||
final height = size.height.round();
|
||||
final pixelRatio = (window.devicePixelRatio * 100).round();
|
||||
final dpi = (window.devicePixelRatio * 160).round();
|
||||
return {
|
||||
'screen_width': width,
|
||||
'screen_height': height,
|
||||
'screen_dpi': dpi,
|
||||
'device_pixel_ratio': pixelRatio,
|
||||
'orientation': width >= height ? 'landscape' : 'portrait',
|
||||
};
|
||||
} catch (_) {
|
||||
return const {
|
||||
'screen_width': -1,
|
||||
'screen_height': -1,
|
||||
'screen_dpi': -1,
|
||||
'device_pixel_ratio': -1,
|
||||
'orientation': '',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:analytics_sdk/entity/network_state_change_event.dart';
|
||||
import 'package:analytics_sdk/manager/network_state_fields_stub.dart'
|
||||
// ignore: uri_does_not_exist
|
||||
if (dart.library.html) 'package:analytics_sdk/manager/network_state_fields_web.dart';
|
||||
import 'package:analytics_sdk/manager/sdk_context.dart';
|
||||
import 'package:analytics_sdk/manager/session_manager.dart';
|
||||
import 'package:battery_plus/battery_plus.dart';
|
||||
import 'package:connectivity_plus/connectivity_plus.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:analytics_sdk/utils/network_type_resolver.dart';
|
||||
|
||||
import 'environment_info_native.dart'
|
||||
// ignore: uri_does_not_exist
|
||||
if (dart.library.html) 'environment_info_web.dart';
|
||||
import 'runtime_observation_stub.dart'
|
||||
// ignore: uri_does_not_exist
|
||||
if (dart.library.io) 'runtime_observation_native.dart'
|
||||
as runtime_observation;
|
||||
|
||||
/// 系统、网络、硬件和浏览器环境快照字段采集工具。
|
||||
class EnvironmentInfoUtil {
|
||||
static const Duration _batteryRefreshInterval = Duration(seconds: 60);
|
||||
|
||||
static final Battery _battery = Battery();
|
||||
static final Connectivity _connectivity = Connectivity();
|
||||
static Map<String, dynamic> _fields = _defaultFields();
|
||||
static bool _initialized = false;
|
||||
static bool _monitoring = false;
|
||||
static DateTime? _lastBatteryRefreshAt;
|
||||
static StreamSubscription<ConnectivityResult>? _connectivitySubscription;
|
||||
static StreamSubscription<BatteryState>? _batteryStateSubscription;
|
||||
static runtime_observation.RuntimeObservationMonitor?
|
||||
_runtimeObservationMonitor;
|
||||
static String _lastNetworkType = 'unknown';
|
||||
static String _networkChangeCountSid = '';
|
||||
static int _networkChangeCountInSid = 0;
|
||||
static void Function(dynamic event)? _trackNetworkStateChange;
|
||||
|
||||
/// 初始化并缓存环境字段。失败时保留默认 unknown/-1 兜底值。
|
||||
static Future<void> initialize() async {
|
||||
if (_initialized) return;
|
||||
_initialized = true;
|
||||
try {
|
||||
_fields = {
|
||||
..._defaultFields(),
|
||||
...await collectEnvironmentInfo(),
|
||||
...await runtime_observation.collectRuntimeObservationFields(),
|
||||
};
|
||||
_lastNetworkType = _stringField(_fields['network_type'], 'unknown');
|
||||
_lastBatteryRefreshAt = DateTime.now();
|
||||
} catch (_) {
|
||||
_fields = _defaultFields();
|
||||
_lastNetworkType = 'unknown';
|
||||
}
|
||||
}
|
||||
|
||||
/// 启动运行期字段监听,后续变化会同步更新内存公共字段。
|
||||
static void startMonitoring({void Function(dynamic event)? track}) {
|
||||
if (track != null) _trackNetworkStateChange = track;
|
||||
if (_monitoring) return;
|
||||
_monitoring = true;
|
||||
_connectivitySubscription = _connectivity.onConnectivityChanged.listen(
|
||||
(result) => _handleNetworkChange(result),
|
||||
onError: (_) => _mergeFields({'network_type': 'unknown'}),
|
||||
);
|
||||
_batteryStateSubscription = _battery.onBatteryStateChanged.listen(
|
||||
(state) async {
|
||||
final updates = <String, dynamic>{
|
||||
'is_charging': _mapBatteryState(state),
|
||||
'battery_state': _batteryStateName(state),
|
||||
};
|
||||
final level = await _readBatteryLevel();
|
||||
if (level >= 0) updates['battery_level'] = level;
|
||||
_mergeFields(updates);
|
||||
},
|
||||
onError: (_) => _mergeFields({
|
||||
'is_charging': -1,
|
||||
'battery_state': 'unknown',
|
||||
}),
|
||||
);
|
||||
_runtimeObservationMonitor =
|
||||
runtime_observation.startRuntimeObservationMonitoring(_mergeFields);
|
||||
}
|
||||
|
||||
/// 按最小间隔刷新电量百分比,避免为了字段新鲜度持续轮询。
|
||||
static Future<void> refreshBatteryLevelIfNeeded({bool force = false}) async {
|
||||
final now = DateTime.now();
|
||||
final last = _lastBatteryRefreshAt;
|
||||
if (!force &&
|
||||
last != null &&
|
||||
now.difference(last) < _batteryRefreshInterval) {
|
||||
return;
|
||||
}
|
||||
final level = await _readBatteryLevel();
|
||||
if (level < 0) return;
|
||||
_lastBatteryRefreshAt = now;
|
||||
_mergeFields({'battery_level': level});
|
||||
}
|
||||
|
||||
/// 主动刷新低频运行态快照,通常在前台恢复时调用。
|
||||
static Future<void> refreshRuntimeObservationFields() async {
|
||||
try {
|
||||
_mergeFields(await runtime_observation.collectRuntimeObservationFields());
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
/// 停止监听并清理订阅。
|
||||
static Future<void> dispose() async {
|
||||
_monitoring = false;
|
||||
await _connectivitySubscription?.cancel();
|
||||
await _batteryStateSubscription?.cancel();
|
||||
await _runtimeObservationMonitor?.dispose();
|
||||
_connectivitySubscription = null;
|
||||
_batteryStateSubscription = null;
|
||||
_runtimeObservationMonitor = null;
|
||||
_trackNetworkStateChange = null;
|
||||
}
|
||||
|
||||
/// 当前已缓存的环境快照字段。
|
||||
static Map<String, dynamic> get fields => Map<String, dynamic>.from(_fields);
|
||||
|
||||
static void _mergeFields(Map<String, dynamic> fields) {
|
||||
if (fields.isEmpty) return;
|
||||
_fields = {
|
||||
..._fields,
|
||||
...fields,
|
||||
};
|
||||
SdkContext.updateEnvironmentFields(fields);
|
||||
}
|
||||
|
||||
static Future<void> _handleNetworkChange(ConnectivityResult result) async {
|
||||
final webConnectionFields =
|
||||
kIsWeb ? await collectNetworkStateFields() : const <String, dynamic>{};
|
||||
final toNetworkType = kIsWeb
|
||||
? resolveWebNetworkType(
|
||||
connectionType: _stringField(
|
||||
webConnectionFields['connection_type'],
|
||||
'unknown',
|
||||
),
|
||||
effectiveType: _stringField(
|
||||
webConnectionFields['effective_type'],
|
||||
'unknown',
|
||||
),
|
||||
)
|
||||
: _mapNetworkType(result);
|
||||
final fromNetworkType = _lastNetworkType;
|
||||
_mergeFields({'network_type': toNetworkType});
|
||||
if (toNetworkType == fromNetworkType) return;
|
||||
|
||||
_lastNetworkType = toNetworkType;
|
||||
final track = _trackNetworkStateChange;
|
||||
if (track == null) return;
|
||||
|
||||
try {
|
||||
track(NetworkStateChangeEvent(
|
||||
networkType: toNetworkType,
|
||||
fromNetworkType: fromNetworkType,
|
||||
isOnline: isOnlineForNetworkType(toNetworkType),
|
||||
changeCountInSid: _nextNetworkChangeCountInSid(),
|
||||
carrier: kIsWeb
|
||||
? ''
|
||||
: _stringField(
|
||||
SdkContext.getEnvironmentSnapshotFields()['carrier'],
|
||||
'',
|
||||
),
|
||||
webConnectionFields: kIsWeb ? webConnectionFields : const {},
|
||||
));
|
||||
} catch (_) {
|
||||
// 网络变化是低优先级自动事件,采集失败不得影响业务。
|
||||
}
|
||||
}
|
||||
|
||||
static String _mapNetworkType(ConnectivityResult result) {
|
||||
switch (result) {
|
||||
case ConnectivityResult.wifi:
|
||||
return 'wifi';
|
||||
case ConnectivityResult.ethernet:
|
||||
return 'ethernet';
|
||||
case ConnectivityResult.mobile:
|
||||
return 'cellular';
|
||||
case ConnectivityResult.none:
|
||||
return 'offline';
|
||||
case ConnectivityResult.bluetooth:
|
||||
case ConnectivityResult.vpn:
|
||||
case ConnectivityResult.other:
|
||||
return 'unknown';
|
||||
}
|
||||
}
|
||||
|
||||
static int _mapBatteryState(BatteryState state) {
|
||||
if (state == BatteryState.charging || state == BatteryState.full) return 1;
|
||||
if (state == BatteryState.discharging) return 0;
|
||||
return -1;
|
||||
}
|
||||
|
||||
static String _batteryStateName(BatteryState state) {
|
||||
switch (state) {
|
||||
case BatteryState.charging:
|
||||
return 'charging';
|
||||
case BatteryState.discharging:
|
||||
return 'discharging';
|
||||
case BatteryState.full:
|
||||
return 'full';
|
||||
case BatteryState.unknown:
|
||||
return 'unknown';
|
||||
}
|
||||
}
|
||||
|
||||
static Future<int> _readBatteryLevel() async {
|
||||
try {
|
||||
return await _battery.batteryLevel;
|
||||
} catch (_) {
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
static int _isOnline(String networkType) {
|
||||
return isOnlineForNetworkType(networkType);
|
||||
}
|
||||
|
||||
static int _nextNetworkChangeCountInSid() {
|
||||
final sid = SessionManager.instance.currentSessionId ?? '';
|
||||
if (sid != _networkChangeCountSid) {
|
||||
_networkChangeCountSid = sid;
|
||||
_networkChangeCountInSid = 0;
|
||||
}
|
||||
_networkChangeCountInSid += 1;
|
||||
return _networkChangeCountInSid;
|
||||
}
|
||||
|
||||
static String _stringField(dynamic value, String fallback) {
|
||||
final text = value?.toString().trim() ?? '';
|
||||
return text.isEmpty ? fallback : text;
|
||||
}
|
||||
|
||||
@visibleForTesting
|
||||
static Future<void> handleNetworkChangeForTest(
|
||||
ConnectivityResult result, {
|
||||
required void Function(dynamic event) track,
|
||||
}) async {
|
||||
final previous = _trackNetworkStateChange;
|
||||
_trackNetworkStateChange = track;
|
||||
try {
|
||||
await _handleNetworkChange(result);
|
||||
} finally {
|
||||
_trackNetworkStateChange = previous;
|
||||
}
|
||||
}
|
||||
|
||||
@visibleForTesting
|
||||
static void resetNetworkChangeStateForTest({
|
||||
String networkType = 'unknown',
|
||||
String sid = '',
|
||||
}) {
|
||||
_lastNetworkType = networkType;
|
||||
_networkChangeCountSid = sid;
|
||||
_networkChangeCountInSid = 0;
|
||||
_trackNetworkStateChange = null;
|
||||
_mergeFields({'network_type': networkType});
|
||||
}
|
||||
|
||||
static Map<String, dynamic> _defaultFields() => {
|
||||
'language': '',
|
||||
'timezone': '',
|
||||
'package_name': '',
|
||||
'screen_width': -1,
|
||||
'screen_height': -1,
|
||||
'screen_dpi': -1,
|
||||
'device_pixel_ratio': -1,
|
||||
'orientation': '',
|
||||
'screen_brightness': -1,
|
||||
'volume': -1,
|
||||
'is_screen_on': -1,
|
||||
'network_type': 'unknown',
|
||||
'battery_level': -1,
|
||||
'is_charging': -1,
|
||||
'battery_state': 'unknown',
|
||||
'cpu_cores': -1,
|
||||
'device_memory_gb': -1,
|
||||
'browser_name': '',
|
||||
'browser_version': '',
|
||||
'cookie_enabled': -1,
|
||||
'local_storage_enabled': -1,
|
||||
'touch_support': -1,
|
||||
'max_touch_points': -1,
|
||||
'color_depth': -1,
|
||||
'is_pwa': -1,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
// ignore: avoid_web_libraries_in_flutter
|
||||
import 'dart:html' as html;
|
||||
|
||||
import 'package:analytics_sdk/manager/network_state_fields_web.dart';
|
||||
import 'package:package_info_plus/package_info_plus.dart';
|
||||
import 'package:analytics_sdk/utils/js_interop_compat.dart';
|
||||
import 'package:analytics_sdk/utils/network_type_resolver.dart';
|
||||
import 'package:battery_plus/battery_plus.dart';
|
||||
|
||||
/// Web 端环境字段采集。
|
||||
Future<Map<String, dynamic>> collectEnvironmentInfo() async {
|
||||
final navigator = html.window.navigator;
|
||||
final screen = html.window.screen;
|
||||
final browser = _parseBrowser(navigator.userAgent);
|
||||
final batteryState = await _batteryState();
|
||||
|
||||
return {
|
||||
'language': navigator.language,
|
||||
'timezone': _timezoneIdentifier(),
|
||||
'package_name': await _packageName(),
|
||||
'screen_width': screen?.width ?? -1,
|
||||
'screen_height': screen?.height ?? -1,
|
||||
'screen_dpi': -1,
|
||||
'orientation': _orientation(),
|
||||
'viewport_width': html.window.innerWidth ?? -1,
|
||||
'viewport_height': html.window.innerHeight ?? -1,
|
||||
'device_pixel_ratio': (html.window.devicePixelRatio * 100).round(),
|
||||
'network_type': await _networkType(),
|
||||
'battery_level': await _batteryLevel(),
|
||||
'is_charging': _isChargingState(batteryState),
|
||||
'battery_state': _batteryStateName(batteryState),
|
||||
'cpu_cores': navigator.hardwareConcurrency ?? -1,
|
||||
'device_memory_gb': _deviceMemoryGb(navigator),
|
||||
'browser_name': browser.name,
|
||||
'browser_version': browser.version,
|
||||
'cookie_enabled': navigator.cookieEnabled == true ? 1 : 0,
|
||||
'local_storage_enabled': _localStorageEnabled(),
|
||||
'touch_support': (navigator.maxTouchPoints ?? 0) > 0 ? 1 : 0,
|
||||
'color_depth': screen?.colorDepth ?? -1,
|
||||
'is_pwa': _isPwa(),
|
||||
};
|
||||
}
|
||||
|
||||
String _timezoneIdentifier() {
|
||||
try {
|
||||
final intl = getJsProperty(html.window, 'Intl');
|
||||
if (intl == null) return DateTime.now().timeZoneName;
|
||||
final formatter = callJsMethod(intl, 'DateTimeFormat', const []);
|
||||
if (formatter == null) return DateTime.now().timeZoneName;
|
||||
final options = callJsMethod(formatter, 'resolvedOptions', const []);
|
||||
if (options == null) return DateTime.now().timeZoneName;
|
||||
final timezone =
|
||||
getJsProperty(options, 'timeZone')?.toString().trim() ?? '';
|
||||
if (timezone.isNotEmpty) return timezone;
|
||||
} catch (_) {}
|
||||
return DateTime.now().timeZoneName;
|
||||
}
|
||||
|
||||
Future<String> _packageName() async {
|
||||
try {
|
||||
final info = await PackageInfo.fromPlatform();
|
||||
return info.packageName;
|
||||
} catch (_) {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
Future<String> _networkType() async {
|
||||
try {
|
||||
final connection = await collectNetworkStateFields();
|
||||
return resolveWebNetworkType(
|
||||
connectionType: connection['connection_type']?.toString() ?? 'unknown',
|
||||
effectiveType: connection['effective_type']?.toString() ?? 'unknown',
|
||||
);
|
||||
} catch (_) {
|
||||
return 'unknown';
|
||||
}
|
||||
}
|
||||
|
||||
Future<int> _batteryLevel() async {
|
||||
try {
|
||||
return await Battery().batteryLevel;
|
||||
} catch (_) {
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
Future<BatteryState> _batteryState() async {
|
||||
try {
|
||||
return await Battery().batteryState;
|
||||
} catch (_) {
|
||||
return BatteryState.unknown;
|
||||
}
|
||||
}
|
||||
|
||||
int _isChargingState(BatteryState state) {
|
||||
if (state == BatteryState.charging || state == BatteryState.full) return 1;
|
||||
if (state == BatteryState.discharging) return 0;
|
||||
return -1;
|
||||
}
|
||||
|
||||
String _batteryStateName(BatteryState state) {
|
||||
switch (state) {
|
||||
case BatteryState.charging:
|
||||
return 'charging';
|
||||
case BatteryState.discharging:
|
||||
return 'discharging';
|
||||
case BatteryState.full:
|
||||
return 'full';
|
||||
case BatteryState.unknown:
|
||||
return 'unknown';
|
||||
}
|
||||
}
|
||||
|
||||
String _orientation() {
|
||||
try {
|
||||
final width = html.window.innerWidth ?? 0;
|
||||
final height = html.window.innerHeight ?? 0;
|
||||
if (width <= 0 || height <= 0) return '';
|
||||
return width >= height ? 'landscape' : 'portrait';
|
||||
} catch (_) {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
int _deviceMemoryGb(html.Navigator navigator) {
|
||||
try {
|
||||
final memory = getJsProperty(navigator, 'deviceMemory');
|
||||
return memory is num ? memory.round() : -1;
|
||||
} catch (_) {
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
int _localStorageEnabled() {
|
||||
try {
|
||||
const key = '__analytics_sdk_storage_test__';
|
||||
html.window.localStorage[key] = '1';
|
||||
final ok = html.window.localStorage[key] == '1';
|
||||
html.window.localStorage.remove(key);
|
||||
return ok ? 1 : 0;
|
||||
} catch (_) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
int _isPwa() {
|
||||
try {
|
||||
final standalone = html.window
|
||||
.matchMedia('(display-mode: standalone)')
|
||||
.matches;
|
||||
final iosStandalone =
|
||||
getJsProperty(html.window.navigator, 'standalone') == true;
|
||||
return standalone || iosStandalone ? 1 : 0;
|
||||
} catch (_) {
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
_BrowserInfo _parseBrowser(String ua) {
|
||||
final rules = <String, RegExp>{
|
||||
'Edge': RegExp(r'Edg/([\d.]+)'),
|
||||
'Chrome': RegExp(r'Chrome/([\d.]+)'),
|
||||
'Firefox': RegExp(r'Firefox/([\d.]+)'),
|
||||
'Safari': RegExp(r'Version/([\d.]+).*Safari'),
|
||||
};
|
||||
for (final entry in rules.entries) {
|
||||
final match = entry.value.firstMatch(ua);
|
||||
if (match != null) {
|
||||
return _BrowserInfo(entry.key, match.group(1) ?? '');
|
||||
}
|
||||
}
|
||||
return const _BrowserInfo('', '');
|
||||
}
|
||||
|
||||
class _BrowserInfo {
|
||||
final String name;
|
||||
final String version;
|
||||
|
||||
const _BrowserInfo(this.name, this.version);
|
||||
}
|
||||
@@ -0,0 +1,592 @@
|
||||
import 'package:analytics_sdk/manager/session_manager.dart';
|
||||
import 'package:analytics_sdk/manager/sdk_context.dart';
|
||||
import 'package:analytics_sdk/utils/logger.dart';
|
||||
|
||||
/// 事件字段校验器
|
||||
///
|
||||
/// 校验规则分两类:
|
||||
///
|
||||
/// 1. **关键字段**:校验失败直接丢弃整个事件,[validate] 返回 `null`。
|
||||
/// 2. **可纠正字段**:自动截断/转换/填充,记录日志后继续上报。
|
||||
class EventValidator {
|
||||
// ──────── 公共字段长度限制 ────────
|
||||
static const int _maxEventLen = 64;
|
||||
static const int _maxAppIdLen = 64;
|
||||
static const int _maxShortLen = 128;
|
||||
static const int _maxFpLen = 128;
|
||||
static const int _maxUaLen = 512;
|
||||
|
||||
// ──────── timestamp 合法范围(2000-01-01 ~ 2100-01-01) ────────
|
||||
static const int _minTs = 946684800;
|
||||
static const int _maxTs = 4102444800;
|
||||
|
||||
// ──────── 正则 ────────
|
||||
static final RegExp _eventPattern = RegExp(r'^[a-z0-9_]+$');
|
||||
static final RegExp _eventIdPattern = RegExp(r'^[A-Za-z0-9]{1,32}$');
|
||||
static final RegExp _eventSigPattern = RegExp(r'^[a-f0-9]{64}$');
|
||||
static final RegExp _keyPattern = RegExp(r'^[A-Za-z0-9_]+$');
|
||||
|
||||
// ──────── payload 字段分类 ────────
|
||||
|
||||
/// 关键 ID 字段:校验失败丢弃整个事件
|
||||
static const Set<String> _criticalIdFields = {
|
||||
'video_id',
|
||||
'novel_id',
|
||||
'comic_id',
|
||||
'click_item_id',
|
||||
};
|
||||
|
||||
/// 普通 ID 字段:校验失败仅移除该字段
|
||||
/// 注意:ad_id / recommend_id 已在 _commaSeparatedFields 中处理,不重复列入
|
||||
static const Set<String> _normalIdFields = {
|
||||
'order_id',
|
||||
'item_id',
|
||||
'page_id',
|
||||
};
|
||||
|
||||
/// 逗号分隔字段(分割 → trim → 过滤空 → 重新拼接)
|
||||
static const Set<String> _commaSeparatedFields = {
|
||||
'ad_id',
|
||||
};
|
||||
|
||||
/// 名称字段,最大 128 字符
|
||||
static const Set<String> _nameFields = {
|
||||
'page_name',
|
||||
'referrer_page_name',
|
||||
'current_page_name',
|
||||
'source_page_name',
|
||||
'item_name',
|
||||
'tab_name',
|
||||
'product_name',
|
||||
'video_type_name',
|
||||
'novel_type_name',
|
||||
'comic_type_name',
|
||||
'navigation_name',
|
||||
'advertising_name',
|
||||
'video_behavior_name',
|
||||
'novel_behavior_name',
|
||||
'comic_behavior_name',
|
||||
'consume_reason_name',
|
||||
'vip_duration_name',
|
||||
'click_item_type_name',
|
||||
};
|
||||
|
||||
/// 标题字段,最大 256 字符
|
||||
static const Set<String> _titleFields = {
|
||||
'video_title',
|
||||
'novel_title',
|
||||
'comic_title',
|
||||
};
|
||||
|
||||
/// 关键词/评论字段,最大 500 字符
|
||||
static const Set<String> _keywordFields = {
|
||||
'keyword',
|
||||
'comment_content',
|
||||
};
|
||||
|
||||
/// 百分比字段,范围 0~100
|
||||
static const Set<String> _percentageFields = {
|
||||
'play_progress',
|
||||
'read_progress',
|
||||
'click_x_percent',
|
||||
'click_y_percent',
|
||||
};
|
||||
|
||||
/// 位置字段,值 ≥ 1
|
||||
static const Set<String> _positionFields = {
|
||||
'page_no',
|
||||
'click_position',
|
||||
};
|
||||
|
||||
/// 非负数字字段,值 ≥ 0
|
||||
static const Set<String> _numericFields = {
|
||||
'play_duration',
|
||||
'video_duration',
|
||||
'coin_quantity',
|
||||
'coin_consume_amount',
|
||||
'coin_balance_before',
|
||||
'coin_balance_after',
|
||||
'amount',
|
||||
'search_result_count',
|
||||
'screen_width',
|
||||
'screen_height',
|
||||
'screen_dpi',
|
||||
'page_load_time',
|
||||
'flag',
|
||||
'available_storage',
|
||||
'storage',
|
||||
'fill_count',
|
||||
'load_duration',
|
||||
'position',
|
||||
};
|
||||
|
||||
/// 时间戳字段(范围同 client_ts)
|
||||
static const Set<String> _timestampFields = {
|
||||
'create_time',
|
||||
'vip_expiration_time',
|
||||
};
|
||||
|
||||
// ─────────────────────────────────────────────────────────
|
||||
// 公开 API
|
||||
// ─────────────────────────────────────────────────────────
|
||||
|
||||
/// 校验并自动纠正事件 Map。
|
||||
///
|
||||
/// 返回纠正后的 Map;若关键字段无法修复则返回 `null`(调用方应丢弃该事件)。
|
||||
/// 本方法永远不会抛出异常。
|
||||
static Map<String, dynamic>? validate(Map<String, dynamic> event) {
|
||||
try {
|
||||
final result = Map<String, dynamic>.from(event);
|
||||
|
||||
// ── 1. event ─────────────────────────────────────────
|
||||
// 必填,trim,正则 ^[a-z0-9_]+$,最大 64 字符;不符合 → 丢弃
|
||||
final rawEvent = _toStr(result['event']);
|
||||
if (rawEvent == null) {
|
||||
Logger.analyticsSdk('校验失败:event 字段缺失或类型错误,丢弃事件', level: LogLevel.warn);
|
||||
return null;
|
||||
}
|
||||
if (!_eventPattern.hasMatch(rawEvent) || rawEvent.length > _maxEventLen) {
|
||||
Logger.analyticsSdk('校验失败:event "$rawEvent" 格式不合规或超长,丢弃事件',
|
||||
level: LogLevel.warn);
|
||||
return null;
|
||||
}
|
||||
result['event'] = rawEvent;
|
||||
|
||||
// ── 2. event_id ──────────────────────────────────────
|
||||
// 必填,trim,正则 ^[A-Za-z0-9]{1,32}$;不符合 → 丢弃
|
||||
final rawEventId = _toStr(result['event_id']);
|
||||
if (rawEventId == null || rawEventId.isEmpty) {
|
||||
Logger.analyticsSdk('校验失败:event_id 字段缺失或为空,丢弃事件', level: LogLevel.warn);
|
||||
return null;
|
||||
}
|
||||
if (!_eventIdPattern.hasMatch(rawEventId)) {
|
||||
Logger.analyticsSdk('校验失败:event_id "$rawEventId" 格式不合规,丢弃事件',
|
||||
level: LogLevel.warn);
|
||||
return null;
|
||||
}
|
||||
result['event_id'] = rawEventId;
|
||||
|
||||
// ── 3. app_id ────────────────────────────────────────
|
||||
// 必填,不能为空,最大 64 字符;空或缺失 → 丢弃;超长 → 截断
|
||||
final rawAppId = _toStr(result['app_id']);
|
||||
if (rawAppId == null || rawAppId.isEmpty) {
|
||||
Logger.analyticsSdk('校验失败:app_id 字段缺失或为空,丢弃事件', level: LogLevel.warn);
|
||||
return null;
|
||||
}
|
||||
result['app_id'] = _trimTrunc(rawAppId, _maxAppIdLen);
|
||||
|
||||
// ── 4. channel ───────────────────────────────────────
|
||||
// 可选,trim,最大 128;超长截断
|
||||
final rawChannel = _toStr(result['channel']);
|
||||
if (rawChannel != null) {
|
||||
result['channel'] = _trimTrunc(rawChannel, _maxShortLen);
|
||||
}
|
||||
|
||||
// ── 5. uid ───────────────────────────────────────────
|
||||
// 可选,trim,最大 128;超长截断
|
||||
final rawUid = _toStr(result['uid']);
|
||||
if (rawUid != null) {
|
||||
result['uid'] = _trimTrunc(rawUid, _maxShortLen);
|
||||
}
|
||||
|
||||
// ── 6. sid ───────────────────────────────────────────
|
||||
// 必填;空/缺失 → 自动生成;生成后仍为空 → 丢弃
|
||||
String sid = _toStr(result['sid']) ?? '';
|
||||
if (sid.isEmpty) {
|
||||
try {
|
||||
sid = SessionManager.instance.getSessionId();
|
||||
Logger.analyticsSdk('自动纠正:sid 为空,已自动生成: $sid');
|
||||
} catch (e) {
|
||||
Logger.analyticsSdk('自动纠正:sid 生成失败: $e', level: LogLevel.warn);
|
||||
sid = '';
|
||||
}
|
||||
}
|
||||
if (sid.isEmpty) {
|
||||
Logger.analyticsSdk('校验失败:sid 无法获取,丢弃事件', level: LogLevel.warn);
|
||||
return null;
|
||||
}
|
||||
result['sid'] = sid;
|
||||
|
||||
// ── 7. client_ts ─────────────────────────────────────
|
||||
// 必填,整数,范围 946684800~4102444800;字符串可尝试转换;超出范围 → 丢弃
|
||||
final tsVal = _parseTs(result['client_ts']);
|
||||
if (tsVal == null) {
|
||||
Logger.analyticsSdk('校验失败:client_ts 无效或超出范围,丢弃事件',
|
||||
level: LogLevel.warn);
|
||||
return null;
|
||||
}
|
||||
result['client_ts'] = tsVal;
|
||||
|
||||
// ── 8. device ────────────────────────────────────────
|
||||
// 仅 Android/iOS/PC;大小写自动纠正;其他值 → 丢弃;空字符串视为未设置,跳过
|
||||
final rawDevice = _toStr(result['device']);
|
||||
if (rawDevice != null && rawDevice.isNotEmpty) {
|
||||
final normalized = _normalizeDevice(rawDevice);
|
||||
if (normalized == null) {
|
||||
Logger.analyticsSdk(
|
||||
'校验失败:device "$rawDevice" 不合规(须为 Android/iOS/PC),丢弃事件',
|
||||
level: LogLevel.warn);
|
||||
return null;
|
||||
}
|
||||
result['device'] = normalized;
|
||||
}
|
||||
|
||||
// ── 9. device_id ─────────────────────────────────────
|
||||
// 必填,trim,1~128;空或缺失 → 丢弃;超长截断
|
||||
final rawDeviceId = _toStr(result['device_id']);
|
||||
if (rawDeviceId == null || rawDeviceId.isEmpty) {
|
||||
Logger.analyticsSdk('校验失败:device_id 字段缺失或为空,丢弃事件',
|
||||
level: LogLevel.warn);
|
||||
return null;
|
||||
}
|
||||
result['device_id'] = _trimTrunc(rawDeviceId, _maxShortLen);
|
||||
|
||||
// ── 10. user_agent ───────────────────────────────────
|
||||
// 可选,trim,超长截断(512,规范要求)
|
||||
final rawUa = _toStr(result['user_agent']);
|
||||
if (rawUa != null) {
|
||||
result['user_agent'] = _trimTrunc(rawUa, _maxUaLen);
|
||||
}
|
||||
|
||||
// ── 11. device_brand / device_model / system_name / system_version ──
|
||||
// 可选,trim,超长截断(128)
|
||||
for (final field in [
|
||||
'device_brand',
|
||||
'device_model',
|
||||
'system_name',
|
||||
'system_version'
|
||||
]) {
|
||||
final v = _toStr(result[field]);
|
||||
if (v != null) {
|
||||
result[field] = _trimTrunc(v, _maxShortLen);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 12. sdk_version / app_version ────────────────────
|
||||
// 可选,trim,最大 128 字符;超长截断
|
||||
for (final field in ['sdk_version', 'app_version']) {
|
||||
final v = _toStr(result[field]);
|
||||
if (v != null) {
|
||||
result[field] = _trimTrunc(v, _maxShortLen);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 12.1 system / app / network environment ─────────
|
||||
for (final field in [
|
||||
'language',
|
||||
'timezone',
|
||||
'package_name',
|
||||
'network_type',
|
||||
'browser_name',
|
||||
'browser_version',
|
||||
]) {
|
||||
final v = _toStr(result[field]);
|
||||
if (v != null) {
|
||||
result[field] = _trimTrunc(v, _maxShortLen);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 12.2 hardware / browser numeric environment ─────
|
||||
for (final field in [
|
||||
'battery_level',
|
||||
'is_charging',
|
||||
'cpu_cores',
|
||||
'device_memory_gb',
|
||||
'cookie_enabled',
|
||||
'local_storage_enabled',
|
||||
'touch_support',
|
||||
'color_depth',
|
||||
'is_pwa',
|
||||
]) {
|
||||
final v = _parseInt(result[field]);
|
||||
if (v != null) result[field] = v;
|
||||
}
|
||||
|
||||
// ── 13. device_fingerprint ───────────────────────────
|
||||
// SDK 管控字段,不接受外部覆盖。
|
||||
final rawFp = _toStr(result['device_fingerprint']);
|
||||
final hasDeviceFingerprintField =
|
||||
result.containsKey('device_fingerprint');
|
||||
if (SdkContext.deviceFingerprint.isNotEmpty ||
|
||||
hasDeviceFingerprintField) {
|
||||
final sdkFp = _trimTrunc(SdkContext.deviceFingerprint, _maxFpLen);
|
||||
if (rawFp != sdkFp) {
|
||||
Logger.analyticsSdk('自动纠正:device_fingerprint 不允许外部覆盖,已改为 SDK 内部值');
|
||||
}
|
||||
result['device_fingerprint'] = sdkFp;
|
||||
}
|
||||
|
||||
// ── 14. fp_version ───────────────────────────────────
|
||||
// fp_version 是 SDK 指纹规则版本,不接受外部覆盖。
|
||||
final fp = (result['device_fingerprint'] as String?) ?? '';
|
||||
final hasFpVersionField = result.containsKey('fp_version');
|
||||
if (fp.isNotEmpty || hasFpVersionField || hasDeviceFingerprintField) {
|
||||
final fpVer = _toStr(result['fp_version']) ?? '';
|
||||
if (fpVer != SdkContext.fingerprintVersion) {
|
||||
result['fp_version'] = SdkContext.fingerprintVersion;
|
||||
if (fpVer.isEmpty) {
|
||||
Logger.analyticsSdk(
|
||||
'自动纠正:fp_version 为空,已填充默认版本 ${SdkContext.fingerprintVersion}');
|
||||
} else {
|
||||
Logger.analyticsSdk(
|
||||
'自动纠正:fp_version=$fpVer 不允许外部覆盖,已改为 ${SdkContext.fingerprintVersion}');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── 14.1 event_sig / event_sig_version ───────────────
|
||||
// 客户端签名字段由 SdkContext 生成;校验器只规范化已存在字段。
|
||||
if (result.containsKey('event_sig')) {
|
||||
final rawEventSig = _toStr(result['event_sig']) ?? '';
|
||||
result['event_sig'] =
|
||||
_eventSigPattern.hasMatch(rawEventSig) ? rawEventSig : '';
|
||||
}
|
||||
|
||||
if (result.containsKey('event_sig_version')) {
|
||||
final rawEventSigVersion = _toStr(result['event_sig_version']) ?? '';
|
||||
result['event_sig_version'] =
|
||||
_trimTrunc(rawEventSigVersion, _maxShortLen);
|
||||
}
|
||||
|
||||
// ── 15. payload ──────────────────────────────────────
|
||||
// 可为 null(补空 Map),类型错误 → 丢弃
|
||||
final rawPayload = result['payload'];
|
||||
if (rawPayload == null) {
|
||||
result['payload'] = <String, dynamic>{};
|
||||
} else if (rawPayload is! Map) {
|
||||
Logger.analyticsSdk('校验失败:payload 类型错误(${rawPayload.runtimeType}),丢弃事件',
|
||||
level: LogLevel.warn);
|
||||
return null;
|
||||
} else {
|
||||
final validated = _validatePayload(
|
||||
Map<String, dynamic>.from(rawPayload),
|
||||
eventType: result['event'] as String,
|
||||
);
|
||||
if (validated == null) return null; // 关键 ID 字段校验失败
|
||||
result['payload'] = validated;
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (e) {
|
||||
Logger.analyticsSdk('事件校验内部异常: $e,丢弃事件', level: LogLevel.warn);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────
|
||||
// payload 业务字段校验
|
||||
// ─────────────────────────────────────────────────────────
|
||||
|
||||
/// 校验 payload 业务字段。
|
||||
/// 返回纠正后的 Map;若关键 ID 字段不合规则返回 `null`(丢弃事件)。
|
||||
static Map<String, dynamic>? _validatePayload(
|
||||
Map<String, dynamic> payload, {
|
||||
required String eventType,
|
||||
}) {
|
||||
final result = <String, dynamic>{};
|
||||
|
||||
for (final entry in payload.entries) {
|
||||
final key = entry.key;
|
||||
final value = entry.value;
|
||||
|
||||
// key 本身须符合 ^[A-Za-z0-9_]+$
|
||||
if (!_keyPattern.hasMatch(key)) {
|
||||
Logger.analyticsSdk('自动纠正:payload 字段名 "$key" 不合规,已移除',
|
||||
level: LogLevel.warn);
|
||||
continue;
|
||||
}
|
||||
|
||||
// ── 逗号分隔字段 ─────────────────────────────────────
|
||||
if (_commaSeparatedFields.contains(key)) {
|
||||
final s = _toStr(value);
|
||||
if (s == null) {
|
||||
Logger.analyticsSdk('自动纠正:payload.$key 类型错误,已移除');
|
||||
continue;
|
||||
}
|
||||
final cleaned = _cleanComma(s);
|
||||
if (cleaned.isEmpty) {
|
||||
// 逗号分隔字段清洗后为空,视为无效字段移除
|
||||
Logger.analyticsSdk('自动纠正:payload.$key 清洗后为空,已移除');
|
||||
continue;
|
||||
}
|
||||
result[key] = cleaned;
|
||||
continue;
|
||||
}
|
||||
|
||||
// ── 关键 ID 字段 ─────────────────────────────────────
|
||||
if (_criticalIdFields.contains(key)) {
|
||||
final s = _toStr(value);
|
||||
if (s == null || s.isEmpty) {
|
||||
Logger.analyticsSdk('校验失败:payload.$key(关键ID)为空或类型错误,丢弃事件',
|
||||
level: LogLevel.warn);
|
||||
return null;
|
||||
}
|
||||
result[key] = s;
|
||||
continue;
|
||||
}
|
||||
|
||||
// ── 普通 ID 字段 ─────────────────────────────────────
|
||||
if (_normalIdFields.contains(key)) {
|
||||
final s = _toStr(value);
|
||||
if (s == null || s.isEmpty) {
|
||||
Logger.analyticsSdk('自动纠正:payload.$key(ID字段)为空或类型错误,已移除');
|
||||
continue;
|
||||
}
|
||||
result[key] = s;
|
||||
continue;
|
||||
}
|
||||
|
||||
// ── 名称字段(最大 128) ──────────────────────────────
|
||||
if (_nameFields.contains(key)) {
|
||||
final s = _toStr(value);
|
||||
if (s == null) {
|
||||
Logger.analyticsSdk('自动纠正:payload.$key 类型错误,已移除');
|
||||
continue;
|
||||
}
|
||||
result[key] = _trimTrunc(s, 128);
|
||||
continue;
|
||||
}
|
||||
|
||||
// ── 标题字段(最大 256) ──────────────────────────────
|
||||
if (_titleFields.contains(key)) {
|
||||
final s = _toStr(value);
|
||||
if (s == null) {
|
||||
Logger.analyticsSdk('自动纠正:payload.$key 类型错误,已移除');
|
||||
continue;
|
||||
}
|
||||
result[key] = _trimTrunc(s, 256);
|
||||
continue;
|
||||
}
|
||||
|
||||
// ── 关键词字段(最大 500) ────────────────────────────
|
||||
if (_keywordFields.contains(key)) {
|
||||
final s = _toStr(value);
|
||||
if (s == null) {
|
||||
Logger.analyticsSdk('自动纠正:payload.$key 类型错误,已移除');
|
||||
continue;
|
||||
}
|
||||
result[key] = _trimTrunc(s, 500);
|
||||
continue;
|
||||
}
|
||||
|
||||
// ── 百分比字段(0~100) ───────────────────────────────
|
||||
if (_percentageFields.contains(key)) {
|
||||
final n = _parseInt(value);
|
||||
if (n == null || n < 0 || n > 100) {
|
||||
Logger.analyticsSdk('自动纠正:payload.$key 百分比值无效($value),已移除');
|
||||
continue;
|
||||
}
|
||||
result[key] = n;
|
||||
continue;
|
||||
}
|
||||
|
||||
// ── 位置字段(≥ 1) ───────────────────────────────────
|
||||
if (_positionFields.contains(key)) {
|
||||
final n = _parseInt(value);
|
||||
if (n == null || n < 1) {
|
||||
Logger.analyticsSdk('自动纠正:payload.$key 位置值无效($value),已移除');
|
||||
continue;
|
||||
}
|
||||
result[key] = n;
|
||||
continue;
|
||||
}
|
||||
|
||||
// ── 数字字段(≥ 0) ───────────────────────────────────
|
||||
if (_numericFields.contains(key)) {
|
||||
final n = _parseInt(value);
|
||||
final min = eventType == 'sdk_init' ? -1 : 0;
|
||||
if (n == null || n < min) {
|
||||
Logger.analyticsSdk('自动纠正:payload.$key 数字值无效($value),已移除');
|
||||
continue;
|
||||
}
|
||||
result[key] = n;
|
||||
continue;
|
||||
}
|
||||
|
||||
// ── 时间戳字段(范围校验) ─────────────────────────────
|
||||
if (_timestampFields.contains(key)) {
|
||||
final ts = _parseTs(value);
|
||||
if (ts == null) {
|
||||
Logger.analyticsSdk('自动纠正:payload.$key 时间戳无效($value),已移除');
|
||||
continue;
|
||||
}
|
||||
result[key] = ts;
|
||||
continue;
|
||||
}
|
||||
|
||||
// ── 其他字段(字符串超长截断,非字符串保留原值) ───────
|
||||
if (value is String && value.length > 500) {
|
||||
result[key] = value.substring(0, 500);
|
||||
Logger.analyticsSdk('自动纠正:payload.$key 超长,已截断至500字符');
|
||||
} else {
|
||||
result[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────
|
||||
// 工具方法
|
||||
// ─────────────────────────────────────────────────────────
|
||||
|
||||
/// 将 dynamic 转为 trim 后的 String
|
||||
///
|
||||
/// - String → trim
|
||||
/// - num / bool → toString().trim()(业务方可能传入 int 类型的 uid、channel 等)
|
||||
/// - 其他类型 → null
|
||||
static String? _toStr(dynamic v) {
|
||||
if (v == null) return null;
|
||||
if (v is String) return v.trim();
|
||||
if (v is num || v is bool) return v.toString().trim();
|
||||
return null;
|
||||
}
|
||||
|
||||
/// 截断字符串到最大长度(已是 trim 后的值)
|
||||
static String _trimTrunc(String s, int max) {
|
||||
return s.length > max ? s.substring(0, max) : s;
|
||||
}
|
||||
|
||||
/// 将 device 字段大小写标准化,返回 null 表示无法识别
|
||||
static String? _normalizeDevice(String raw) {
|
||||
final lower = raw.trim().toLowerCase();
|
||||
if (lower == 'android') return 'Android';
|
||||
if (lower == 'ios') return 'iOS';
|
||||
if (lower == 'pc') return 'PC';
|
||||
return null;
|
||||
}
|
||||
|
||||
/// 解析整数(支持 int 和数字字符串)
|
||||
static int? _parseInt(dynamic v) {
|
||||
if (v is int) return v;
|
||||
if (v is double) return v.toInt();
|
||||
if (v is String) return int.tryParse(v.trim());
|
||||
return null;
|
||||
}
|
||||
|
||||
/// 解析时间戳,范围须在 [_minTs, _maxTs];支持 int / double / 数字字符串
|
||||
///
|
||||
/// 注意:Dart Web 上 int 与 double 统一为 JS number,显式处理 double
|
||||
/// 以保证 Native 与 Web 行为一致。
|
||||
static int? _parseTs(dynamic v) {
|
||||
int? ts;
|
||||
if (v is int) {
|
||||
ts = v;
|
||||
} else if (v is double) {
|
||||
if (v.isFinite) ts = v.toInt();
|
||||
} else if (v is String) {
|
||||
ts = int.tryParse(v.trim());
|
||||
}
|
||||
if (ts == null || ts < _minTs || ts > _maxTs) return null;
|
||||
return ts;
|
||||
}
|
||||
|
||||
/// 清理逗号分隔字符串:各段 trim、过滤空段、重新拼接
|
||||
static String _cleanComma(String raw) {
|
||||
return raw
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.where((s) => s.isNotEmpty)
|
||||
.join(',');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export 'js_interop_legacy.dart'
|
||||
if (dart.library.js_interop) 'js_interop_modern.dart';
|
||||
@@ -0,0 +1,23 @@
|
||||
// ignore_for_file: uri_does_not_exist
|
||||
|
||||
import 'dart:js_util' as js_util;
|
||||
|
||||
/// Dynamic JavaScript interop helpers for Dart 2.17 / Flutter 3.0.
|
||||
Object? getJsProperty(Object object, Object property) =>
|
||||
js_util.getProperty<Object?>(object, property);
|
||||
|
||||
bool hasJsProperty(Object object, String property) =>
|
||||
js_util.hasProperty(object, property);
|
||||
|
||||
Object? callJsMethod(
|
||||
Object object,
|
||||
String method, [
|
||||
List<Object?> arguments = const [],
|
||||
]) =>
|
||||
js_util.callMethod<Object?>(object, method, arguments);
|
||||
|
||||
Object? constructJsObject(
|
||||
Object constructor, [
|
||||
List<Object?> arguments = const [],
|
||||
]) =>
|
||||
js_util.callConstructor<Object?>(constructor, arguments);
|
||||
@@ -0,0 +1,57 @@
|
||||
// ignore_for_file: undefined_class, undefined_identifier, undefined_method, uri_does_not_exist
|
||||
|
||||
import 'dart:js_interop';
|
||||
import 'dart:js_interop_unsafe';
|
||||
|
||||
/// Dynamic JavaScript interop helpers for Dart 3.10 and WebAssembly builds.
|
||||
Object? getJsProperty(Object object, Object property) {
|
||||
final value = (object as JSObject).getProperty<JSAny?>(
|
||||
_propertyToJs(property),
|
||||
);
|
||||
return _toDartValue(value);
|
||||
}
|
||||
|
||||
bool hasJsProperty(Object object, String property) =>
|
||||
(object as JSObject).has(property);
|
||||
|
||||
Object? callJsMethod(
|
||||
Object object,
|
||||
String method, [
|
||||
List<Object?> arguments = const [],
|
||||
]) {
|
||||
final value = (object as JSObject).callMethodVarArgs<JSAny?>(
|
||||
method.toJS,
|
||||
arguments.map(_valueToJs).toList(),
|
||||
);
|
||||
return _toDartValue(value);
|
||||
}
|
||||
|
||||
Object? constructJsObject(
|
||||
Object constructor, [
|
||||
List<Object?> arguments = const [],
|
||||
]) =>
|
||||
(constructor as JSFunction).callAsConstructorVarArgs<JSObject>(
|
||||
arguments.map(_valueToJs).toList(),
|
||||
);
|
||||
|
||||
JSAny _propertyToJs(Object property) {
|
||||
if (property is String) return property.toJS;
|
||||
if (property is num) return property.toJS;
|
||||
throw ArgumentError.value(property, 'property', 'Must be a string or number');
|
||||
}
|
||||
|
||||
JSAny? _valueToJs(Object? value) {
|
||||
if (value == null) return null;
|
||||
if (value is JSAny) return value;
|
||||
if (value is String) return value.toJS;
|
||||
if (value is num) return value.toJS;
|
||||
if (value is bool) return value.toJS;
|
||||
throw ArgumentError.value(value, 'value', 'Unsupported JavaScript argument');
|
||||
}
|
||||
|
||||
Object? _toDartValue(JSAny? value) {
|
||||
if (value is JSString) return value.toDart;
|
||||
if (value is JSNumber) return value.toDartDouble;
|
||||
if (value is JSBoolean) return value.toDart;
|
||||
return value;
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
import 'dart:developer' as developer;
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
/// 日志级别枚举
|
||||
enum LogLevel {
|
||||
debug,
|
||||
info,
|
||||
warn,
|
||||
error,
|
||||
}
|
||||
|
||||
/// 日志工具类:统一管理 SDK 中的所有日志输出
|
||||
class Logger {
|
||||
Logger._();
|
||||
|
||||
/// 是否启用日志(默认:仅在 debug 模式下启用)
|
||||
static bool enabled = false;
|
||||
|
||||
/// 外部日志回调(可选)。
|
||||
/// 注册后,所有 SDK 日志在控制台输出的同时也会转发给此回调。
|
||||
/// 示例:在 example app 中将 SDK 内部日志显示到 UI 面板。
|
||||
static void Function(String message, LogLevel level)? onLog;
|
||||
|
||||
/// 测试专用:替换 dart:developer.log,便于验证长日志走 developer log。
|
||||
@visibleForTesting
|
||||
static void Function(String message, {String name})? developerLogWriterForTest;
|
||||
|
||||
/// 测试专用:替换 debugPrint,便于验证长日志分片输出到控制台/logcat。
|
||||
@visibleForTesting
|
||||
static void Function(String message)? consoleLogWriterForTest;
|
||||
|
||||
/// Android logcat 单行有长度限制,保守按字符分片,避免超长 JSON 被截断。
|
||||
static const int _consoleChunkSize = 800;
|
||||
|
||||
/// 日志标签映射
|
||||
static const Map<String, String> _tags = {
|
||||
'AnalyticsSdk': '[AnalyticsSdk]',
|
||||
'DomainManager': '[DomainManager]',
|
||||
'EventTypeConfigManager': '[EventTypeConfigManager]',
|
||||
'GlobalClick': '[GlobalClick]',
|
||||
'AdImpressionManager': '[AdImpressionManager]',
|
||||
'PageLifecycleObserver': '[PageLifecycleObserver]',
|
||||
'InitConfigResult': '[InitConfigResult]',
|
||||
};
|
||||
|
||||
/// 获取日志标签
|
||||
static String _getTag(String tag) {
|
||||
return _tags[tag] ?? '[$tag]';
|
||||
}
|
||||
|
||||
/// 格式化日志消息
|
||||
static String _formatMessage(String tag, String message) {
|
||||
return '${_getTag(tag)} $message';
|
||||
}
|
||||
|
||||
/// 输出到 Flutter 控制台;Android 上会进入 logcat 的 flutter 日志流。
|
||||
static void _writeConsole(String message) {
|
||||
final writer = consoleLogWriterForTest;
|
||||
if (writer != null) {
|
||||
writer(message);
|
||||
} else {
|
||||
debugPrint(message);
|
||||
}
|
||||
}
|
||||
|
||||
/// 分片输出超长日志,避免 logcat / IDE 控制台按单行截断。
|
||||
static void _writeConsoleMessage(String tag, String message) {
|
||||
if (message.length <= _consoleChunkSize) {
|
||||
_writeConsole(_formatMessage(tag, message));
|
||||
return;
|
||||
}
|
||||
|
||||
final tagPrefix = _getTag(tag);
|
||||
final total = (message.length + _consoleChunkSize - 1) ~/ _consoleChunkSize;
|
||||
for (var index = 0; index < total; index++) {
|
||||
final start = index * _consoleChunkSize;
|
||||
final end = (start + _consoleChunkSize < message.length) ? start + _consoleChunkSize : message.length;
|
||||
_writeConsole(
|
||||
'$tagPrefix part ${index + 1}/$total: ${message.substring(start, end)}',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 输出调试日志
|
||||
static void debug(String tag, String message) {
|
||||
if (enabled) {
|
||||
final msg = _formatMessage(tag, message);
|
||||
_writeConsoleMessage(tag, message);
|
||||
try {
|
||||
onLog?.call(msg, LogLevel.debug);
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
|
||||
/// 输出信息日志
|
||||
static void info(String tag, String message) {
|
||||
if (enabled) {
|
||||
final msg = _formatMessage(tag, message);
|
||||
_writeConsoleMessage(tag, message);
|
||||
try {
|
||||
onLog?.call(msg, LogLevel.info);
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
|
||||
/// 输出警告日志
|
||||
static void warn(String tag, String message) {
|
||||
if (enabled) {
|
||||
final formattedMessage = '⚠️ $message';
|
||||
final msg = _formatMessage(tag, formattedMessage);
|
||||
_writeConsoleMessage(tag, formattedMessage);
|
||||
try {
|
||||
onLog?.call(msg, LogLevel.warn);
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
|
||||
/// 输出错误日志
|
||||
static void error(String tag, String message, [Object? error, StackTrace? stackTrace]) {
|
||||
if (enabled) {
|
||||
final errorInfo = error != null ? ', 错误: $error' : '';
|
||||
final stackInfo = stackTrace != null ? '\n堆栈: $stackTrace' : '';
|
||||
final formattedMessage = '❌ $message$errorInfo$stackInfo';
|
||||
final msg = _formatMessage(tag, formattedMessage);
|
||||
_writeConsoleMessage(tag, formattedMessage);
|
||||
try {
|
||||
onLog?.call(msg, LogLevel.error);
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
|
||||
/// 输出日志(自动判断级别)
|
||||
static void log(String tag, String message, {LogLevel level = LogLevel.debug}) {
|
||||
switch (level) {
|
||||
case LogLevel.debug:
|
||||
debug(tag, message);
|
||||
break;
|
||||
case LogLevel.info:
|
||||
info(tag, message);
|
||||
break;
|
||||
case LogLevel.warn:
|
||||
warn(tag, message);
|
||||
break;
|
||||
case LogLevel.error:
|
||||
error(tag, message);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/// 输出 developer log。
|
||||
///
|
||||
/// 长 JSON 等需要完整复制的日志使用此入口。
|
||||
///
|
||||
/// developer.log 保留完整单条内容给 DevTools;控制台/logcat 使用分片,避免单行截断。
|
||||
static void developerLog(
|
||||
String tag,
|
||||
String message, {
|
||||
LogLevel level = LogLevel.debug,
|
||||
}) {
|
||||
if (enabled) {
|
||||
final msg = _formatMessage(tag, message);
|
||||
final writer = developerLogWriterForTest;
|
||||
if (writer != null) {
|
||||
writer(msg, name: tag);
|
||||
} else {
|
||||
developer.log(msg, name: tag);
|
||||
}
|
||||
_writeConsoleMessage(tag, message);
|
||||
try {
|
||||
onLog?.call(msg, level);
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
|
||||
/// 便捷方法:AnalyticsSdk 日志
|
||||
static void analyticsSdk(String message, {LogLevel level = LogLevel.debug}) {
|
||||
log('AnalyticsSdk', message, level: level);
|
||||
}
|
||||
|
||||
/// 便捷方法:AnalyticsSdk developer log
|
||||
static void analyticsSdkDeveloperLog(
|
||||
String message, {
|
||||
LogLevel level = LogLevel.debug,
|
||||
}) {
|
||||
developerLog('AnalyticsSdk', message, level: level);
|
||||
}
|
||||
|
||||
/// 便捷方法:DomainManager 日志
|
||||
static void domainManager(String message, {LogLevel level = LogLevel.debug}) {
|
||||
log('DomainManager', message, level: level);
|
||||
}
|
||||
|
||||
/// 便捷方法:EventTypeConfigManager 日志
|
||||
static void eventTypeConfigManager(String message, {LogLevel level = LogLevel.debug}) {
|
||||
log('EventTypeConfigManager', message, level: level);
|
||||
}
|
||||
|
||||
/// 便捷方法:GlobalClick 日志
|
||||
static void globalClick(String message, {LogLevel level = LogLevel.debug}) {
|
||||
log('GlobalClick', message, level: level);
|
||||
}
|
||||
|
||||
/// 便捷方法:AdImpressionManager 日志
|
||||
static void adImpressionManager(String message, {LogLevel level = LogLevel.debug}) {
|
||||
log('AdImpressionManager', message, level: level);
|
||||
}
|
||||
|
||||
/// 便捷方法:PageLifecycleObserver 日志
|
||||
static void pageLifecycleObserver(String message, {LogLevel level = LogLevel.debug}) {
|
||||
log('PageLifecycleObserver', message, level: level);
|
||||
}
|
||||
|
||||
/// 便捷方法:InitConfigResult 日志
|
||||
static void initConfigResult(String message, {LogLevel level = LogLevel.debug}) {
|
||||
log('InitConfigResult', message, level: level);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
const Set<String> networkTypeValues = {
|
||||
'wifi',
|
||||
'ethernet',
|
||||
'cellular',
|
||||
'cellular_2g',
|
||||
'cellular_3g',
|
||||
'cellular_4g',
|
||||
'cellular_5g',
|
||||
'offline',
|
||||
'unknown',
|
||||
};
|
||||
|
||||
String resolveWebNetworkType({
|
||||
required String connectionType,
|
||||
required String effectiveType,
|
||||
}) {
|
||||
switch (connectionType.trim().toLowerCase()) {
|
||||
case 'wifi':
|
||||
return 'wifi';
|
||||
case 'ethernet':
|
||||
return 'ethernet';
|
||||
case 'none':
|
||||
return 'offline';
|
||||
case 'cellular':
|
||||
switch (effectiveType.trim().toLowerCase()) {
|
||||
case 'slow-2g':
|
||||
case '2g':
|
||||
return 'cellular_2g';
|
||||
case '3g':
|
||||
return 'cellular_3g';
|
||||
case '4g':
|
||||
return 'cellular_4g';
|
||||
case '5g':
|
||||
return 'cellular_5g';
|
||||
default:
|
||||
return 'cellular';
|
||||
}
|
||||
default:
|
||||
return 'unknown';
|
||||
}
|
||||
}
|
||||
|
||||
String normalizeNetworkType(String value) {
|
||||
final normalized = value.trim().toLowerCase();
|
||||
return networkTypeValues.contains(normalized) ? normalized : 'unknown';
|
||||
}
|
||||
|
||||
int isOnlineForNetworkType(String networkType) {
|
||||
switch (normalizeNetworkType(networkType)) {
|
||||
case 'offline':
|
||||
return 0;
|
||||
case 'unknown':
|
||||
return -1;
|
||||
default:
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import 'platform_storage_native.dart'
|
||||
// ignore: uri_does_not_exist
|
||||
if (dart.library.html) 'platform_storage_web.dart';
|
||||
|
||||
/// 跨平台 key-value 存储。
|
||||
/// Native:文件存储(path_provider);Web:localStorage。
|
||||
/// key 即文件名(native)或 localStorage key(web),不含路径。
|
||||
class PlatformStorage {
|
||||
static Future<String?> read(String key) => platformRead(key);
|
||||
static Future<void> write(String key, String value) =>
|
||||
platformWrite(key, value);
|
||||
static Future<bool> has(String key) => platformHas(key);
|
||||
static Future<void> delete(String key) => platformDelete(key);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
|
||||
Future<String?> platformRead(String key) async {
|
||||
try {
|
||||
final dir = await getApplicationDocumentsDirectory();
|
||||
final file = File('${dir.path}/$key');
|
||||
if (!await file.exists()) return null;
|
||||
return await file.readAsString();
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> platformWrite(String key, String value) async {
|
||||
try {
|
||||
final dir = await getApplicationDocumentsDirectory();
|
||||
await File('${dir.path}/$key').writeAsString(value);
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
Future<bool> platformHas(String key) async {
|
||||
try {
|
||||
final dir = await getApplicationDocumentsDirectory();
|
||||
return File('${dir.path}/$key').existsSync();
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> platformDelete(String key) async {
|
||||
try {
|
||||
final dir = await getApplicationDocumentsDirectory();
|
||||
final file = File('${dir.path}/$key');
|
||||
if (await file.exists()) await file.delete();
|
||||
} catch (_) {}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
// ignore: avoid_web_libraries_in_flutter
|
||||
import 'dart:html' as html;
|
||||
|
||||
Future<String?> platformRead(String key) async =>
|
||||
html.window.localStorage[key];
|
||||
|
||||
Future<void> platformWrite(String key, String value) async =>
|
||||
html.window.localStorage[key] = value;
|
||||
|
||||
Future<bool> platformHas(String key) async =>
|
||||
html.window.localStorage.containsKey(key);
|
||||
|
||||
Future<void> platformDelete(String key) async =>
|
||||
html.window.localStorage.remove(key);
|
||||
@@ -0,0 +1,164 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io' show Platform;
|
||||
import 'dart:ui' show window;
|
||||
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:screen_brightness/screen_brightness.dart';
|
||||
import 'package:screen_state/screen_state.dart';
|
||||
import 'package:volume_controller/volume_controller.dart';
|
||||
|
||||
/// Native runtime observation fields backed by platform plugins.
|
||||
class RuntimeObservationMonitor {
|
||||
final List<StreamSubscription<dynamic>> _subscriptions;
|
||||
final WidgetsBindingObserver? _metricsObserver;
|
||||
|
||||
const RuntimeObservationMonitor(
|
||||
this._subscriptions, {
|
||||
WidgetsBindingObserver? metricsObserver,
|
||||
}) : _metricsObserver = metricsObserver;
|
||||
|
||||
Future<void> dispose() async {
|
||||
for (final subscription in _subscriptions) {
|
||||
await subscription.cancel();
|
||||
}
|
||||
final metricsObserver = _metricsObserver;
|
||||
if (metricsObserver != null) {
|
||||
try {
|
||||
WidgetsBinding.instance.removeObserver(metricsObserver);
|
||||
} catch (_) {}
|
||||
}
|
||||
try {
|
||||
VolumeController().removeListener();
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> collectRuntimeObservationFields() async {
|
||||
return {
|
||||
..._screenObservationFields(),
|
||||
'screen_brightness': await _readScreenBrightness(),
|
||||
'volume': await _readVolume(),
|
||||
'is_screen_on': _initialScreenOn(),
|
||||
};
|
||||
}
|
||||
|
||||
RuntimeObservationMonitor startRuntimeObservationMonitoring(
|
||||
void Function(Map<String, dynamic> fields) onFields,
|
||||
) {
|
||||
final subscriptions = <StreamSubscription<dynamic>>[];
|
||||
WidgetsBindingObserver? metricsObserver;
|
||||
|
||||
try {
|
||||
metricsObserver = _MetricsObserver(
|
||||
onMetricsChanged: () => onFields(_screenObservationFields()),
|
||||
);
|
||||
WidgetsBinding.instance.addObserver(metricsObserver);
|
||||
} catch (_) {}
|
||||
|
||||
try {
|
||||
subscriptions.add(ScreenBrightness().onCurrentBrightnessChanged.listen(
|
||||
(value) => onFields({
|
||||
'screen_brightness': _normalizedPercent(value),
|
||||
}), onError: (_) {
|
||||
onFields({'screen_brightness': -1});
|
||||
}));
|
||||
} catch (_) {}
|
||||
|
||||
try {
|
||||
subscriptions.add(VolumeController().listener((value) {
|
||||
onFields({'volume': _normalizedPercent(value)});
|
||||
}));
|
||||
} catch (_) {}
|
||||
|
||||
try {
|
||||
if (Platform.isAndroid || Platform.isIOS) {
|
||||
subscriptions.add(Screen().screenStateStream.listen((event) {
|
||||
onFields({'is_screen_on': _screenStateValue(event)});
|
||||
}, onError: (_) {
|
||||
onFields({'is_screen_on': -1});
|
||||
}));
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
return RuntimeObservationMonitor(
|
||||
subscriptions,
|
||||
metricsObserver: metricsObserver,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> _screenObservationFields() {
|
||||
try {
|
||||
final size = window.physicalSize;
|
||||
final width = size.width.round();
|
||||
final height = size.height.round();
|
||||
final pixelRatio = (window.devicePixelRatio * 100).round();
|
||||
final dpi = (window.devicePixelRatio * 160).round();
|
||||
return {
|
||||
'screen_width': width,
|
||||
'screen_height': height,
|
||||
'screen_dpi': dpi,
|
||||
'device_pixel_ratio': pixelRatio,
|
||||
'orientation': width >= height ? 'landscape' : 'portrait',
|
||||
};
|
||||
} catch (_) {
|
||||
return const {
|
||||
'screen_width': -1,
|
||||
'screen_height': -1,
|
||||
'screen_dpi': -1,
|
||||
'device_pixel_ratio': -1,
|
||||
'orientation': '',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Future<int> _readScreenBrightness() async {
|
||||
try {
|
||||
return _normalizedPercent(await ScreenBrightness().current);
|
||||
} catch (_) {
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
Future<int> _readVolume() async {
|
||||
try {
|
||||
return _normalizedPercent(await VolumeController().getVolume());
|
||||
} catch (_) {
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
int _initialScreenOn() {
|
||||
try {
|
||||
return Platform.isAndroid || Platform.isIOS ? 1 : -1;
|
||||
} catch (_) {
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
int _screenStateValue(ScreenStateEvent event) {
|
||||
switch (event) {
|
||||
case ScreenStateEvent.SCREEN_ON:
|
||||
case ScreenStateEvent.SCREEN_UNLOCKED:
|
||||
return 1;
|
||||
case ScreenStateEvent.SCREEN_OFF:
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
int _normalizedPercent(num value) {
|
||||
final numeric = value.toDouble();
|
||||
if (!numeric.isFinite) return -1;
|
||||
final scaled = numeric <= 1 ? numeric * 100 : numeric;
|
||||
return scaled.clamp(0, 100).round();
|
||||
}
|
||||
|
||||
class _MetricsObserver with WidgetsBindingObserver {
|
||||
final void Function() onMetricsChanged;
|
||||
|
||||
const _MetricsObserver({required this.onMetricsChanged});
|
||||
|
||||
@override
|
||||
void didChangeMetrics() {
|
||||
onMetricsChanged();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
/// Runtime observation fields for platforms without native observers.
|
||||
class RuntimeObservationMonitor {
|
||||
const RuntimeObservationMonitor();
|
||||
|
||||
Future<void> dispose() async {}
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> collectRuntimeObservationFields() async {
|
||||
return const <String, dynamic>{};
|
||||
}
|
||||
|
||||
RuntimeObservationMonitor startRuntimeObservationMonitoring(
|
||||
void Function(Map<String, dynamic> fields) onFields,
|
||||
) {
|
||||
return const RuntimeObservationMonitor();
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import 'dart:math';
|
||||
|
||||
/// 生成符合 RFC 4122 的 UUID v4 字符串
|
||||
/// 替代 uuid 包,消除跨 Dart 版本的依赖约束冲突
|
||||
String generateUuidV4() {
|
||||
final random = Random.secure();
|
||||
final bytes = List<int>.generate(16, (_) => random.nextInt(256));
|
||||
bytes[6] = (bytes[6] & 0x0f) | 0x40; // version 4
|
||||
bytes[8] = (bytes[8] & 0x3f) | 0x80; // variant 1
|
||||
String toHex(int b) => b.toRadixString(16).padLeft(2, '0');
|
||||
return '${toHex(bytes[0])}${toHex(bytes[1])}${toHex(bytes[2])}${toHex(bytes[3])}-'
|
||||
'${toHex(bytes[4])}${toHex(bytes[5])}-${toHex(bytes[6])}${toHex(bytes[7])}-'
|
||||
'${toHex(bytes[8])}${toHex(bytes[9])}-'
|
||||
'${toHex(bytes[10])}${toHex(bytes[11])}${toHex(bytes[12])}${toHex(bytes[13])}${toHex(bytes[14])}${toHex(bytes[15])}';
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
/// 内部桥接层:解除 widget 对 AnalyticsSdk 的直接依赖。
|
||||
///
|
||||
/// AnalyticsSdk.init() 调用 [register] 注册实现;
|
||||
/// widget 层通过此类的静态方法访问 SDK 能力,
|
||||
/// 无需 import analytics_sdk.dart,避免上行依赖。
|
||||
class WidgetBridge {
|
||||
static void Function(dynamic event)? _track;
|
||||
static String Function()? _getUserType;
|
||||
static bool _enableDebugBanner = false;
|
||||
static List<Map<String, String>> Function()? _getDebugSteps;
|
||||
|
||||
/// 由 AnalyticsSdk.init() 调用,注册 SDK 实现
|
||||
static void register({
|
||||
required void Function(dynamic event) track,
|
||||
required String Function() getUserType,
|
||||
required bool enableDebugBanner,
|
||||
required List<Map<String, String>> Function() getDebugSteps,
|
||||
}) {
|
||||
_track = track;
|
||||
_getUserType = getUserType;
|
||||
_enableDebugBanner = enableDebugBanner;
|
||||
_getDebugSteps = getDebugSteps;
|
||||
}
|
||||
|
||||
static void track(dynamic event) => _track?.call(event);
|
||||
|
||||
/// 获取当前用户类型,SDK 未初始化时返回 'normal'。
|
||||
static String getUserType() => _getUserType?.call() ?? 'normal';
|
||||
|
||||
static bool get enableDebugBanner => _enableDebugBanner;
|
||||
|
||||
static List<Map<String, String>> getDebugSteps() =>
|
||||
_getDebugSteps?.call() ?? [];
|
||||
}
|
||||
Reference in New Issue
Block a user