Initial commit: 91dj android client dev_1.0.0

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
谢宇宁
2026-10-08 11:37:25 +07:00
co-authored by Claude Opus 5.5
commit 97dd6a739a
1262 changed files with 119813 additions and 0 deletions
+346
View File
@@ -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;
}
+218
View File
@@ -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();
}
+15
View File
@@ -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() ?? [];
}