Initial commit: 91dj android client dev_1.0.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,12 @@
|
||||
import 'package:flutter_web_plugins/flutter_web_plugins.dart';
|
||||
|
||||
/// Analytics SDK 的 Web 平台插件入口。
|
||||
///
|
||||
/// Flutter Web 插件系统要求此文件存在,并实现 [registerWith]。
|
||||
/// SDK 的 Web 实现(设备信息、指纹等)位于 utils/ 目录下的 web 专属文件。
|
||||
class AnalyticsSdkWeb {
|
||||
static void registerWith(Registrar registrar) {
|
||||
// SDK 不使用 MethodChannel,无需注册通道。
|
||||
// Web 平台功能通过条件导入的工具类直接实现。
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
/// API 配置管理类:统一管理所有 API 端点路径
|
||||
class ApiConfig {
|
||||
ApiConfig._();
|
||||
|
||||
// ==================== API 路径(固定不变)====================
|
||||
|
||||
/// 上报接口路径(固定)
|
||||
static const String defaultReportPath = '/api/eventTracking/batchReport.json';
|
||||
|
||||
/// 上报接口路径
|
||||
static String get reportPath => defaultReportPath;
|
||||
|
||||
// ==================== 配置方法 ====================
|
||||
|
||||
/// 构建完整的 URL
|
||||
/// [baseDomain] 基础域名,如 'https://api.example.com'
|
||||
/// [path] API 路径,如 '/api/analytics/report'
|
||||
/// [appId] 应用ID(可选),如果提供会作为查询参数添加到URL中
|
||||
/// 返回完整的 URL,如 'https://api.example.com/api/analytics/report?appId=xxx'
|
||||
static String buildUrl(String baseDomain, String path, {String? appId}) {
|
||||
// 确保路径以 / 开头
|
||||
final normalizedPath = _normalizePath(path);
|
||||
|
||||
// 解析基础域名
|
||||
final uri = Uri.parse(baseDomain);
|
||||
final scheme = uri.scheme.isEmpty ? 'https' : uri.scheme;
|
||||
final host = uri.host.isEmpty ? baseDomain : uri.host;
|
||||
|
||||
// 保留非默认端口(如 :8080),避免 IP+端口 场景下端口丢失
|
||||
final defaultPort = scheme == 'https' ? 443 : 80;
|
||||
final portSuffix =
|
||||
(uri.port > 0 && uri.port != defaultPort) ? ':${uri.port}' : '';
|
||||
|
||||
// 保留 baseDomain 中的路径前缀(如 /v2),仅在 host 成功解析时使用
|
||||
// 去掉末尾 / 避免与 normalizedPath 拼出双斜杠
|
||||
final basePath = uri.host.isNotEmpty && uri.path.isNotEmpty && uri.path != '/'
|
||||
? (uri.path.endsWith('/')
|
||||
? uri.path.substring(0, uri.path.length - 1)
|
||||
: uri.path)
|
||||
: '';
|
||||
|
||||
// 构建完整 URL
|
||||
String url = '$scheme://$host$portSuffix$basePath$normalizedPath';
|
||||
|
||||
// 如果提供了 appId,添加到查询参数
|
||||
if (appId != null && appId.isNotEmpty) {
|
||||
url = _addQueryParameter(url, 'appId', appId);
|
||||
}
|
||||
|
||||
return url;
|
||||
}
|
||||
|
||||
/// 规范化路径:确保路径以 / 开头
|
||||
static String _normalizePath(String path) {
|
||||
if (path.isEmpty) {
|
||||
return '/';
|
||||
}
|
||||
return path.startsWith('/') ? path : '/$path';
|
||||
}
|
||||
|
||||
/// 获取上报接口 URL
|
||||
/// [baseDomain] 基础域名
|
||||
/// [appId] 应用ID(可选,如果提供会作为查询参数)
|
||||
static String getReportUrl(String baseDomain, {String? appId}) {
|
||||
return buildUrl(baseDomain, reportPath, appId: appId);
|
||||
}
|
||||
|
||||
/// 添加查询参数到 URL
|
||||
/// [url] 原始 URL
|
||||
/// [key] 参数名
|
||||
/// [value] 参数值
|
||||
static String _addQueryParameter(String url, String key, String value) {
|
||||
try {
|
||||
final uri = Uri.parse(url);
|
||||
final queryParams = Map<String, String>.from(uri.queryParameters);
|
||||
queryParams[key] = value;
|
||||
return uri.replace(queryParameters: queryParams).toString();
|
||||
} catch (e) {
|
||||
// 如果 URL 解析失败,直接拼接
|
||||
final separator = url.contains('?') ? '&' : '?';
|
||||
return '$url$separator$key=${Uri.encodeComponent(value)}';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,334 @@
|
||||
/// SDK 配置管理类:统一管理所有 SDK 相关的配置常量
|
||||
class SdkConfig {
|
||||
SdkConfig._();
|
||||
|
||||
// ==================== 默认值常量(单一来源)====================
|
||||
// 字段声明和 reset() 均引用这些常量,避免三处重复。
|
||||
|
||||
static const int _defaultMaxQueueSize = 5000;
|
||||
static const int _defaultAutoUploadThreshold = 10;
|
||||
static const int _defaultMaxBatchSize = 500;
|
||||
static const int _defaultIsolateEncodingThreshold = 100;
|
||||
static const Duration _defaultUploadInterval = Duration(seconds: 5);
|
||||
static const Duration _defaultPageLoadTimeout = Duration(seconds: 5);
|
||||
static const Duration _defaultClickThrottleDuration =
|
||||
Duration(milliseconds: 300);
|
||||
static const Duration _defaultConnectionTimeout = Duration(seconds: 10);
|
||||
static const Duration _defaultReadTimeout = Duration(seconds: 30);
|
||||
static const Duration _defaultSpeedTestTimeout = Duration(seconds: 5);
|
||||
static const Duration _defaultConfigRequestTimeout = Duration(seconds: 10);
|
||||
static const int _defaultMaxBackoffLevel = 5;
|
||||
static const Duration _defaultBaseRetryDelay = Duration(seconds: 2);
|
||||
static const Duration _defaultMaxRetryDelay = Duration(seconds: 60);
|
||||
static const String _defaultCacheFileName = 'data_plus_events_cache.jsonl';
|
||||
static const String _defaultDebugReportEventLogFileName =
|
||||
'data_plus_report_events_debug.jsonl';
|
||||
static const bool _defaultEnableDebugReportLog = false;
|
||||
static const String _defaultEventTypeConfigCacheFileName =
|
||||
'analytics_event_types_config.json';
|
||||
static const String _defaultDomainCacheFileName =
|
||||
'data_plus_domains_cache.json';
|
||||
static const int _defaultMaxCacheLines = 2000;
|
||||
static const int _defaultMaxCacheBytes = 2 * 1024 * 1024;
|
||||
static const int _defaultTombstoneCompactionThreshold = 200;
|
||||
static const String _defaultTombstoneFileName =
|
||||
'data_plus_events_tombstone.txt';
|
||||
static const int _defaultMaxJsonSize = 5 * 1024 * 1024;
|
||||
static const int _defaultMaxSingleEventSize = 300 * 1024;
|
||||
static const int _defaultMaxConfigResponseSize = 1 * 1024 * 1024;
|
||||
static const int _defaultMaxPageStackSize = 100;
|
||||
static const int _defaultMaxAdImpressionCapacity = 10000;
|
||||
static const int _defaultCacheBufferThreshold = 10;
|
||||
static const Duration _defaultCacheFlushInterval = Duration(seconds: 5);
|
||||
|
||||
// ==================== 事件队列配置 ====================
|
||||
|
||||
/// 事件队列最大容量限制,防止内存无限增长
|
||||
static int maxQueueSize = _defaultMaxQueueSize;
|
||||
|
||||
/// 自动上报阈值:队列达到此数量时立即触发上报
|
||||
static int autoUploadThreshold = _defaultAutoUploadThreshold;
|
||||
|
||||
/// 单次上报的最大事件数量限制,避免请求过大导致超时
|
||||
static int maxBatchSize = _defaultMaxBatchSize;
|
||||
|
||||
/// JSON 编码的批次大小阈值,超过此大小使用 isolate 编码
|
||||
static int isolateEncodingThreshold = _defaultIsolateEncodingThreshold;
|
||||
|
||||
// ==================== 定时器配置 ====================
|
||||
|
||||
/// 定时上报间隔(秒)
|
||||
static Duration uploadInterval = _defaultUploadInterval;
|
||||
|
||||
/// 页面加载超时时间(秒)
|
||||
static Duration pageLoadTimeout = _defaultPageLoadTimeout;
|
||||
|
||||
/// 点击事件节流时间(毫秒)
|
||||
static Duration clickThrottleDuration = _defaultClickThrottleDuration;
|
||||
|
||||
// ==================== 网络超时配置 ====================
|
||||
|
||||
/// HTTP 连接超时时间
|
||||
static Duration connectionTimeout = _defaultConnectionTimeout;
|
||||
|
||||
/// HTTP 读取超时时间
|
||||
static Duration readTimeout = _defaultReadTimeout;
|
||||
|
||||
/// HTTP 写入超时时间(与连接超时相同)
|
||||
static Duration get writeTimeout => connectionTimeout;
|
||||
|
||||
/// HTTP 总超时时间(连接 + 写入 + 读取)
|
||||
static Duration get totalRequestTimeout =>
|
||||
connectionTimeout + writeTimeout + readTimeout;
|
||||
|
||||
/// 域名测速超时时间
|
||||
static Duration speedTestTimeout = _defaultSpeedTestTimeout;
|
||||
|
||||
/// 配置请求超时时间
|
||||
static Duration configRequestTimeout = _defaultConfigRequestTimeout;
|
||||
|
||||
// ==================== 加密配置 ====================
|
||||
|
||||
/// AES-GCM 解密密钥(固定密钥,用于 decryptResponseAuto 等)
|
||||
static const String decryptKey =
|
||||
'en1BNo0VBrN/zi+mI2LO7E9W40ehCBYwC+frBn8s3rQ';
|
||||
|
||||
// ==================== 重试配置 ====================
|
||||
|
||||
/// 最大退避级别
|
||||
static int maxBackoffLevel = _defaultMaxBackoffLevel;
|
||||
|
||||
/// 基础延迟时间
|
||||
static Duration baseRetryDelay = _defaultBaseRetryDelay;
|
||||
|
||||
/// 最大延迟时间
|
||||
static Duration maxRetryDelay = _defaultMaxRetryDelay;
|
||||
|
||||
// ==================== 缓存配置 ====================
|
||||
|
||||
/// 事件缓存文件名
|
||||
static String cacheFileName = _defaultCacheFileName;
|
||||
|
||||
/// Debug 包完整事件 JSONL 文件名(Native 平台 applicationDocumentsDirectory 下)
|
||||
static String debugReportEventLogFileName =
|
||||
_defaultDebugReportEventLogFileName;
|
||||
|
||||
/// 是否启用调试事件日志。
|
||||
///
|
||||
/// 默认关闭;开启后才会本地记录上传事件完整 JSONL,并打印 accepted event 完整 JSON。
|
||||
static bool enableDebugReportLog = _defaultEnableDebugReportLog;
|
||||
|
||||
/// 事件类型配置缓存文件名
|
||||
static String eventTypeConfigCacheFileName =
|
||||
_defaultEventTypeConfigCacheFileName;
|
||||
|
||||
/// 域名列表缓存文件名
|
||||
static String domainCacheFileName = _defaultDomainCacheFileName;
|
||||
|
||||
/// 最大缓存行数限制,防止文件过大导致内存问题
|
||||
/// 默认 2000 行,避免在网络不稳定时生成过大的缓存文件
|
||||
static int maxCacheLines = _defaultMaxCacheLines;
|
||||
|
||||
/// 缓存文件最大字节数限制,超过后从文件开头裁剪旧事件
|
||||
/// 默认 2MB
|
||||
static int maxCacheBytes = _defaultMaxCacheBytes;
|
||||
|
||||
/// tombstone 触发压缩的 event_id 数量阈值
|
||||
/// 默认 200:每积累 200 条已上报 ID 就压缩一次主缓存文件
|
||||
static int tombstoneCompactionThreshold =
|
||||
_defaultTombstoneCompactionThreshold;
|
||||
|
||||
/// tombstone 缓存文件名
|
||||
static String tombstoneFileName = _defaultTombstoneFileName;
|
||||
|
||||
/// 持久化 buffer 攒批阈值(达到此数量立即写盘)
|
||||
static int cacheBufferThreshold = _defaultCacheBufferThreshold;
|
||||
|
||||
/// 持久化 buffer 定时刷写间隔
|
||||
static Duration cacheFlushInterval = _defaultCacheFlushInterval;
|
||||
|
||||
// ==================== 请求大小限制 ====================
|
||||
|
||||
/// JSON 最大大小限制(5MB)
|
||||
static int maxJsonSize = _defaultMaxJsonSize;
|
||||
|
||||
/// 单条事件最大字节数限制,超过则丢弃(300KB)
|
||||
static int maxSingleEventSize = _defaultMaxSingleEventSize;
|
||||
|
||||
/// 配置接口响应体最大字节数限制(1MB),防止恶意/异常服务器返回超大数据导致 OOM
|
||||
static int maxConfigResponseSize = _defaultMaxConfigResponseSize;
|
||||
|
||||
// ==================== 容量配置 ====================
|
||||
|
||||
/// 页面栈最大容量
|
||||
static int maxPageStackSize = _defaultMaxPageStackSize;
|
||||
|
||||
/// 广告去重最大容量
|
||||
static int maxAdImpressionCapacity = _defaultMaxAdImpressionCapacity;
|
||||
|
||||
// ==================== 配置方法 ====================
|
||||
|
||||
/// 配置所有 SDK 参数
|
||||
///
|
||||
/// 示例:
|
||||
/// ```dart
|
||||
/// SdkConfig.configure(
|
||||
/// maxQueueSize: 20000,
|
||||
/// uploadInterval: Duration(seconds: 30),
|
||||
/// connectionTimeout: Duration(seconds: 15),
|
||||
/// );
|
||||
/// ```
|
||||
static void configure({
|
||||
int? maxQueueSize,
|
||||
int? autoUploadThreshold,
|
||||
int? maxBatchSize,
|
||||
int? isolateEncodingThreshold,
|
||||
Duration? uploadInterval,
|
||||
Duration? pageLoadTimeout,
|
||||
Duration? clickThrottleDuration,
|
||||
Duration? connectionTimeout,
|
||||
Duration? readTimeout,
|
||||
Duration? speedTestTimeout,
|
||||
Duration? configRequestTimeout,
|
||||
int? maxBackoffLevel,
|
||||
Duration? baseRetryDelay,
|
||||
Duration? maxRetryDelay,
|
||||
String? cacheFileName,
|
||||
String? debugReportEventLogFileName,
|
||||
bool? enableDebugReportLog,
|
||||
String? eventTypeConfigCacheFileName,
|
||||
String? domainCacheFileName,
|
||||
int? maxCacheLines,
|
||||
int? maxCacheBytes,
|
||||
int? maxJsonSize,
|
||||
int? maxSingleEventSize,
|
||||
int? maxPageStackSize,
|
||||
int? maxAdImpressionCapacity,
|
||||
}) {
|
||||
if (maxQueueSize != null && maxQueueSize > 0) {
|
||||
SdkConfig.maxQueueSize = maxQueueSize;
|
||||
}
|
||||
if (autoUploadThreshold != null && autoUploadThreshold > 0) {
|
||||
SdkConfig.autoUploadThreshold = autoUploadThreshold;
|
||||
}
|
||||
if (maxBatchSize != null && maxBatchSize > 0) {
|
||||
SdkConfig.maxBatchSize = maxBatchSize;
|
||||
}
|
||||
if (isolateEncodingThreshold != null && isolateEncodingThreshold > 0) {
|
||||
SdkConfig.isolateEncodingThreshold = isolateEncodingThreshold;
|
||||
}
|
||||
if (uploadInterval != null) {
|
||||
SdkConfig.uploadInterval = uploadInterval;
|
||||
}
|
||||
if (pageLoadTimeout != null) {
|
||||
SdkConfig.pageLoadTimeout = pageLoadTimeout;
|
||||
}
|
||||
if (clickThrottleDuration != null) {
|
||||
SdkConfig.clickThrottleDuration = clickThrottleDuration;
|
||||
}
|
||||
if (connectionTimeout != null) {
|
||||
SdkConfig.connectionTimeout = connectionTimeout;
|
||||
}
|
||||
if (readTimeout != null) {
|
||||
SdkConfig.readTimeout = readTimeout;
|
||||
}
|
||||
if (speedTestTimeout != null) {
|
||||
SdkConfig.speedTestTimeout = speedTestTimeout;
|
||||
}
|
||||
if (configRequestTimeout != null) {
|
||||
SdkConfig.configRequestTimeout = configRequestTimeout;
|
||||
}
|
||||
if (maxBackoffLevel != null && maxBackoffLevel >= 0) {
|
||||
SdkConfig.maxBackoffLevel = maxBackoffLevel;
|
||||
}
|
||||
if (baseRetryDelay != null) {
|
||||
SdkConfig.baseRetryDelay = baseRetryDelay;
|
||||
}
|
||||
if (maxRetryDelay != null) {
|
||||
SdkConfig.maxRetryDelay = maxRetryDelay;
|
||||
}
|
||||
if (cacheFileName != null &&
|
||||
cacheFileName.isNotEmpty &&
|
||||
!cacheFileName.contains('/') &&
|
||||
!cacheFileName.contains('\\') &&
|
||||
!cacheFileName.contains('..')) {
|
||||
SdkConfig.cacheFileName = cacheFileName;
|
||||
}
|
||||
if (debugReportEventLogFileName != null &&
|
||||
debugReportEventLogFileName.isNotEmpty &&
|
||||
!debugReportEventLogFileName.contains('/') &&
|
||||
!debugReportEventLogFileName.contains('\\') &&
|
||||
!debugReportEventLogFileName.contains('..')) {
|
||||
SdkConfig.debugReportEventLogFileName = debugReportEventLogFileName;
|
||||
}
|
||||
if (enableDebugReportLog != null) {
|
||||
SdkConfig.enableDebugReportLog = enableDebugReportLog;
|
||||
}
|
||||
if (eventTypeConfigCacheFileName != null &&
|
||||
eventTypeConfigCacheFileName.isNotEmpty &&
|
||||
!eventTypeConfigCacheFileName.contains('/') &&
|
||||
!eventTypeConfigCacheFileName.contains('\\') &&
|
||||
!eventTypeConfigCacheFileName.contains('..')) {
|
||||
SdkConfig.eventTypeConfigCacheFileName = eventTypeConfigCacheFileName;
|
||||
}
|
||||
if (domainCacheFileName != null &&
|
||||
domainCacheFileName.isNotEmpty &&
|
||||
!domainCacheFileName.contains('/') &&
|
||||
!domainCacheFileName.contains('\\') &&
|
||||
!domainCacheFileName.contains('..')) {
|
||||
SdkConfig.domainCacheFileName = domainCacheFileName;
|
||||
}
|
||||
if (maxCacheLines != null && maxCacheLines > 0) {
|
||||
SdkConfig.maxCacheLines = maxCacheLines;
|
||||
}
|
||||
if (maxCacheBytes != null && maxCacheBytes > 0) {
|
||||
SdkConfig.maxCacheBytes = maxCacheBytes;
|
||||
}
|
||||
if (maxJsonSize != null && maxJsonSize > 0) {
|
||||
SdkConfig.maxJsonSize = maxJsonSize;
|
||||
}
|
||||
if (maxSingleEventSize != null && maxSingleEventSize > 0) {
|
||||
SdkConfig.maxSingleEventSize = maxSingleEventSize;
|
||||
}
|
||||
if (maxPageStackSize != null && maxPageStackSize > 0) {
|
||||
SdkConfig.maxPageStackSize = maxPageStackSize;
|
||||
}
|
||||
if (maxAdImpressionCapacity != null && maxAdImpressionCapacity > 0) {
|
||||
SdkConfig.maxAdImpressionCapacity = maxAdImpressionCapacity;
|
||||
}
|
||||
}
|
||||
|
||||
/// 重置所有配置为默认值
|
||||
static void reset() {
|
||||
maxQueueSize = _defaultMaxQueueSize;
|
||||
autoUploadThreshold = _defaultAutoUploadThreshold;
|
||||
maxBatchSize = _defaultMaxBatchSize;
|
||||
isolateEncodingThreshold = _defaultIsolateEncodingThreshold;
|
||||
uploadInterval = _defaultUploadInterval;
|
||||
pageLoadTimeout = _defaultPageLoadTimeout;
|
||||
clickThrottleDuration = _defaultClickThrottleDuration;
|
||||
connectionTimeout = _defaultConnectionTimeout;
|
||||
readTimeout = _defaultReadTimeout;
|
||||
speedTestTimeout = _defaultSpeedTestTimeout;
|
||||
configRequestTimeout = _defaultConfigRequestTimeout;
|
||||
maxBackoffLevel = _defaultMaxBackoffLevel;
|
||||
baseRetryDelay = _defaultBaseRetryDelay;
|
||||
maxRetryDelay = _defaultMaxRetryDelay;
|
||||
cacheFileName = _defaultCacheFileName;
|
||||
debugReportEventLogFileName = _defaultDebugReportEventLogFileName;
|
||||
enableDebugReportLog = _defaultEnableDebugReportLog;
|
||||
eventTypeConfigCacheFileName = _defaultEventTypeConfigCacheFileName;
|
||||
domainCacheFileName = _defaultDomainCacheFileName;
|
||||
maxCacheLines = _defaultMaxCacheLines;
|
||||
maxCacheBytes = _defaultMaxCacheBytes;
|
||||
tombstoneCompactionThreshold = _defaultTombstoneCompactionThreshold;
|
||||
tombstoneFileName = _defaultTombstoneFileName;
|
||||
cacheBufferThreshold = _defaultCacheBufferThreshold;
|
||||
cacheFlushInterval = _defaultCacheFlushInterval;
|
||||
maxJsonSize = _defaultMaxJsonSize;
|
||||
maxSingleEventSize = _defaultMaxSingleEventSize;
|
||||
maxConfigResponseSize = _defaultMaxConfigResponseSize;
|
||||
maxPageStackSize = _defaultMaxPageStackSize;
|
||||
maxAdImpressionCapacity = _defaultMaxAdImpressionCapacity;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
// 如果需要其他 UUID 生成,保留;但 eventId 现在固定,不再使用
|
||||
|
||||
/// 事件类型枚举:定义所有可能的事件类型
|
||||
/// 每个枚举值存储固定的 event 字符串
|
||||
/// 同时覆盖客户端、服务端上报的所有事件,方便统一管理
|
||||
enum EventType {
|
||||
// ==================== 安装 & 用户 ====================
|
||||
|
||||
/// App 安装事件(客户端)
|
||||
appInstall(event: 'app_install'),
|
||||
|
||||
/// SDK 初始化快照事件(客户端自动)
|
||||
sdkInit(event: 'sdk_init'),
|
||||
|
||||
/// 应用生命周期事件(客户端自动)
|
||||
appLifecycle(event: 'app_lifecycle'),
|
||||
|
||||
/// 网络状态变化事件(客户端自动)
|
||||
networkStateChange(event: 'network_state_change'),
|
||||
|
||||
/// 会话汇总事件(客户端自动)
|
||||
appSessionSummary(event: 'app_session_summary'),
|
||||
|
||||
/// 用户注册事件(服务端)
|
||||
userRegister(event: 'user_register'),
|
||||
|
||||
/// 用户登录事件(服务端)
|
||||
userLogin(event: 'user_login'),
|
||||
|
||||
/// 实时在线人数(服务端)
|
||||
realtimeOnline(event: 'realtime_online'),
|
||||
|
||||
// ==================== 订单 & 金币 ====================
|
||||
|
||||
/// 订单创建事件(服务端)
|
||||
orderCreated(event: 'order_created'),
|
||||
|
||||
/// 订单支付成功事件(服务端)
|
||||
orderPaid(event: 'order_paid'),
|
||||
|
||||
/// 金币消耗事件(服务端)
|
||||
coinConsume(event: 'coin_consume'),
|
||||
|
||||
/// 导航模块点击事件
|
||||
navigation(event: 'navigation'),
|
||||
|
||||
/// 应用页面展示
|
||||
appPageView(event: 'app_page_view'),
|
||||
|
||||
/// 应用页面点击
|
||||
pageClick(event: 'page_click'),
|
||||
|
||||
/// 页面内元素曝光事件(客户端)
|
||||
appPageItemExposure(event: 'app_pageitem_exposure'),
|
||||
|
||||
/// 页面内元素点击事件(客户端)
|
||||
appPageItemClick(event: 'app_pageitem_click'),
|
||||
|
||||
/// 广告事件
|
||||
advertising(event: 'advertising'),
|
||||
|
||||
/// 广告点击事件
|
||||
adClick(event: 'ad_click'),
|
||||
|
||||
/// 页面存活
|
||||
pageLifecycle(event: 'page_lifecycle'),
|
||||
|
||||
/// 视频事件
|
||||
videoEvent(event: 'video_event'),
|
||||
|
||||
/// 广告展示
|
||||
adImpression(event: 'ad_impression'),
|
||||
|
||||
// ==================== 广告 SDK 生命周期事件 ====================
|
||||
|
||||
/// 广告 SDK 初始化
|
||||
adSdkInit(event: 'ad_sdk_init'),
|
||||
|
||||
/// 广告请求
|
||||
adRequest(event: 'ad_request'),
|
||||
|
||||
/// 广告填充成功
|
||||
adFill(event: 'ad_fill'),
|
||||
|
||||
/// 广告无填充
|
||||
adNoFill(event: 'ad_no_fill'),
|
||||
|
||||
/// 广告素材加载成功
|
||||
adLoadSuccess(event: 'ad_load_success'),
|
||||
|
||||
/// 广告素材加载失败。
|
||||
@Deprecated('ad_load_fail 已并入 ad_error,使用 MATERIAL_LOAD_FAIL 错误码。')
|
||||
adLoadFail(event: 'ad_load_fail'),
|
||||
|
||||
/// 广告 SDK 曝光
|
||||
adImpressionSdk(event: 'ad_impression_sdk'),
|
||||
|
||||
/// 广告 SDK 点击
|
||||
adClickSdk(event: 'ad_click_sdk'),
|
||||
|
||||
/// 广告关闭
|
||||
adClose(event: 'ad_close'),
|
||||
|
||||
/// 广告跳过
|
||||
adSkip(event: 'ad_skip'),
|
||||
|
||||
/// 广告错误
|
||||
adError(event: 'ad_error'),
|
||||
|
||||
/// 视频点赞事件
|
||||
videoLike(event: 'video_like'),
|
||||
|
||||
/// 小说事件
|
||||
novelEvent(event: 'novel_event'),
|
||||
|
||||
/// 小说点赞事件
|
||||
novelLike(event: 'novel_like'),
|
||||
|
||||
/// 漫画事件
|
||||
comicEvent(event: 'comic_event'),
|
||||
|
||||
/// 漫画点赞事件
|
||||
comicLike(event: 'comic_like'),
|
||||
|
||||
/// 关键词搜索事件
|
||||
keywordSearch(event: 'keyword_search'),
|
||||
|
||||
/// 关键词搜索结果点击事件
|
||||
keywordClick(event: 'keyword_click'),
|
||||
|
||||
/// 小说收藏事件
|
||||
novelCollect(event: 'novel_collect'),
|
||||
|
||||
/// 漫画收藏事件
|
||||
comicCollect(event: 'comic_collect'),
|
||||
|
||||
// ==================== 推荐引擎相关 ====================
|
||||
|
||||
/// 推荐列表展示事件(服务端/客户端)
|
||||
recommendListView(event: 'recommend_list_view'),
|
||||
|
||||
/// 推荐列表点击事件(服务端/客户端)
|
||||
recommendListClick(event: 'recommend_list_click'),
|
||||
|
||||
// ==================== 视频相关扩展事件 ====================
|
||||
|
||||
/// 视频评论事件(服务端/客户端)
|
||||
videoComment(event: 'video_comment'),
|
||||
|
||||
/// 视频收藏事件(服务端/客户端)
|
||||
videoCollect(event: 'video_collect'),
|
||||
|
||||
/// 视频购买事件(服务端/客户端)
|
||||
videoPurchase(event: 'video_purchase'),
|
||||
|
||||
/// 视频状态更改事件(服务端)
|
||||
videoStatusChange(event: 'video_status_change'),
|
||||
|
||||
// ==================== 小说相关扩展事件 ====================
|
||||
|
||||
/// 小说评论事件(服务端/客户端)
|
||||
novelComment(event: 'novel_comment'),
|
||||
|
||||
/// 小说购买事件(服务端/客户端)
|
||||
novelPurchase(event: 'novel_purchase'),
|
||||
|
||||
// ==================== 漫画相关扩展事件 ====================
|
||||
|
||||
/// 漫画评论事件(服务端/客户端)
|
||||
comicComment(event: 'comic_comment'),
|
||||
|
||||
/// 漫画购买事件(服务端/客户端)
|
||||
comicPurchase(event: 'comic_purchase'),
|
||||
;
|
||||
|
||||
/// 事件类型字符串
|
||||
final String event;
|
||||
|
||||
const EventType({required this.event});
|
||||
|
||||
/// 静态方法:根据 event 字符串获取枚举值(如果存在)
|
||||
static EventType? fromEvent(String event) {
|
||||
try {
|
||||
return EventType.values.firstWhere((e) => e.event == event);
|
||||
} catch (_) {
|
||||
return null; // 或 throw ArgumentError('Unknown event: $event')
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import 'package:analytics_sdk/manager/page_name_manager.dart';
|
||||
import 'package:analytics_sdk/const/event_type.dart';
|
||||
import 'package:analytics_sdk/manager/sdk_context.dart';
|
||||
|
||||
/// 广告点击日志 (手动上报)
|
||||
class AdClickEvent {
|
||||
/// 事件类型(固定)
|
||||
final String event = EventType.adClick.event;
|
||||
|
||||
/// 去重 ID(动态计算)
|
||||
late final String eventId;
|
||||
|
||||
/// 页面唯一标识,建议使用 [AppPageEnum](如 `AppPageEnum.videoPlay.key`),
|
||||
/// 也可传入自定义字符串。
|
||||
final String pageKey;
|
||||
|
||||
/// 页面展示名称,建议与 [pageKey] 保持对应(如 `AppPageEnum.videoPlay.name`)。
|
||||
final String pageName;
|
||||
|
||||
/// 广告位标识:与展示事件一致,如 home_banner_1
|
||||
final String adSlotKey;
|
||||
|
||||
/// 被点击的广告ID
|
||||
final String adId;
|
||||
|
||||
/// 素材ID(可选)
|
||||
final String creativeId;
|
||||
|
||||
/// 广告类型字符串,由广告中心定义,如 banner, feed 等
|
||||
final String adType;
|
||||
|
||||
/// 公共字段(通过工具类生成)
|
||||
late final Map<String, dynamic> _commonFields;
|
||||
|
||||
AdClickEvent({
|
||||
required String pageKey,
|
||||
required this.pageName,
|
||||
required this.adSlotKey,
|
||||
required this.adId,
|
||||
this.creativeId = '',
|
||||
this.adType = '',
|
||||
}) : pageKey = PageNameMapper.normalizeKey(pageKey) {
|
||||
_commonFields = SdkContext.generateCommonFields(event);
|
||||
|
||||
eventId = SdkContext.generateEventIdFromCommonFields(_commonFields, [
|
||||
pageKey,
|
||||
pageName,
|
||||
adSlotKey,
|
||||
adId,
|
||||
adType,
|
||||
creativeId,
|
||||
]);
|
||||
}
|
||||
|
||||
/// 转换为 JSON Map,用于上报和缓存
|
||||
Map<String, dynamic> toJson() {
|
||||
Map<String, dynamic> payload = {
|
||||
'page_key': pageKey,
|
||||
'page_name': pageName,
|
||||
'ad_slot_key': adSlotKey,
|
||||
'ad_id': adId,
|
||||
'ad_type': adType,
|
||||
'creative_id': creativeId,
|
||||
};
|
||||
return {..._commonFields, 'event_id': eventId, "payload": payload};
|
||||
}
|
||||
|
||||
/// 从 JSON 恢复实例(用于缓存恢复)
|
||||
factory AdClickEvent.fromJson(Map<String, dynamic> json) {
|
||||
return AdClickEvent(
|
||||
pageKey: json['page_key'] as String,
|
||||
pageName: json['page_name'] as String,
|
||||
adSlotKey: json['ad_slot_key'] as String,
|
||||
adId: json['ad_id'] as String,
|
||||
creativeId: json['creative_id'] as String? ?? '',
|
||||
adType: json['ad_type'] as String? ?? '',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import 'package:analytics_sdk/manager/page_name_manager.dart';
|
||||
import 'package:analytics_sdk/const/event_type.dart';
|
||||
import 'package:analytics_sdk/manager/sdk_context.dart';
|
||||
|
||||
/// 广告展示日志 (手动)
|
||||
/// 展示上报逻辑:
|
||||
/// 1. 进入 APP → TabA
|
||||
/// - 20 条曝光 → 上报 20 条(批量)
|
||||
/// 2. 切换到TabB
|
||||
/// - 20 条曝光 → 上报 20 条
|
||||
/// 3. 返回TabA → 上滑加载 10 条
|
||||
/// - 新 10 条渲染出来 → 上报 10 条
|
||||
/// 4. 用户再次上下滑动TabA
|
||||
/// - 所有广告已曝光,无需再上报
|
||||
|
||||
class AdImpressionEvent {
|
||||
final String event = EventType.adImpression.event;
|
||||
late final String eventId;
|
||||
|
||||
/// 页面唯一标识,建议使用 [AppPageEnum](如 `AppPageEnum.home.key`),
|
||||
/// 也可传入自定义字符串。
|
||||
final String pageKey;
|
||||
|
||||
/// 页面展示名称,建议与 [pageKey] 保持对应(如 `AppPageEnum.home.name`)。
|
||||
final String pageName;
|
||||
|
||||
/// 广告位标识:如 home_banner_1等 对应广告导入数据的advertiseLocationCode
|
||||
final String adSlotKey;
|
||||
|
||||
/// 本次广告请求唯一 ID。
|
||||
final String requestId;
|
||||
|
||||
/// 广告ID, 多个广告ID英文逗号分隔 对应广告导入数据的advertiseCode
|
||||
final String adId;
|
||||
|
||||
/// 实际素材尺寸,格式如 "1080x1920"。
|
||||
final String materialSize;
|
||||
|
||||
/// 素材ID(可选)多个素材ID英文逗号分隔,和广告ID一一对应,没有的填充空字符串
|
||||
final String creativeId;
|
||||
|
||||
/// 广告类型字符串,如 banner, feed, interstitial, reward_video 等,由广告中心定义
|
||||
final String adType;
|
||||
|
||||
late final Map<String, dynamic> _commonFields;
|
||||
|
||||
AdImpressionEvent({
|
||||
required String pageKey,
|
||||
required this.pageName,
|
||||
required this.adSlotKey,
|
||||
this.requestId = '',
|
||||
required this.adId,
|
||||
this.materialSize = '',
|
||||
this.creativeId = '',
|
||||
this.adType = '',
|
||||
}) : pageKey = PageNameMapper.normalizeKey(pageKey) {
|
||||
_commonFields = SdkContext.generateCommonFields(event);
|
||||
|
||||
eventId = SdkContext.generateEventIdFromCommonFields(_commonFields, [
|
||||
pageKey,
|
||||
pageName,
|
||||
adSlotKey,
|
||||
requestId,
|
||||
adId,
|
||||
adType,
|
||||
materialSize,
|
||||
creativeId,
|
||||
]);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
Map<String, dynamic> payload = {
|
||||
'page_key': pageKey,
|
||||
'page_name': pageName,
|
||||
'request_id': requestId,
|
||||
'ad_slot_key': adSlotKey,
|
||||
'ad_id': adId,
|
||||
'ad_type': adType,
|
||||
'material_size': materialSize,
|
||||
'creative_id': creativeId,
|
||||
};
|
||||
return {..._commonFields, 'event_id': eventId, "payload": payload};
|
||||
}
|
||||
|
||||
factory AdImpressionEvent.fromJson(Map<String, dynamic> json) {
|
||||
return AdImpressionEvent(
|
||||
pageKey: json['page_key'] as String,
|
||||
pageName: json['page_name'] as String,
|
||||
adSlotKey: json['ad_slot_key'] as String,
|
||||
requestId: json['request_id'] as String? ?? '',
|
||||
adId: json['ad_id'] as String,
|
||||
materialSize: json['material_size'] as String? ?? '',
|
||||
creativeId: json['creative_id'] as String? ?? '',
|
||||
adType: json['ad_type'] as String? ?? '',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,459 @@
|
||||
import 'package:analytics_sdk/const/event_type.dart';
|
||||
import 'package:analytics_sdk/manager/sdk_context.dart';
|
||||
|
||||
abstract class _AdSdkEvent {
|
||||
final String event;
|
||||
|
||||
late final String eventId;
|
||||
late final Map<String, dynamic> _commonFields;
|
||||
|
||||
_AdSdkEvent(this.event, List<String> eventIdFields) {
|
||||
_commonFields = SdkContext.generateCommonFields(event);
|
||||
eventId = SdkContext.generateEventIdFromCommonFields(
|
||||
_commonFields,
|
||||
eventIdFields,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> buildPayload();
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
..._commonFields,
|
||||
'event_id': eventId,
|
||||
'payload': buildPayload(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// 广告 SDK 初始化事件。
|
||||
class AdSdkInitEvent extends _AdSdkEvent {
|
||||
final int screenWidth;
|
||||
final int screenHeight;
|
||||
final int screenDpi;
|
||||
final String orientation;
|
||||
final String networkType;
|
||||
final String carrier;
|
||||
final String language;
|
||||
final String timezone;
|
||||
final String oaid;
|
||||
final String androidId;
|
||||
final String idfv;
|
||||
final String idfa;
|
||||
final bool lat;
|
||||
final String adSdkVersion;
|
||||
final int availableStorage;
|
||||
final int batteryLevel;
|
||||
final String cpu;
|
||||
final int storage;
|
||||
final String activationTime;
|
||||
|
||||
AdSdkInitEvent({
|
||||
this.screenWidth = 0,
|
||||
this.screenHeight = 0,
|
||||
this.screenDpi = 0,
|
||||
this.orientation = '',
|
||||
this.networkType = '',
|
||||
this.carrier = '',
|
||||
this.language = '',
|
||||
this.timezone = '',
|
||||
this.oaid = '',
|
||||
this.androidId = '',
|
||||
this.idfv = '',
|
||||
this.idfa = '',
|
||||
this.lat = false,
|
||||
required this.adSdkVersion,
|
||||
this.availableStorage = 0,
|
||||
this.batteryLevel = 0,
|
||||
this.cpu = '',
|
||||
this.storage = 0,
|
||||
this.activationTime = '',
|
||||
}) : super(EventType.adSdkInit.event, [
|
||||
screenWidth.toString(),
|
||||
screenHeight.toString(),
|
||||
screenDpi.toString(),
|
||||
orientation,
|
||||
networkType,
|
||||
carrier,
|
||||
language,
|
||||
timezone,
|
||||
oaid,
|
||||
androidId,
|
||||
idfv,
|
||||
idfa,
|
||||
lat.toString(),
|
||||
adSdkVersion,
|
||||
availableStorage.toString(),
|
||||
batteryLevel.toString(),
|
||||
cpu,
|
||||
storage.toString(),
|
||||
activationTime,
|
||||
]);
|
||||
|
||||
@override
|
||||
Map<String, dynamic> buildPayload() => {
|
||||
'screen_width': screenWidth,
|
||||
'screen_height': screenHeight,
|
||||
'screen_dpi': screenDpi,
|
||||
'orientation': orientation,
|
||||
'network_type': networkType,
|
||||
'carrier': carrier,
|
||||
'language': language,
|
||||
'timezone': timezone,
|
||||
'oaid': oaid,
|
||||
'android_id': androidId,
|
||||
'idfv': idfv,
|
||||
'idfa': idfa,
|
||||
'lat': lat,
|
||||
'ad_sdk_version': adSdkVersion,
|
||||
'available_storage': availableStorage,
|
||||
'battery_level': batteryLevel,
|
||||
'cpu': cpu,
|
||||
'storage': storage,
|
||||
'activation_time': activationTime,
|
||||
};
|
||||
}
|
||||
|
||||
/// 广告请求事件。
|
||||
class AdRequestEvent extends _AdSdkEvent {
|
||||
final String requestId;
|
||||
final String adSlotKey;
|
||||
final String adType;
|
||||
final String? adId;
|
||||
|
||||
AdRequestEvent({
|
||||
required this.requestId,
|
||||
required this.adSlotKey,
|
||||
this.adType = '',
|
||||
this.adId,
|
||||
}) : super(EventType.adRequest.event, [
|
||||
requestId,
|
||||
adSlotKey,
|
||||
adType,
|
||||
adId ?? '',
|
||||
]);
|
||||
|
||||
@override
|
||||
Map<String, dynamic> buildPayload() => {
|
||||
'request_id': requestId,
|
||||
'ad_slot_key': adSlotKey,
|
||||
'ad_type': adType,
|
||||
if (adId?.trim().isNotEmpty ?? false) 'ad_id': adId!.trim(),
|
||||
};
|
||||
}
|
||||
|
||||
/// 广告填充成功事件。
|
||||
class AdFillEvent extends _AdSdkEvent {
|
||||
final String traceId;
|
||||
final String requestId;
|
||||
final String adSlotKey;
|
||||
final String adId;
|
||||
final String adType;
|
||||
final int fillCount;
|
||||
|
||||
AdFillEvent({
|
||||
this.traceId = '',
|
||||
required this.requestId,
|
||||
required this.adSlotKey,
|
||||
required this.adId,
|
||||
required this.adType,
|
||||
required this.fillCount,
|
||||
}) : super(EventType.adFill.event, [
|
||||
if (traceId.isNotEmpty) traceId,
|
||||
requestId,
|
||||
adSlotKey,
|
||||
adId,
|
||||
adType,
|
||||
fillCount.toString(),
|
||||
]);
|
||||
|
||||
@override
|
||||
Map<String, dynamic> buildPayload() => {
|
||||
'trace_id': traceId,
|
||||
'request_id': requestId,
|
||||
'ad_slot_key': adSlotKey,
|
||||
'ad_id': adId,
|
||||
'ad_type': adType,
|
||||
'fill_count': fillCount,
|
||||
};
|
||||
}
|
||||
|
||||
/// 广告无填充事件。
|
||||
class AdNoFillEvent extends _AdSdkEvent {
|
||||
final String requestId;
|
||||
final String adSlotKey;
|
||||
final String adType;
|
||||
final String reason;
|
||||
|
||||
AdNoFillEvent({
|
||||
required this.requestId,
|
||||
required this.adSlotKey,
|
||||
required this.adType,
|
||||
required this.reason,
|
||||
}) : super(EventType.adNoFill.event, [requestId, adSlotKey, adType, reason]);
|
||||
|
||||
@override
|
||||
Map<String, dynamic> buildPayload() => {
|
||||
'request_id': requestId,
|
||||
'ad_slot_key': adSlotKey,
|
||||
'ad_type': adType,
|
||||
'reason': reason,
|
||||
};
|
||||
}
|
||||
|
||||
/// 广告素材加载成功事件。
|
||||
class AdLoadSuccessEvent extends _AdSdkEvent {
|
||||
final String traceId;
|
||||
final String requestId;
|
||||
final String adSlotKey;
|
||||
final String adId;
|
||||
final String adType;
|
||||
final int loadDuration;
|
||||
|
||||
AdLoadSuccessEvent({
|
||||
this.traceId = '',
|
||||
required this.requestId,
|
||||
required this.adSlotKey,
|
||||
required this.adId,
|
||||
required this.adType,
|
||||
required this.loadDuration,
|
||||
}) : super(EventType.adLoadSuccess.event, [
|
||||
if (traceId.isNotEmpty) traceId,
|
||||
requestId,
|
||||
adSlotKey,
|
||||
adId,
|
||||
adType,
|
||||
loadDuration.toString(),
|
||||
]);
|
||||
|
||||
@override
|
||||
Map<String, dynamic> buildPayload() => {
|
||||
'trace_id': traceId,
|
||||
'request_id': requestId,
|
||||
'ad_slot_key': adSlotKey,
|
||||
'ad_id': adId,
|
||||
'ad_type': adType,
|
||||
'load_duration': loadDuration,
|
||||
};
|
||||
}
|
||||
|
||||
/// 广告素材加载失败事件。
|
||||
@Deprecated('ad_load_fail 已并入 ad_error,使用 MATERIAL_LOAD_FAIL 错误码。')
|
||||
class AdLoadFailEvent extends _AdSdkEvent {
|
||||
final String traceId;
|
||||
final String requestId;
|
||||
final String adSlotKey;
|
||||
final String adId;
|
||||
final String adType;
|
||||
final String errorCode;
|
||||
|
||||
AdLoadFailEvent({
|
||||
this.traceId = '',
|
||||
required this.requestId,
|
||||
required this.adSlotKey,
|
||||
required this.adId,
|
||||
required this.adType,
|
||||
required this.errorCode,
|
||||
}) : super(EventType.adLoadFail.event, [
|
||||
if (traceId.isNotEmpty) traceId,
|
||||
requestId,
|
||||
adSlotKey,
|
||||
adId,
|
||||
adType,
|
||||
errorCode,
|
||||
]);
|
||||
|
||||
@override
|
||||
Map<String, dynamic> buildPayload() => {
|
||||
'trace_id': traceId,
|
||||
'request_id': requestId,
|
||||
'ad_slot_key': adSlotKey,
|
||||
'ad_id': adId,
|
||||
'ad_type': adType,
|
||||
'error_code': errorCode,
|
||||
};
|
||||
}
|
||||
|
||||
/// 广告 SDK 曝光事件。
|
||||
class AdImpressionSdkEvent extends _AdSdkEvent {
|
||||
final String traceId;
|
||||
final String requestId;
|
||||
final String adSlotKey;
|
||||
final String adId;
|
||||
final String adType;
|
||||
final String materialSize;
|
||||
|
||||
AdImpressionSdkEvent({
|
||||
this.traceId = '',
|
||||
required this.requestId,
|
||||
required this.adSlotKey,
|
||||
required this.adId,
|
||||
required this.adType,
|
||||
required this.materialSize,
|
||||
}) : super(EventType.adImpressionSdk.event, [
|
||||
traceId,
|
||||
requestId,
|
||||
adSlotKey,
|
||||
adId,
|
||||
adType,
|
||||
materialSize,
|
||||
]);
|
||||
|
||||
@override
|
||||
Map<String, dynamic> buildPayload() => {
|
||||
'trace_id': traceId,
|
||||
'request_id': requestId,
|
||||
'ad_slot_key': adSlotKey,
|
||||
'ad_id': adId,
|
||||
'ad_type': adType,
|
||||
'material_size': materialSize,
|
||||
};
|
||||
}
|
||||
|
||||
/// 广告 SDK 点击事件。
|
||||
class AdClickSdkEvent extends _AdSdkEvent {
|
||||
final String traceId;
|
||||
final String requestId;
|
||||
final String adSlotKey;
|
||||
final String adId;
|
||||
final String adType;
|
||||
|
||||
AdClickSdkEvent({
|
||||
this.traceId = '',
|
||||
required this.requestId,
|
||||
required this.adSlotKey,
|
||||
required this.adId,
|
||||
required this.adType,
|
||||
}) : super(EventType.adClickSdk.event, [
|
||||
traceId,
|
||||
requestId,
|
||||
adSlotKey,
|
||||
adId,
|
||||
adType,
|
||||
]);
|
||||
|
||||
@override
|
||||
Map<String, dynamic> buildPayload() => {
|
||||
'trace_id': traceId,
|
||||
'request_id': requestId,
|
||||
'ad_slot_key': adSlotKey,
|
||||
'ad_id': adId,
|
||||
'ad_type': adType,
|
||||
};
|
||||
}
|
||||
|
||||
/// 广告关闭事件。
|
||||
class AdCloseEvent extends _AdSdkEvent {
|
||||
final String traceId;
|
||||
final String requestId;
|
||||
final String adSlotKey;
|
||||
final String adId;
|
||||
final String adType;
|
||||
final String closeType;
|
||||
final int shownDuration;
|
||||
|
||||
AdCloseEvent({
|
||||
this.traceId = '',
|
||||
required this.requestId,
|
||||
required this.adSlotKey,
|
||||
required this.adId,
|
||||
required this.adType,
|
||||
required this.closeType,
|
||||
this.shownDuration = 0,
|
||||
}) : super(EventType.adClose.event, [
|
||||
if (traceId.isNotEmpty) traceId,
|
||||
requestId,
|
||||
adSlotKey,
|
||||
adId,
|
||||
adType,
|
||||
closeType,
|
||||
shownDuration.toString(),
|
||||
]);
|
||||
|
||||
@override
|
||||
Map<String, dynamic> buildPayload() => {
|
||||
'trace_id': traceId,
|
||||
'request_id': requestId,
|
||||
'ad_slot_key': adSlotKey,
|
||||
'ad_id': adId,
|
||||
'ad_type': adType,
|
||||
'close_type': closeType,
|
||||
'shown_duration': shownDuration,
|
||||
};
|
||||
}
|
||||
|
||||
/// 广告跳过事件。
|
||||
class AdSkipEvent extends _AdSdkEvent {
|
||||
final String traceId;
|
||||
final String requestId;
|
||||
final String adSlotKey;
|
||||
final String adId;
|
||||
final String adType;
|
||||
final int shownDuration;
|
||||
|
||||
AdSkipEvent({
|
||||
this.traceId = '',
|
||||
required this.requestId,
|
||||
required this.adSlotKey,
|
||||
required this.adId,
|
||||
required this.adType,
|
||||
this.shownDuration = 0,
|
||||
}) : super(EventType.adSkip.event, [
|
||||
if (traceId.isNotEmpty) traceId,
|
||||
requestId,
|
||||
adSlotKey,
|
||||
adId,
|
||||
adType,
|
||||
shownDuration.toString(),
|
||||
]);
|
||||
|
||||
@override
|
||||
Map<String, dynamic> buildPayload() => {
|
||||
'trace_id': traceId,
|
||||
'request_id': requestId,
|
||||
'ad_slot_key': adSlotKey,
|
||||
'ad_id': adId,
|
||||
'ad_type': adType,
|
||||
'shown_duration': shownDuration,
|
||||
};
|
||||
}
|
||||
|
||||
/// 广告错误事件。
|
||||
class AdErrorEvent extends _AdSdkEvent {
|
||||
final String traceId;
|
||||
final String requestId;
|
||||
final String adSlotKey;
|
||||
final String adType;
|
||||
final String errorCode;
|
||||
final String errorMsg;
|
||||
final String errorStage;
|
||||
|
||||
AdErrorEvent({
|
||||
this.traceId = '',
|
||||
this.requestId = '',
|
||||
required this.adSlotKey,
|
||||
required this.adType,
|
||||
required this.errorCode,
|
||||
required this.errorMsg,
|
||||
required this.errorStage,
|
||||
}) : super(EventType.adError.event, [
|
||||
if (traceId.isNotEmpty) traceId,
|
||||
requestId,
|
||||
adSlotKey,
|
||||
adType,
|
||||
errorCode,
|
||||
errorMsg,
|
||||
errorStage,
|
||||
]);
|
||||
|
||||
@override
|
||||
Map<String, dynamic> buildPayload() => {
|
||||
'trace_id': traceId,
|
||||
'request_id': requestId,
|
||||
'ad_slot_key': adSlotKey,
|
||||
'ad_type': adType,
|
||||
'error_code': errorCode,
|
||||
'error_msg': errorMsg,
|
||||
'error_stage': errorStage,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import 'package:analytics_sdk/const/event_type.dart';
|
||||
import 'package:analytics_sdk/manager/sdk_context.dart';
|
||||
|
||||
/// APP广告行为
|
||||
class AdvertisingEvent {
|
||||
final String event = EventType.advertising.event;
|
||||
late final String eventId;
|
||||
|
||||
///事件类型:click(点击), close(关闭), show(展示)
|
||||
final String eventType;
|
||||
|
||||
/// 广告标识:home_popup(首页弹窗), home_banner(首页Banner), video_reward(激励视频)等
|
||||
final String advertisingKey;
|
||||
|
||||
/// 广告标识名称:首页弹窗, 首页Banner, 激励视频
|
||||
final String advertisingName;
|
||||
|
||||
/// 广告ID
|
||||
final String advertisingId;
|
||||
|
||||
late final Map<String, dynamic> _commonFields;
|
||||
|
||||
AdvertisingEvent({
|
||||
required this.eventType,
|
||||
required this.advertisingKey,
|
||||
required this.advertisingName,
|
||||
required this.advertisingId,
|
||||
}) {
|
||||
_commonFields = SdkContext.generateCommonFields(event);
|
||||
|
||||
eventId = SdkContext.generateEventIdFromCommonFields(_commonFields, [
|
||||
eventType,
|
||||
advertisingKey,
|
||||
advertisingName,
|
||||
advertisingId,
|
||||
]);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
Map<String, dynamic> payload = {
|
||||
'event_type': eventType,
|
||||
'advertising_key': advertisingKey,
|
||||
'advertising_name': advertisingName,
|
||||
'advertising_id': advertisingId,
|
||||
};
|
||||
return {..._commonFields, 'event_id': eventId, "payload": payload};
|
||||
}
|
||||
|
||||
factory AdvertisingEvent.fromJson(Map<String, dynamic> json) {
|
||||
return AdvertisingEvent(
|
||||
eventType: json['event_type'] as String,
|
||||
advertisingKey: json['advertising_key'] as String,
|
||||
advertisingName: json['advertising_name'] as String,
|
||||
advertisingId: json['advertising_id'] as String,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
/// Tab 页面的 analytics 配置。
|
||||
///
|
||||
/// - [key]:pageKey(必填),建议使用 [AppPageEnum](如 `AppPageEnum.home.key`),
|
||||
/// 也可传入自定义字符串。
|
||||
/// - [name]:pageName(可选),省略时由 [PageNameMapper] 自动解析;
|
||||
/// 传入时建议与 [AppPageEnum] 保持一致(如 `AppPageEnum.home.name`)。
|
||||
///
|
||||
/// ```dart
|
||||
/// // 使用枚举
|
||||
/// AnalyticsTab(AppPageEnum.home.key, AppPageEnum.home.name)
|
||||
/// AnalyticsTab(AppPageEnum.video.key, AppPageEnum.video.name)
|
||||
///
|
||||
/// // 使用自定义值
|
||||
/// AnalyticsTab('live_room', '直播间')
|
||||
///
|
||||
/// // 省略 name(由 PageNameMapper 解析)
|
||||
/// AnalyticsTab('home')
|
||||
/// ```
|
||||
class AnalyticsTab {
|
||||
final String key;
|
||||
final String? name;
|
||||
|
||||
const AnalyticsTab(this.key, [this.name]);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import 'package:analytics_sdk/manager/sdk_context.dart';
|
||||
export 'package:analytics_sdk/manager/sdk_context.dart';
|
||||
|
||||
// SdkContext 的向后兼容别名,内部使用。
|
||||
typedef AnalyticsUtils = SdkContext;
|
||||
@@ -0,0 +1,46 @@
|
||||
import 'package:analytics_sdk/const/event_type.dart';
|
||||
import 'package:analytics_sdk/manager/sdk_context.dart';
|
||||
|
||||
/// App 安装事件(客户端)
|
||||
class AppInstallEvent {
|
||||
/// 事件类型(固定)
|
||||
final String event = EventType.appInstall.event;
|
||||
|
||||
/// 去重 ID(动态计算)
|
||||
late final String eventId;
|
||||
|
||||
/// 落地页点击生成唯一ID, 复制到剪切板, 打开APP后读取剪切板
|
||||
final String traceId;
|
||||
|
||||
/// 原生包是否开启归因:1 开启,0 关闭(默认)
|
||||
final int attributionFlag;
|
||||
|
||||
late final Map<String, dynamic> _commonFields;
|
||||
|
||||
AppInstallEvent({
|
||||
required this.traceId,
|
||||
this.attributionFlag = 0,
|
||||
}) {
|
||||
_commonFields = SdkContext.generateCommonFields(event);
|
||||
|
||||
eventId = SdkContext.generateEventIdFromCommonFields(_commonFields, [
|
||||
traceId,
|
||||
attributionFlag.toString(),
|
||||
]);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final payload = <String, dynamic>{
|
||||
'trace_id': traceId,
|
||||
'attribution_flag': attributionFlag,
|
||||
};
|
||||
return {..._commonFields, 'event_id': eventId, 'payload': payload};
|
||||
}
|
||||
|
||||
factory AppInstallEvent.fromJson(Map<String, dynamic> json) {
|
||||
return AppInstallEvent(
|
||||
traceId: json['trace_id'] as String,
|
||||
attributionFlag: (json['attribution_flag'] as int?) ?? 0,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:analytics_sdk/const/event_type.dart';
|
||||
import 'package:analytics_sdk/manager/sdk_context.dart';
|
||||
|
||||
/// 应用生命周期事件。
|
||||
///
|
||||
/// `app_lifecycle` 的子事件字段写入 `payload`。每个工厂方法只写入该
|
||||
/// 子事件文档定义的字段。
|
||||
class AppLifecycleEvent {
|
||||
final String event = EventType.appLifecycle.event;
|
||||
final String lifecycleEventKey;
|
||||
final Map<String, dynamic> _fields;
|
||||
|
||||
late final String eventId;
|
||||
late final Map<String, dynamic> _commonFields;
|
||||
|
||||
AppLifecycleEvent._(
|
||||
this.lifecycleEventKey,
|
||||
Map<String, dynamic> fields,
|
||||
) : _fields = Map<String, dynamic>.from(fields)
|
||||
..removeWhere((_, value) => value == null) {
|
||||
_commonFields = SdkContext.generateCommonFields(event);
|
||||
eventId = SdkContext.generateEventIdFromCommonFields(_commonFields, [
|
||||
lifecycleEventKey,
|
||||
jsonEncode(_stableFields),
|
||||
]);
|
||||
}
|
||||
|
||||
factory AppLifecycleEvent.coldStart({
|
||||
required int coldStartDurationMs,
|
||||
String lastExitType = 'unknown',
|
||||
int bootTime = 0,
|
||||
}) =>
|
||||
AppLifecycleEvent._('cold_start', {
|
||||
'cold_start_duration_ms': _nonNegative(coldStartDurationMs),
|
||||
'last_exit_type': _allowedString(
|
||||
lastExitType,
|
||||
const {'normal', 'killed', 'crash', 'unknown'},
|
||||
'unknown',
|
||||
),
|
||||
'boot_time': bootTime > 0 ? bootTime : 0,
|
||||
});
|
||||
|
||||
factory AppLifecycleEvent.webLoad({
|
||||
required String pageUrl,
|
||||
required int pageLoadTimeMs,
|
||||
required int viewportWidth,
|
||||
required int viewportHeight,
|
||||
}) =>
|
||||
AppLifecycleEvent._('web_load', {
|
||||
'page_url': pageUrl,
|
||||
'page_load_time_ms': _nonNegative(pageLoadTimeMs),
|
||||
'viewport_width': viewportWidth,
|
||||
'viewport_height': viewportHeight,
|
||||
});
|
||||
|
||||
factory AppLifecycleEvent.warmStart({
|
||||
required int backgroundDurationMs,
|
||||
String resumeReason = 'unknown',
|
||||
}) =>
|
||||
AppLifecycleEvent._('warm_start', {
|
||||
'background_duration_ms': _nonNegative(backgroundDurationMs),
|
||||
'resume_reason': _allowedString(
|
||||
resumeReason,
|
||||
const {'user', 'push', 'scheme', 'unknown'},
|
||||
'unknown',
|
||||
),
|
||||
});
|
||||
|
||||
factory AppLifecycleEvent.foreground({
|
||||
required int foregroundCountInSid,
|
||||
}) =>
|
||||
AppLifecycleEvent._('foreground', {
|
||||
'foreground_count_in_sid':
|
||||
foregroundCountInSid < 1 ? 1 : foregroundCountInSid,
|
||||
'is_foreground': 1,
|
||||
});
|
||||
|
||||
factory AppLifecycleEvent.background({
|
||||
required int foregroundDurationMs,
|
||||
required String lastActivePageKey,
|
||||
}) =>
|
||||
AppLifecycleEvent._('background', {
|
||||
'is_foreground': 0,
|
||||
'foreground_duration_ms': _nonNegative(foregroundDurationMs),
|
||||
'last_active_page_key': lastActivePageKey,
|
||||
});
|
||||
|
||||
factory AppLifecycleEvent.webUnload({
|
||||
required String pageUrl,
|
||||
required String lastActivePageKey,
|
||||
required int pageStayDurationMs,
|
||||
String unloadReason = 'unknown',
|
||||
required int scrollDepthPercentAtUnload,
|
||||
required String webVisibilityState,
|
||||
}) =>
|
||||
AppLifecycleEvent._('web_unload', {
|
||||
'page_url': pageUrl,
|
||||
'last_active_page_key': lastActivePageKey,
|
||||
'page_stay_duration_ms': _nonNegative(pageStayDurationMs),
|
||||
'unload_reason': _allowedString(
|
||||
unloadReason,
|
||||
const {
|
||||
'normal',
|
||||
'visibility_hidden',
|
||||
'navigation',
|
||||
'tab_close',
|
||||
'unknown',
|
||||
},
|
||||
'unknown',
|
||||
),
|
||||
'scroll_depth_percent_at_unload':
|
||||
scrollDepthPercentAtUnload.clamp(0, 100),
|
||||
'web_visibility_state': _allowedString(
|
||||
webVisibilityState,
|
||||
const {'visible', 'hidden', 'unknown'},
|
||||
'unknown',
|
||||
),
|
||||
});
|
||||
|
||||
factory AppLifecycleEvent.terminate({
|
||||
required String lastActivePageKey,
|
||||
String terminateType = 'unknown',
|
||||
required int sessionDurationMs,
|
||||
required int terminateTs,
|
||||
}) =>
|
||||
AppLifecycleEvent._('terminate', {
|
||||
'last_active_page_key': lastActivePageKey,
|
||||
'terminate_type': _allowedString(
|
||||
terminateType,
|
||||
const {'user_quit', 'system_kill', 'app_finish', 'unknown'},
|
||||
'unknown',
|
||||
),
|
||||
'session_duration_ms': _nonNegative(sessionDurationMs),
|
||||
'terminate_ts': terminateTs > 0
|
||||
? terminateTs
|
||||
: DateTime.now().millisecondsSinceEpoch ~/ 1000,
|
||||
});
|
||||
|
||||
factory AppLifecycleEvent.unknown({
|
||||
String unknownReason = 'unknown',
|
||||
}) =>
|
||||
AppLifecycleEvent._('unknown', {
|
||||
'unknown_reason': _allowedString(
|
||||
unknownReason,
|
||||
const {
|
||||
'callback_missing',
|
||||
'state_conflict',
|
||||
'unsupported_platform',
|
||||
'sdk_not_ready',
|
||||
'system_restricted',
|
||||
'route_unrecognized',
|
||||
'collect_error',
|
||||
'unknown',
|
||||
},
|
||||
'unknown',
|
||||
),
|
||||
});
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
..._commonFields,
|
||||
'event_id': eventId,
|
||||
'payload': {
|
||||
'lifecycle_event_key': lifecycleEventKey,
|
||||
..._fields,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
Map<String, dynamic> get _stableFields {
|
||||
final sortedKeys = _fields.keys.toList()..sort();
|
||||
return {
|
||||
for (final key in sortedKeys) key: _fields[key],
|
||||
};
|
||||
}
|
||||
|
||||
static int _nonNegative(int value) => value < 0 ? 0 : value;
|
||||
|
||||
static String _allowedString(
|
||||
String value,
|
||||
Set<String> allowed,
|
||||
String fallback,
|
||||
) {
|
||||
final normalized = value.trim();
|
||||
return allowed.contains(normalized) ? normalized : fallback;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
import 'package:analytics_sdk/const/event_type.dart';
|
||||
import 'package:analytics_sdk/manager/page_name_manager.dart';
|
||||
import 'package:analytics_sdk/manager/sdk_context.dart';
|
||||
|
||||
/// 页面内元素曝光事件(客户端手动上报)。
|
||||
///
|
||||
/// 仅限需要采集页面内元素曝光的项目接入。未对接推荐引擎时,推荐相关字段传空字符串。
|
||||
class AppPageItemExposureEvent {
|
||||
final String event = EventType.appPageItemExposure.event;
|
||||
late final String eventId;
|
||||
|
||||
/// 元素唯一 ID。
|
||||
final String itemId;
|
||||
|
||||
/// 元素类型,如推荐、关注、评论、小说、视频、漫画等业务字典值。
|
||||
final String itemType;
|
||||
|
||||
/// 元素名称,如推荐、关注、评论、小说、视频、漫画等业务字典值。
|
||||
final String itemName;
|
||||
|
||||
/// 父元素 ID。
|
||||
final String parentItemId;
|
||||
|
||||
/// 当前页面标识,建议使用 [AppPageEnum] 或业务字典中的稳定 key。
|
||||
final String pageKey;
|
||||
|
||||
/// 当前页面名称。
|
||||
final String pageName;
|
||||
|
||||
/// 来路页面标识。
|
||||
final String referrerPageKey;
|
||||
|
||||
/// 来路页面名称。
|
||||
final String referrerPageName;
|
||||
|
||||
/// 推荐引擎 trace_id;多个推荐列表用英文逗号分隔。
|
||||
final String recommendTraceId;
|
||||
|
||||
/// 推荐内容 ID;未对接推荐引擎传空字符串。
|
||||
final String recommendId;
|
||||
|
||||
/// 推荐引擎 trace_info;未对接推荐引擎传空字符串。
|
||||
final String recommendTraceInfo;
|
||||
|
||||
late final Map<String, dynamic> _commonFields;
|
||||
|
||||
AppPageItemExposureEvent({
|
||||
required this.itemId,
|
||||
required this.itemType,
|
||||
required this.itemName,
|
||||
required this.parentItemId,
|
||||
required String pageKey,
|
||||
required this.pageName,
|
||||
required String referrerPageKey,
|
||||
required this.referrerPageName,
|
||||
this.recommendTraceId = '',
|
||||
this.recommendId = '',
|
||||
this.recommendTraceInfo = '',
|
||||
}) : pageKey = PageNameMapper.normalizeKey(pageKey),
|
||||
referrerPageKey = PageNameMapper.normalizeKey(referrerPageKey) {
|
||||
_commonFields = SdkContext.generateCommonFields(event);
|
||||
|
||||
eventId = SdkContext.generateEventIdFromCommonFields(_commonFields, [
|
||||
itemId,
|
||||
itemType,
|
||||
itemName,
|
||||
parentItemId,
|
||||
this.pageKey,
|
||||
pageName,
|
||||
this.referrerPageKey,
|
||||
referrerPageName,
|
||||
recommendTraceId,
|
||||
recommendId,
|
||||
recommendTraceInfo,
|
||||
]);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final payload = <String, dynamic>{
|
||||
'item_id': itemId,
|
||||
'item_type': itemType,
|
||||
'item_name': itemName,
|
||||
'parent_item_id': parentItemId,
|
||||
'page_key': pageKey,
|
||||
'page_name': pageName,
|
||||
'referrer_page_key': referrerPageKey,
|
||||
'referrer_page_name': referrerPageName,
|
||||
'recommend_trace_id': recommendTraceId,
|
||||
'recommend_id': recommendId,
|
||||
'recommend_trace_info': recommendTraceInfo,
|
||||
};
|
||||
return {..._commonFields, 'event_id': eventId, 'payload': payload};
|
||||
}
|
||||
|
||||
factory AppPageItemExposureEvent.fromJson(Map<String, dynamic> json) {
|
||||
final source = _payloadOrSelf(json);
|
||||
return AppPageItemExposureEvent(
|
||||
itemId: source['item_id'] as String,
|
||||
itemType: source['item_type'] as String,
|
||||
itemName: source['item_name'] as String,
|
||||
parentItemId: source['parent_item_id'] as String,
|
||||
pageKey: source['page_key'] as String,
|
||||
pageName: source['page_name'] as String,
|
||||
referrerPageKey: source['referrer_page_key'] as String,
|
||||
referrerPageName: source['referrer_page_name'] as String,
|
||||
recommendTraceId: source['recommend_trace_id'] as String? ?? '',
|
||||
recommendId: source['recommend_id'] as String? ?? '',
|
||||
recommendTraceInfo: source['recommend_trace_info'] as String? ?? '',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 页面内元素点击事件(客户端手动上报)。
|
||||
///
|
||||
/// 仅限需要采集页面内元素点击的项目接入。未对接推荐引擎时,推荐相关字段传空字符串。
|
||||
class AppPageItemClickEvent {
|
||||
final String event = EventType.appPageItemClick.event;
|
||||
late final String eventId;
|
||||
|
||||
/// 元素唯一 ID。
|
||||
final String itemId;
|
||||
|
||||
/// 点击元素类型,如推荐、关注、评论、小说、视频、漫画等业务字典值。
|
||||
final String itemType;
|
||||
|
||||
/// 点击元素名称,如推荐、关注、评论、小说、视频、漫画等业务字典值。
|
||||
final String itemName;
|
||||
|
||||
/// 父元素 ID。
|
||||
final String parentItemId;
|
||||
|
||||
/// 在父元素中的顺序索引(从 0 或 1 开始,按业务约定传入)。
|
||||
final int position;
|
||||
|
||||
/// 当前页面标识,建议使用 [AppPageEnum] 或业务字典中的稳定 key。
|
||||
final String pageKey;
|
||||
|
||||
/// 当前页面名称。
|
||||
final String pageName;
|
||||
|
||||
/// 推荐引擎 trace_id;多个推荐列表用英文逗号分隔。
|
||||
final String recommendTraceId;
|
||||
|
||||
/// 推荐内容 ID;未对接推荐引擎传空字符串。
|
||||
final String recommendId;
|
||||
|
||||
/// 推荐引擎 trace_info;未对接推荐引擎传空字符串。
|
||||
final String recommendTraceInfo;
|
||||
|
||||
late final Map<String, dynamic> _commonFields;
|
||||
|
||||
AppPageItemClickEvent({
|
||||
required this.itemId,
|
||||
required this.itemType,
|
||||
required this.itemName,
|
||||
required this.parentItemId,
|
||||
required this.position,
|
||||
required String pageKey,
|
||||
required this.pageName,
|
||||
this.recommendTraceId = '',
|
||||
this.recommendId = '',
|
||||
this.recommendTraceInfo = '',
|
||||
}) : pageKey = PageNameMapper.normalizeKey(pageKey) {
|
||||
_commonFields = SdkContext.generateCommonFields(event);
|
||||
|
||||
eventId = SdkContext.generateEventIdFromCommonFields(_commonFields, [
|
||||
itemId,
|
||||
itemType,
|
||||
itemName,
|
||||
parentItemId,
|
||||
position.toString(),
|
||||
this.pageKey,
|
||||
pageName,
|
||||
recommendTraceId,
|
||||
recommendId,
|
||||
recommendTraceInfo,
|
||||
]);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final payload = <String, dynamic>{
|
||||
'item_id': itemId,
|
||||
'item_type': itemType,
|
||||
'item_name': itemName,
|
||||
'parent_item_id': parentItemId,
|
||||
'position': position,
|
||||
'page_key': pageKey,
|
||||
'page_name': pageName,
|
||||
'recommend_trace_id': recommendTraceId,
|
||||
'recommend_id': recommendId,
|
||||
'recommend_trace_info': recommendTraceInfo,
|
||||
};
|
||||
return {..._commonFields, 'event_id': eventId, 'payload': payload};
|
||||
}
|
||||
|
||||
factory AppPageItemClickEvent.fromJson(Map<String, dynamic> json) {
|
||||
final source = _payloadOrSelf(json);
|
||||
return AppPageItemClickEvent(
|
||||
itemId: source['item_id'] as String,
|
||||
itemType: source['item_type'] as String,
|
||||
itemName: source['item_name'] as String,
|
||||
parentItemId: source['parent_item_id'] as String,
|
||||
position: source['position'] as int,
|
||||
pageKey: source['page_key'] as String,
|
||||
pageName: source['page_name'] as String,
|
||||
recommendTraceId: source['recommend_trace_id'] as String? ?? '',
|
||||
recommendId: source['recommend_id'] as String? ?? '',
|
||||
recommendTraceInfo: source['recommend_trace_info'] as String? ?? '',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, dynamic> _payloadOrSelf(Map<String, dynamic> json) {
|
||||
final payload = json['payload'];
|
||||
if (payload is Map) return Map<String, dynamic>.from(payload);
|
||||
return json;
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
import 'dart:ui' show window;
|
||||
|
||||
import 'package:analytics_sdk/const/event_type.dart';
|
||||
import 'package:analytics_sdk/manager/page_view_auto_fields.dart';
|
||||
import 'package:analytics_sdk/manager/sdk_context.dart';
|
||||
import 'package:analytics_sdk/manager/page_name_manager.dart';
|
||||
import 'package:analytics_sdk/utils/uuid_util.dart';
|
||||
|
||||
/// 应用页面展示日志(由 [PageLifecycleObserver] 和 Tab 包装器自动上报)。
|
||||
///
|
||||
/// **page_key / page_name 填写规范**
|
||||
///
|
||||
/// 建议使用 [AppPageEnum] 预定义值,也可直接传入自定义字符串:
|
||||
/// ```dart
|
||||
/// AppPageViewEvent(
|
||||
/// pageKey: AppPageEnum.videoDetail.key, // 'video_detail'
|
||||
/// pageName: AppPageEnum.videoDetail.name, // '视频详情页'
|
||||
/// ...
|
||||
/// )
|
||||
/// ```
|
||||
class AppPageViewEvent {
|
||||
final String event = EventType.appPageView.event;
|
||||
late final String eventId;
|
||||
|
||||
final String userType;
|
||||
|
||||
/// 当前页面标识,建议使用 [AppPageEnum](如 `AppPageEnum.videoDetail.key`),
|
||||
/// 也可传入自定义字符串。
|
||||
final String pageKey;
|
||||
|
||||
/// 当前页面展示名称,建议与 [pageKey] 保持对应(如 `AppPageEnum.videoDetail.name`)。
|
||||
final String pageName;
|
||||
|
||||
/// 来路页面标识,填写跳转前所在页面的 pageKey,规范同 [pageKey]。
|
||||
final String referrerPageKey;
|
||||
|
||||
/// 来路页面展示名称,规范同 [pageName]。
|
||||
final String referrerPageName;
|
||||
|
||||
/// 当前页面标识(与 [pageKey] 通常保持一致,保留用于扩展场景)。
|
||||
final String currentPageKey;
|
||||
|
||||
/// 当前页面展示名称(与 [pageName] 通常保持一致)。
|
||||
final String currentPageName;
|
||||
|
||||
/// 页面加载耗时(毫秒)
|
||||
final int pageLoadTime;
|
||||
|
||||
/// 当前页面实例唯一 ID,由 SDK 自动生成。
|
||||
final String pageId;
|
||||
|
||||
/// SDK 首次识别当前页面实例的时间,10 位时间戳。
|
||||
final int pageEnterTs;
|
||||
|
||||
/// Web/H5 当前页面物理地址,仅包含 scheme + host + path。
|
||||
final String pageUrl;
|
||||
|
||||
/// 当前页面根容器可交互可视区域宽度。
|
||||
final int viewportWidth;
|
||||
|
||||
/// 当前页面根容器可交互可视区域高度。
|
||||
final int viewportHeight;
|
||||
|
||||
/// 推荐引擎的trace_id,有多个推荐列表时用英文逗号分隔,未接推荐引擎传空字符串
|
||||
final String recommendTraceId;
|
||||
|
||||
late final Map<String, dynamic> _commonFields;
|
||||
|
||||
AppPageViewEvent({
|
||||
required this.userType,
|
||||
required String pageKey,
|
||||
required this.pageName,
|
||||
required String referrerPageKey,
|
||||
required this.referrerPageName,
|
||||
required String currentPageKey,
|
||||
required this.currentPageName,
|
||||
required this.pageLoadTime,
|
||||
String? pageId,
|
||||
int? pageEnterTs,
|
||||
String? pageUrl,
|
||||
int? viewportWidth,
|
||||
int? viewportHeight,
|
||||
this.recommendTraceId = '',
|
||||
}) : pageKey = PageNameMapper.normalizeKey(pageKey),
|
||||
referrerPageKey = PageNameMapper.normalizeKey(referrerPageKey),
|
||||
currentPageKey = PageNameMapper.normalizeKey(currentPageKey),
|
||||
pageId = _pageId(pageId),
|
||||
pageEnterTs = _pageEnterTs(pageEnterTs),
|
||||
pageUrl = pageUrl ?? currentPageUrl(),
|
||||
viewportWidth = _viewportDimension(viewportWidth, isWidth: true),
|
||||
viewportHeight = _viewportDimension(viewportHeight, isWidth: false) {
|
||||
_commonFields = SdkContext.generateCommonFields(event);
|
||||
|
||||
eventId = SdkContext.generateEventIdFromCommonFields(_commonFields, [
|
||||
this.pageId,
|
||||
this.pageEnterTs.toString(),
|
||||
this.pageUrl,
|
||||
this.viewportWidth.toString(),
|
||||
this.viewportHeight.toString(),
|
||||
userType,
|
||||
this.pageKey,
|
||||
pageName,
|
||||
this.referrerPageKey,
|
||||
referrerPageName,
|
||||
this.currentPageKey,
|
||||
currentPageName,
|
||||
pageLoadTime.toString(),
|
||||
recommendTraceId,
|
||||
]);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
Map<String, dynamic> payload = {
|
||||
'user_type': userType,
|
||||
'page_key': pageKey,
|
||||
'page_name': pageName,
|
||||
'referrer_page_key': referrerPageKey,
|
||||
'referrer_page_name': referrerPageName,
|
||||
'current_page_key': currentPageKey,
|
||||
'current_page_name': currentPageName,
|
||||
'page_load_time': pageLoadTime,
|
||||
'page_id': pageId,
|
||||
'page_enter_ts': pageEnterTs,
|
||||
'page_url': pageUrl,
|
||||
'viewport_width': viewportWidth,
|
||||
'viewport_height': viewportHeight,
|
||||
'recommend_trace_id': recommendTraceId,
|
||||
};
|
||||
return {
|
||||
..._commonFields,
|
||||
'event_id': eventId,
|
||||
"payload": payload,
|
||||
};
|
||||
}
|
||||
|
||||
factory AppPageViewEvent.fromJson(Map<String, dynamic> json) {
|
||||
return AppPageViewEvent(
|
||||
userType: json['user_type'] as String,
|
||||
pageKey: json['page_key'] as String,
|
||||
pageName: json['page_name'] as String,
|
||||
referrerPageKey: json['referrer_page_key'] as String,
|
||||
referrerPageName: json['referrer_page_name'] as String,
|
||||
currentPageKey: json['current_page_key'] as String,
|
||||
currentPageName: json['current_page_name'] as String,
|
||||
pageLoadTime: json['page_load_time'] as int,
|
||||
pageId: json['page_id'] as String?,
|
||||
pageEnterTs: json['page_enter_ts'] as int?,
|
||||
pageUrl: json['page_url'] as String?,
|
||||
viewportWidth: json['viewport_width'] as int?,
|
||||
viewportHeight: json['viewport_height'] as int?,
|
||||
recommendTraceId: json['recommend_trace_id'] as String? ?? '',
|
||||
);
|
||||
}
|
||||
|
||||
static String _pageId(String? value) {
|
||||
final normalized = value?.trim() ?? '';
|
||||
if (normalized.isNotEmpty) return normalized;
|
||||
return generateUuidV4().replaceAll('-', '');
|
||||
}
|
||||
|
||||
static int _pageEnterTs(int? value) {
|
||||
if (value != null && value > 0) return value;
|
||||
return DateTime.now().millisecondsSinceEpoch ~/ 1000;
|
||||
}
|
||||
|
||||
static int _viewportDimension(int? value, {required bool isWidth}) {
|
||||
if (value != null) return value >= 0 ? value : -1;
|
||||
try {
|
||||
final pixelRatio = window.devicePixelRatio;
|
||||
if (pixelRatio <= 0) return -1;
|
||||
final physicalSize = window.physicalSize;
|
||||
final logical = isWidth
|
||||
? physicalSize.width / pixelRatio
|
||||
: physicalSize.height / pixelRatio;
|
||||
if (!logical.isFinite || logical <= 0) return -1;
|
||||
return logical.round();
|
||||
} catch (_) {
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:analytics_sdk/const/event_type.dart';
|
||||
import 'package:analytics_sdk/manager/sdk_context.dart';
|
||||
import 'package:analytics_sdk/manager/session_manager.dart';
|
||||
import 'package:analytics_sdk/manager/session_behavior_manager.dart';
|
||||
import 'package:analytics_sdk/manager/session_metrics_manager.dart';
|
||||
import 'package:analytics_sdk/manager/session_sensor_sampler.dart';
|
||||
|
||||
/// 会话汇总事件。
|
||||
///
|
||||
/// 汇总字段写入 `payload`;签名类 payload 字段由
|
||||
/// [SdkContext.attachDeviceEnvIfNeeded] 在 track 管道中统一补充。
|
||||
class AppSessionSummaryEvent {
|
||||
final String event = EventType.appSessionSummary.event;
|
||||
final Map<String, dynamic> _payloadFields;
|
||||
|
||||
late final String eventId;
|
||||
late final Map<String, dynamic> _commonFields;
|
||||
|
||||
AppSessionSummaryEvent({
|
||||
required String sessionEndReason,
|
||||
DateTime? endedAt,
|
||||
}) : _payloadFields = _buildPayloadFields(
|
||||
sessionEndReason: sessionEndReason,
|
||||
endedAt: endedAt ?? DateTime.now(),
|
||||
) {
|
||||
_commonFields = SdkContext.generateCommonFields(event);
|
||||
eventId = SdkContext.generateEventIdFromCommonFields(_commonFields, [
|
||||
jsonEncode(_stablePayloadFields),
|
||||
]);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
..._commonFields,
|
||||
'event_id': eventId,
|
||||
'payload': Map<String, dynamic>.from(_payloadFields),
|
||||
};
|
||||
}
|
||||
|
||||
Map<String, dynamic> get _stablePayloadFields {
|
||||
final sortedKeys = _payloadFields.keys.toList()..sort();
|
||||
return {
|
||||
for (final key in sortedKeys) key: _payloadFields[key],
|
||||
};
|
||||
}
|
||||
|
||||
static Map<String, dynamic> _buildPayloadFields({
|
||||
required String sessionEndReason,
|
||||
required DateTime endedAt,
|
||||
}) {
|
||||
final metrics = SessionMetricsManager.instance.snapshot(endedAt: endedAt);
|
||||
final behavior = SessionBehaviorManager.instance.snapshot();
|
||||
final environment = SdkContext.getEnvironmentSnapshotFields();
|
||||
final screenBrightness = _intField(environment['screen_brightness'], -1);
|
||||
final sensor = SessionSensorSampler.instance.snapshot(
|
||||
screenBrightness: screenBrightness,
|
||||
);
|
||||
final sessionStart = SessionManager.instance.sessionStartTime ?? endedAt;
|
||||
final sessionStartTs = sessionStart.millisecondsSinceEpoch ~/ 1000;
|
||||
final sessionEndTs = endedAt.millisecondsSinceEpoch ~/ 1000;
|
||||
final durationMs = endedAt.difference(sessionStart).inMilliseconds;
|
||||
|
||||
return {
|
||||
'session_start_ts': sessionStartTs,
|
||||
'session_end_ts': sessionEndTs,
|
||||
'session_duration_ms': durationMs < 0 ? 0 : durationMs,
|
||||
'event_count_total': metrics.eventCountTotal,
|
||||
'business_event_count': metrics.businessEventCount,
|
||||
'foreground_count': metrics.foregroundCount,
|
||||
'background_count_in_sid': metrics.backgroundCountInSid,
|
||||
'page_count': metrics.pageCount,
|
||||
'page_view_count': metrics.pageViewCount,
|
||||
'page_click_count': metrics.pageClickCount,
|
||||
'network_change_count': metrics.networkChangeCount,
|
||||
'session_end_reason': _sessionEndReason(sessionEndReason),
|
||||
'last_event_name': metrics.lastEventName,
|
||||
'orientation': environment['orientation']?.toString() ?? '',
|
||||
'screen_brightness': screenBrightness,
|
||||
'volume': _intField(environment['volume'], -1),
|
||||
'is_screen_on': _intField(environment['is_screen_on'], -1),
|
||||
'battery_level': _intField(environment['battery_level'], -1),
|
||||
'is_charging': _intField(environment['is_charging'], -1),
|
||||
'battery_state': _stringField(environment['battery_state'], 'unknown'),
|
||||
'sensor_signature': sensor.sensorSignature,
|
||||
'sensor_count_active': sensor.sensorCountActive,
|
||||
'scroll_total_count': behavior.scrollTotalCount,
|
||||
'scroll_total_distance_px': behavior.scrollTotalDistancePx,
|
||||
'scroll_up_count': behavior.scrollUpCount,
|
||||
'scroll_down_count': behavior.scrollDownCount,
|
||||
'scroll_direction_change_count': behavior.scrollDirectionChangeCount,
|
||||
'first_scroll_delay_ms': behavior.firstScrollDelayMs,
|
||||
'max_scroll_depth_percent': behavior.maxScrollDepthPercent,
|
||||
'is_reach_bottom': behavior.isReachBottom,
|
||||
'content_height': behavior.contentHeight,
|
||||
'touch_total_count': behavior.touchTotalCount,
|
||||
'swipe_total_count': behavior.swipeTotalCount,
|
||||
'long_press_total_count': behavior.longPressTotalCount,
|
||||
'pinch_total_count': behavior.pinchTotalCount,
|
||||
'first_interaction_delay_ms': behavior.firstInteractionDelayMs,
|
||||
'page_stay_list': metrics.pageStayList,
|
||||
'behavior_sequence_hash': metrics.behaviorSequenceHash,
|
||||
};
|
||||
}
|
||||
|
||||
static int _intField(dynamic value, int fallback) {
|
||||
if (value is int) return value;
|
||||
if (value is num) return value.round();
|
||||
return int.tryParse(value?.toString() ?? '') ?? fallback;
|
||||
}
|
||||
|
||||
static String _stringField(dynamic value, String fallback) {
|
||||
final text = value?.toString().trim() ?? '';
|
||||
return text.isEmpty ? fallback : text;
|
||||
}
|
||||
|
||||
static String _sessionEndReason(String value) {
|
||||
final normalized = value.trim();
|
||||
const allowed = {
|
||||
'idle',
|
||||
'app_exit',
|
||||
'web_visibility_hidden',
|
||||
'forced',
|
||||
};
|
||||
return allowed.contains(normalized) ? normalized : 'app_exit';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import 'package:analytics_sdk/const/event_type.dart';
|
||||
import 'package:analytics_sdk/manager/sdk_context.dart';
|
||||
import 'package:analytics_sdk/enum/read_behavior_enum.dart';
|
||||
|
||||
/// 漫画事件
|
||||
class ComicEvent {
|
||||
final String event = EventType.comicEvent.event;
|
||||
late final String eventId;
|
||||
|
||||
/// 漫画 id
|
||||
final String comicId;
|
||||
|
||||
/// 漫画标题
|
||||
final String comicTitle;
|
||||
|
||||
/// 分类ID
|
||||
final String comicTypeId;
|
||||
|
||||
/// 分类名称
|
||||
final String comicTypeName;
|
||||
|
||||
/// 标签KEY, 多个标签使用英文逗号分隔
|
||||
final String comicTagKey;
|
||||
|
||||
/// 标签名称,多个标签使用英文逗号分隔
|
||||
final String comicTagName;
|
||||
|
||||
/// 推荐引擎的trace_id,未接推荐引擎传空字符串
|
||||
final String recommendTraceId;
|
||||
|
||||
/// 阅读进度百分比(0-100)
|
||||
final int readProgress;
|
||||
|
||||
/// 当前阅读第几页(从1开始)
|
||||
final int pageNo;
|
||||
|
||||
/// 行为标识:view(展示), page_next(下一页), page_prev(上一页), complete(读完) 等
|
||||
final ReadBehaviorEnum comicBehavior;
|
||||
|
||||
/// 章节ID,没有章节上报空字符串,没有对接搜索推荐引擎的上报空字符串
|
||||
final String chapterId;
|
||||
|
||||
/// 章节名称,没有章节上报空字符串,没有对接搜索推荐引擎的上报空字符串
|
||||
final String chapterName;
|
||||
|
||||
/// 老司机媒体资源ID,如未使用老司机库资源可传空字符串
|
||||
final String mediaId;
|
||||
|
||||
late final Map<String, dynamic> _commonFields;
|
||||
|
||||
ComicEvent({
|
||||
required this.comicId,
|
||||
required this.comicTitle,
|
||||
required this.comicTypeId,
|
||||
required this.comicTypeName,
|
||||
required this.comicTagKey,
|
||||
required this.comicTagName,
|
||||
required this.readProgress,
|
||||
required this.pageNo,
|
||||
required this.comicBehavior,
|
||||
this.recommendTraceId = '',
|
||||
this.mediaId = '',
|
||||
this.chapterId = '',
|
||||
this.chapterName = '',
|
||||
}) {
|
||||
_commonFields = SdkContext.generateCommonFields(event);
|
||||
|
||||
eventId = SdkContext.generateEventIdFromCommonFields(_commonFields, [
|
||||
comicId,
|
||||
comicTitle,
|
||||
comicTypeId,
|
||||
comicTypeName,
|
||||
comicTagKey,
|
||||
comicTagName,
|
||||
readProgress.toString(),
|
||||
pageNo.toString(),
|
||||
comicBehavior.key,
|
||||
comicBehavior.name,
|
||||
recommendTraceId,
|
||||
mediaId,
|
||||
chapterId,
|
||||
chapterName,
|
||||
]);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
Map<String, dynamic> payload = {
|
||||
'comic_id': comicId,
|
||||
'comic_title': comicTitle,
|
||||
'comic_type_id': comicTypeId,
|
||||
'comic_type_name': comicTypeName,
|
||||
'comic_tag_key': comicTagKey,
|
||||
'comic_tag_name': comicTagName,
|
||||
'recommend_trace_id': recommendTraceId,
|
||||
'read_progress': readProgress,
|
||||
'page_no': pageNo,
|
||||
'comic_behavior_key': comicBehavior.key,
|
||||
'comic_behavior_name': comicBehavior.name,
|
||||
'media_id': mediaId,
|
||||
'chapter_id': chapterId,
|
||||
'chapter_name': chapterName,
|
||||
};
|
||||
return {..._commonFields, 'event_id': eventId, "payload": payload};
|
||||
}
|
||||
|
||||
factory ComicEvent.fromJson(Map<String, dynamic> json) {
|
||||
final behaviorKey = json['comic_behavior_key'] as String? ?? '';
|
||||
final behaviorName = json['comic_behavior_name'] as String? ?? '';
|
||||
final behavior = ReadBehaviorEnum(
|
||||
behaviorKey.isNotEmpty ? behaviorKey : ReadBehaviorEnum.VIEW.key,
|
||||
behaviorName.isNotEmpty ? behaviorName : ReadBehaviorEnum.VIEW.name,
|
||||
);
|
||||
|
||||
return ComicEvent(
|
||||
comicId: json['comic_id'] as String,
|
||||
comicTitle: json['comic_title'] as String,
|
||||
comicTypeId: json['comic_type_id'] as String,
|
||||
comicTypeName: json['comic_type_name'] as String,
|
||||
comicTagKey: json['comic_tag_key'] as String,
|
||||
comicTagName: json['comic_tag_name'] as String,
|
||||
readProgress: json['read_progress'] as int,
|
||||
pageNo: json['page_no'] as int,
|
||||
comicBehavior: behavior,
|
||||
recommendTraceId: json['recommend_trace_id'] as String? ?? '',
|
||||
mediaId: json['media_id'] as String? ?? '',
|
||||
chapterId: json['chapter_id'] as String? ?? '',
|
||||
chapterName: json['chapter_name'] as String? ?? '',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import 'package:analytics_sdk/const/event_type.dart';
|
||||
import 'package:analytics_sdk/manager/sdk_context.dart';
|
||||
import 'package:analytics_sdk/enum/click_item_type_enum.dart';
|
||||
|
||||
/// 关键词搜索结果点击事件
|
||||
class KeywordClickEvent {
|
||||
final String event = EventType.keywordClick.event;
|
||||
late final String eventId;
|
||||
|
||||
/// 关联关键词
|
||||
final String keyword;
|
||||
|
||||
/// 点击项目ID(视频ID/小说ID等)
|
||||
final String clickItemId;
|
||||
|
||||
/// 点击项目类型,可用预定义常量或自定义:
|
||||
/// `ClickItemTypeEnum.video` / `ClickItemTypeEnum('manga', '漫画类')`
|
||||
final ClickItemTypeEnum clickItemType;
|
||||
|
||||
/// 点击位置(搜索结果中的排序位置)
|
||||
final int clickPosition;
|
||||
|
||||
/// 搜索引擎 trace ID,未接搜索引擎时传空字符串
|
||||
final String searchTraceId;
|
||||
|
||||
late final Map<String, dynamic> _commonFields;
|
||||
|
||||
KeywordClickEvent({
|
||||
required this.keyword,
|
||||
required this.clickItemId,
|
||||
required this.clickItemType,
|
||||
required this.clickPosition,
|
||||
this.searchTraceId = '',
|
||||
}) {
|
||||
_commonFields = SdkContext.generateCommonFields(event);
|
||||
|
||||
eventId = SdkContext.generateEventIdFromCommonFields(_commonFields, [
|
||||
keyword,
|
||||
clickItemId,
|
||||
clickItemType.key,
|
||||
clickItemType.name,
|
||||
clickPosition.toString(),
|
||||
searchTraceId,
|
||||
]);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
Map<String, dynamic> payload = {
|
||||
'keyword': keyword,
|
||||
'click_item_id': clickItemId,
|
||||
'click_item_type_key': clickItemType.key,
|
||||
'click_item_type_name': clickItemType.name,
|
||||
'click_position': clickPosition,
|
||||
'search_trace_id': searchTraceId,
|
||||
};
|
||||
return {..._commonFields, 'event_id': eventId, "payload": payload};
|
||||
}
|
||||
|
||||
factory KeywordClickEvent.fromJson(Map<String, dynamic> json) {
|
||||
return KeywordClickEvent(
|
||||
keyword: json['keyword'] as String,
|
||||
clickItemId: json['click_item_id'] as String,
|
||||
clickItemType: ClickItemTypeEnum(
|
||||
json['click_item_type_key'] as String,
|
||||
json['click_item_type_name'] as String,
|
||||
),
|
||||
clickPosition: json['click_position'] as int,
|
||||
searchTraceId: json['search_trace_id'] as String? ?? '',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import 'package:analytics_sdk/const/event_type.dart';
|
||||
import 'package:analytics_sdk/manager/sdk_context.dart';
|
||||
|
||||
/// 关键词搜索事件
|
||||
class KeywordSearchEvent {
|
||||
final String event = EventType.keywordSearch.event;
|
||||
late final String eventId;
|
||||
|
||||
/// 搜索关键词
|
||||
final String keyword;
|
||||
|
||||
/// 搜索结果数量
|
||||
final int searchResultCount;
|
||||
|
||||
/// 搜索引擎 trace ID,未接搜索引擎时传空字符串
|
||||
final String searchTraceId;
|
||||
|
||||
/// 搜索引擎 search ID,未接搜索引擎时传空字符串
|
||||
final String searchId;
|
||||
|
||||
/// 搜索列表的内容类型:video / novel / comic
|
||||
/// 未接搜索推荐引擎时传空字符串
|
||||
final String searchContentType;
|
||||
|
||||
late final Map<String, dynamic> _commonFields;
|
||||
|
||||
KeywordSearchEvent({
|
||||
required this.keyword,
|
||||
required this.searchResultCount,
|
||||
this.searchTraceId = '',
|
||||
this.searchId = '',
|
||||
this.searchContentType = '',
|
||||
}) {
|
||||
_commonFields = SdkContext.generateCommonFields(event);
|
||||
|
||||
eventId = SdkContext.generateEventIdFromCommonFields(_commonFields, [
|
||||
keyword,
|
||||
searchResultCount.toString(),
|
||||
searchTraceId,
|
||||
searchId,
|
||||
searchContentType,
|
||||
]);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
Map<String, dynamic> payload = {
|
||||
'keyword': keyword,
|
||||
'search_result_count': searchResultCount,
|
||||
'search_trace_id': searchTraceId,
|
||||
'search_id': searchId,
|
||||
'search_content_type': searchContentType,
|
||||
};
|
||||
return {..._commonFields, 'event_id': eventId, "payload": payload};
|
||||
}
|
||||
|
||||
factory KeywordSearchEvent.fromJson(Map<String, dynamic> json) {
|
||||
return KeywordSearchEvent(
|
||||
keyword: json['keyword'] as String,
|
||||
searchResultCount: json['search_result_count'] as int,
|
||||
searchTraceId: json['search_trace_id'] as String? ?? '',
|
||||
searchId: json['search_id'] as String? ?? '',
|
||||
searchContentType: json['search_content_type'] as String? ?? '',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import 'package:analytics_sdk/const/event_type.dart';
|
||||
import 'package:analytics_sdk/manager/page_name_manager.dart';
|
||||
import 'package:analytics_sdk/manager/sdk_context.dart';
|
||||
|
||||
/// 导航菜单点击事件(纯手动上报)。
|
||||
///
|
||||
/// 仅在用户主动点击导航菜单时触发:底部 Tab、顶部 Tab、侧边抽屉等。
|
||||
/// SDK 不会在路由跳转时自动发送此事件;触发方式:
|
||||
/// - [AnalyticsSdk.trackNavigation] 手动调用
|
||||
/// - [BottomNavigationBar.withAnalytics] / [TabBar.withAnalytics] 包装器内部自动处理
|
||||
///
|
||||
/// **page_key / page_name 填写规范**
|
||||
///
|
||||
/// 建议使用 [AppPageEnum] 预定义值,也可直接传入自定义字符串:
|
||||
/// ```dart
|
||||
/// NavigationEvent(
|
||||
/// navigationKey: AppPageEnum.video.key, // 'video'
|
||||
/// navigationName: AppPageEnum.video.name, // '影视'
|
||||
/// )
|
||||
/// ```
|
||||
///
|
||||
/// **上报时序**:应在同次导航的 [AppPageViewEvent] 之前发出。
|
||||
class NavigationEvent {
|
||||
final String event = EventType.navigation.event;
|
||||
late final String eventId;
|
||||
|
||||
/// 导航目标的唯一标识,与对应页面的 [AppPageViewEvent.pageKey] 保持一致。
|
||||
///
|
||||
/// 示例:`home`(首页)、`video`(影视)、`video_hot`(影视·热门子 Tab)
|
||||
final String navigationKey;
|
||||
|
||||
/// 导航目标的展示名称。
|
||||
///
|
||||
/// 示例:`首页`、`影视`、`影视·热门`
|
||||
final String navigationName;
|
||||
|
||||
late final Map<String, dynamic> _commonFields;
|
||||
|
||||
NavigationEvent({
|
||||
required String navigationKey,
|
||||
required this.navigationName,
|
||||
}) : navigationKey = PageNameMapper.normalizeKey(navigationKey) {
|
||||
_commonFields = SdkContext.generateCommonFields(event);
|
||||
|
||||
eventId = SdkContext.generateEventIdFromCommonFields(_commonFields, [
|
||||
this.navigationKey,
|
||||
navigationName,
|
||||
]);
|
||||
}
|
||||
|
||||
/// 序列化为上报 JSON;payload 包含 `navigation_key` 和 `navigation_name`。
|
||||
Map<String, dynamic> toJson() {
|
||||
Map<String, dynamic> payload = {
|
||||
'navigation_key': navigationKey,
|
||||
'navigation_name': navigationName,
|
||||
};
|
||||
return {..._commonFields, 'event_id': eventId, "payload": payload};
|
||||
}
|
||||
|
||||
/// 从 JSON 反序列化(通常用于缓存恢复场景)。
|
||||
factory NavigationEvent.fromJson(Map<String, dynamic> json) {
|
||||
return NavigationEvent(
|
||||
navigationKey: json['navigation_key'] as String,
|
||||
navigationName: json['navigation_name'] as String,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:analytics_sdk/const/event_type.dart';
|
||||
import 'package:analytics_sdk/manager/sdk_context.dart';
|
||||
import 'package:analytics_sdk/utils/network_type_resolver.dart';
|
||||
|
||||
/// 网络状态变化事件。
|
||||
///
|
||||
/// 文档要求除公共字段外,下表字段写入 `payload`。
|
||||
/// 完整设备环境、SIM 详情和当前页面信息不在本事件重传。
|
||||
class NetworkStateChangeEvent {
|
||||
final String event = EventType.networkStateChange.event;
|
||||
final Map<String, dynamic> _payloadFields;
|
||||
|
||||
late final String eventId;
|
||||
late final Map<String, dynamic> _commonFields;
|
||||
|
||||
NetworkStateChangeEvent({
|
||||
required String networkType,
|
||||
required String fromNetworkType,
|
||||
required int isOnline,
|
||||
required int changeCountInSid,
|
||||
String carrier = '',
|
||||
Map<String, dynamic> webConnectionFields = const {},
|
||||
}) : _payloadFields = _buildPayloadFields(
|
||||
networkType: networkType,
|
||||
fromNetworkType: fromNetworkType,
|
||||
isOnline: isOnline,
|
||||
changeCountInSid: changeCountInSid,
|
||||
carrier: carrier,
|
||||
webConnectionFields: webConnectionFields,
|
||||
) {
|
||||
_commonFields = SdkContext.generateCommonFields(event);
|
||||
eventId = SdkContext.generateEventIdFromCommonFields(_commonFields, [
|
||||
jsonEncode(_stablePayloadFields),
|
||||
]);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
..._commonFields,
|
||||
'event_id': eventId,
|
||||
'payload': Map<String, dynamic>.from(_payloadFields),
|
||||
};
|
||||
}
|
||||
|
||||
Map<String, dynamic> get _stablePayloadFields {
|
||||
final sortedKeys = _payloadFields.keys.toList()..sort();
|
||||
return {
|
||||
for (final key in sortedKeys) key: _payloadFields[key],
|
||||
};
|
||||
}
|
||||
|
||||
static Map<String, dynamic> _buildPayloadFields({
|
||||
required String networkType,
|
||||
required String fromNetworkType,
|
||||
required int isOnline,
|
||||
required int changeCountInSid,
|
||||
required String carrier,
|
||||
required Map<String, dynamic> webConnectionFields,
|
||||
}) {
|
||||
final normalizedNetworkType = _networkType(networkType);
|
||||
final fields = <String, dynamic>{
|
||||
'network_type': normalizedNetworkType,
|
||||
'carrier': carrier.trim(),
|
||||
'from_network_type': _networkType(fromNetworkType),
|
||||
'to_network_type': normalizedNetworkType,
|
||||
'is_online': isOnlineForNetworkType(normalizedNetworkType),
|
||||
'change_count_in_sid': changeCountInSid < 1 ? 1 : changeCountInSid,
|
||||
};
|
||||
|
||||
for (final key in const [
|
||||
'connection_type',
|
||||
'effective_type',
|
||||
'downlink',
|
||||
'rtt',
|
||||
]) {
|
||||
final value = webConnectionFields[key];
|
||||
if (value == null) continue;
|
||||
if (value is String && value.trim().isEmpty) continue;
|
||||
fields[key] = value;
|
||||
}
|
||||
return fields;
|
||||
}
|
||||
|
||||
static String _networkType(String value) {
|
||||
return normalizeNetworkType(value);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import 'package:analytics_sdk/const/event_type.dart';
|
||||
import 'package:analytics_sdk/manager/sdk_context.dart';
|
||||
import 'package:analytics_sdk/enum/read_behavior_enum.dart';
|
||||
|
||||
/// 小说事件
|
||||
class NovelEvent {
|
||||
final String event = EventType.novelEvent.event;
|
||||
late final String eventId;
|
||||
|
||||
/// 小说 id
|
||||
final String novelId;
|
||||
|
||||
/// 小说标题
|
||||
final String novelTitle;
|
||||
|
||||
/// 分类ID
|
||||
final String novelTypeId;
|
||||
|
||||
/// 分类名称
|
||||
final String novelTypeName;
|
||||
|
||||
/// 标签KEY, 多个标签使用英文逗号分隔
|
||||
final String novelTagKey;
|
||||
|
||||
/// 标签名称,多个标签使用英文逗号分隔
|
||||
final String novelTagName;
|
||||
|
||||
/// 推荐引擎的trace_id,未接推荐引擎传空字符串
|
||||
final String recommendTraceId;
|
||||
|
||||
/// 阅读进度百分比(0-100)
|
||||
final int readProgress;
|
||||
|
||||
/// 当前阅读第几页(从1开始)
|
||||
final int pageNo;
|
||||
|
||||
/// 行为标识:view(展示), page_next(下一页), page_prev(上一页), complete(读完) 等
|
||||
final ReadBehaviorEnum novelBehavior;
|
||||
|
||||
/// 章节ID,没有章节上报空字符串,没有对接搜索推荐引擎的上报空字符串
|
||||
final String chapterId;
|
||||
|
||||
/// 章节名称,没有章节上报空字符串,没有对接搜索推荐引擎的上报空字符串
|
||||
final String chapterName;
|
||||
|
||||
/// 老司机媒体资源ID,如未使用老司机库资源可传空字符串
|
||||
final String mediaId;
|
||||
|
||||
late final Map<String, dynamic> _commonFields;
|
||||
|
||||
NovelEvent({
|
||||
required this.novelId,
|
||||
required this.novelTitle,
|
||||
required this.novelTypeId,
|
||||
required this.novelTypeName,
|
||||
required this.novelTagKey,
|
||||
required this.novelTagName,
|
||||
required this.readProgress,
|
||||
required this.pageNo,
|
||||
required this.novelBehavior,
|
||||
this.recommendTraceId = '',
|
||||
this.mediaId = '',
|
||||
this.chapterId = '',
|
||||
this.chapterName = '',
|
||||
}) {
|
||||
_commonFields = SdkContext.generateCommonFields(event);
|
||||
|
||||
eventId = SdkContext.generateEventIdFromCommonFields(_commonFields, [
|
||||
novelId,
|
||||
novelTitle,
|
||||
novelTypeId,
|
||||
novelTypeName,
|
||||
novelTagKey,
|
||||
novelTagName,
|
||||
readProgress.toString(),
|
||||
pageNo.toString(),
|
||||
novelBehavior.key,
|
||||
novelBehavior.name,
|
||||
recommendTraceId,
|
||||
mediaId,
|
||||
chapterId,
|
||||
chapterName,
|
||||
]);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
Map<String, dynamic> payload = {
|
||||
'novel_id': novelId,
|
||||
'novel_title': novelTitle,
|
||||
'novel_type_id': novelTypeId,
|
||||
'novel_type_name': novelTypeName,
|
||||
'novel_tag_key': novelTagKey,
|
||||
'novel_tag_name': novelTagName,
|
||||
'recommend_trace_id': recommendTraceId,
|
||||
'read_progress': readProgress,
|
||||
'page_no': pageNo,
|
||||
'novel_behavior_key': novelBehavior.key,
|
||||
'novel_behavior_name': novelBehavior.name,
|
||||
'media_id': mediaId,
|
||||
'chapter_id': chapterId,
|
||||
'chapter_name': chapterName,
|
||||
};
|
||||
return {..._commonFields, 'event_id': eventId, "payload": payload};
|
||||
}
|
||||
|
||||
factory NovelEvent.fromJson(Map<String, dynamic> json) {
|
||||
final behaviorKey = json['novel_behavior_key'] as String? ?? '';
|
||||
final behaviorName = json['novel_behavior_name'] as String? ?? '';
|
||||
final behavior = ReadBehaviorEnum(
|
||||
behaviorKey.isNotEmpty ? behaviorKey : ReadBehaviorEnum.VIEW.key,
|
||||
behaviorName.isNotEmpty ? behaviorName : ReadBehaviorEnum.VIEW.name,
|
||||
);
|
||||
|
||||
return NovelEvent(
|
||||
novelId: json['novel_id'] as String,
|
||||
novelTitle: json['novel_title'] as String,
|
||||
novelTypeId: json['novel_type_id'] as String,
|
||||
novelTypeName: json['novel_type_name'] as String,
|
||||
novelTagKey: json['novel_tag_key'] as String,
|
||||
novelTagName: json['novel_tag_name'] as String,
|
||||
readProgress: json['read_progress'] as int,
|
||||
pageNo: json['page_no'] as int,
|
||||
novelBehavior: behavior,
|
||||
recommendTraceId: json['recommend_trace_id'] as String? ?? '',
|
||||
mediaId: json['media_id'] as String? ?? '',
|
||||
chapterId: json['chapter_id'] as String? ?? '',
|
||||
chapterName: json['chapter_name'] as String? ?? '',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
import 'package:analytics_sdk/manager/page_name_manager.dart';
|
||||
import 'package:analytics_sdk/const/event_type.dart';
|
||||
import 'package:analytics_sdk/manager/page_click_auto_fields.dart';
|
||||
import 'package:analytics_sdk/manager/sdk_context.dart';
|
||||
|
||||
/// 应用页面点击日志 (自动)
|
||||
class PageClickEvent {
|
||||
final String event = EventType.pageClick.event;
|
||||
late final String eventId;
|
||||
|
||||
final String pageKey;
|
||||
|
||||
/// 页面名称:首页, 我的, 视频页
|
||||
final String pageName;
|
||||
|
||||
/// 点击位置坐标x轴
|
||||
final int clickPageX;
|
||||
|
||||
/// 点击位置坐标y轴
|
||||
final int clickPageY;
|
||||
|
||||
/// SDK 标准化点击相对当前页面根容器可视区域左上角的 X 坐标。
|
||||
final int clickViewportX;
|
||||
|
||||
/// SDK 标准化点击相对当前页面根容器可视区域左上角的 Y 坐标。
|
||||
final int clickViewportY;
|
||||
|
||||
/// 点击相对当前页面主内容完整区域左上角的 X 坐标。
|
||||
final int clickDocumentX;
|
||||
|
||||
/// 点击相对当前页面主内容完整区域左上角的 Y 坐标。
|
||||
final int clickDocumentY;
|
||||
|
||||
/// 点击位置横坐标百分比(0-100)
|
||||
final int clickXPercent;
|
||||
|
||||
/// 点击位置纵坐标百分比(0-100)
|
||||
final int clickYPercent;
|
||||
|
||||
/// 屏幕宽度(像素)
|
||||
final int screenWidth;
|
||||
|
||||
/// 屏幕高度(像素)
|
||||
final int screenHeight;
|
||||
|
||||
/// 点击时当前页面根容器可交互可视区域宽度。
|
||||
final int viewportWidth;
|
||||
|
||||
/// 点击时当前页面根容器可交互可视区域高度。
|
||||
final int viewportHeight;
|
||||
|
||||
/// 点击时当前页面主内容容器完整宽度。
|
||||
final int pageWidth;
|
||||
|
||||
/// 点击时当前页面主内容容器完整高度。
|
||||
final int pageHeight;
|
||||
|
||||
/// 点击时当前页面主内容容器纵向滚动偏移。
|
||||
final int scrollTop;
|
||||
|
||||
/// 点击时当前页面主内容容器横向滚动偏移。
|
||||
final int scrollLeft;
|
||||
|
||||
/// 推荐引擎的trace_id,有多个推荐列表时用英文逗号分隔,未接推荐引擎传空字符串
|
||||
final String recommendTraceId;
|
||||
|
||||
late final Map<String, dynamic> _commonFields;
|
||||
|
||||
PageClickEvent({
|
||||
required String pageKey,
|
||||
required this.pageName,
|
||||
required this.clickPageX,
|
||||
required this.clickPageY,
|
||||
required this.clickXPercent,
|
||||
required this.clickYPercent,
|
||||
required this.screenWidth,
|
||||
required this.screenHeight,
|
||||
int? clickViewportX,
|
||||
int? clickViewportY,
|
||||
int? clickDocumentX,
|
||||
int? clickDocumentY,
|
||||
int? viewportWidth,
|
||||
int? viewportHeight,
|
||||
int? pageWidth,
|
||||
int? pageHeight,
|
||||
int? scrollTop,
|
||||
int? scrollLeft,
|
||||
this.recommendTraceId = '',
|
||||
}) : pageKey = PageNameMapper.normalizeKey(pageKey),
|
||||
clickViewportX = _coordinateOrFallback(clickViewportX, clickPageX),
|
||||
clickViewportY = _coordinateOrFallback(clickViewportY, clickPageY),
|
||||
viewportWidth = _dimensionOrFallback(viewportWidth, screenWidth),
|
||||
viewportHeight = _dimensionOrFallback(viewportHeight, screenHeight),
|
||||
clickDocumentX = _hasInvalidHorizontalDocumentMetrics(
|
||||
pageWidth: pageWidth,
|
||||
viewportWidth: viewportWidth,
|
||||
screenWidth: screenWidth,
|
||||
scrollLeft: scrollLeft,
|
||||
)
|
||||
? -1
|
||||
: _unknownable(clickDocumentX),
|
||||
clickDocumentY = _unknownable(clickDocumentY),
|
||||
pageWidth = _hasInvalidHorizontalDocumentMetrics(
|
||||
pageWidth: pageWidth,
|
||||
viewportWidth: viewportWidth,
|
||||
screenWidth: screenWidth,
|
||||
scrollLeft: scrollLeft,
|
||||
)
|
||||
? -1
|
||||
: _unknownable(pageWidth),
|
||||
pageHeight = _unknownable(pageHeight),
|
||||
scrollTop = _unknownable(scrollTop),
|
||||
scrollLeft = _hasInvalidHorizontalDocumentMetrics(
|
||||
pageWidth: pageWidth,
|
||||
viewportWidth: viewportWidth,
|
||||
screenWidth: screenWidth,
|
||||
scrollLeft: scrollLeft,
|
||||
)
|
||||
? -1
|
||||
: _unknownable(scrollLeft) {
|
||||
_commonFields = SdkContext.generateCommonFields(event);
|
||||
|
||||
eventId = SdkContext.generateEventIdFromCommonFields(_commonFields, [
|
||||
this.pageKey,
|
||||
pageName,
|
||||
clickPageX.toString(),
|
||||
clickPageY.toString(),
|
||||
this.clickViewportX.toString(),
|
||||
this.clickViewportY.toString(),
|
||||
this.clickDocumentX.toString(),
|
||||
this.clickDocumentY.toString(),
|
||||
clickXPercent.toString(),
|
||||
clickYPercent.toString(),
|
||||
screenWidth.toString(),
|
||||
screenHeight.toString(),
|
||||
this.viewportWidth.toString(),
|
||||
this.viewportHeight.toString(),
|
||||
this.pageWidth.toString(),
|
||||
this.pageHeight.toString(),
|
||||
this.scrollTop.toString(),
|
||||
this.scrollLeft.toString(),
|
||||
recommendTraceId,
|
||||
]);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
Map<String, dynamic> payload = {
|
||||
'page_key': pageKey,
|
||||
'page_name': pageName,
|
||||
'click_page_x': clickPageX,
|
||||
'click_page_y': clickPageY,
|
||||
'click_viewport_x': clickViewportX,
|
||||
'click_viewport_y': clickViewportY,
|
||||
'click_document_x': clickDocumentX,
|
||||
'click_document_y': clickDocumentY,
|
||||
'click_x_percent': clickXPercent,
|
||||
'click_y_percent': clickYPercent,
|
||||
'screen_width': screenWidth,
|
||||
'screen_height': screenHeight,
|
||||
'viewport_width': viewportWidth,
|
||||
'viewport_height': viewportHeight,
|
||||
'page_width': pageWidth,
|
||||
'page_height': pageHeight,
|
||||
'scroll_top': scrollTop,
|
||||
'scroll_left': scrollLeft,
|
||||
'recommend_trace_id': recommendTraceId,
|
||||
};
|
||||
return {
|
||||
..._commonFields,
|
||||
'event_id': eventId,
|
||||
"payload": payload,
|
||||
};
|
||||
}
|
||||
|
||||
factory PageClickEvent.fromJson(Map<String, dynamic> json) {
|
||||
return PageClickEvent(
|
||||
pageKey: json['page_key'] as String,
|
||||
pageName: json['page_name'] as String,
|
||||
clickPageX: json['click_page_x'] as int,
|
||||
clickPageY: json['click_page_y'] as int,
|
||||
clickXPercent: json['click_x_percent'] as int,
|
||||
clickYPercent: json['click_y_percent'] as int,
|
||||
screenWidth: json['screen_width'] as int,
|
||||
screenHeight: json['screen_height'] as int,
|
||||
clickViewportX: json['click_viewport_x'] as int?,
|
||||
clickViewportY: json['click_viewport_y'] as int?,
|
||||
clickDocumentX: json['click_document_x'] as int?,
|
||||
clickDocumentY: json['click_document_y'] as int?,
|
||||
viewportWidth: json['viewport_width'] as int?,
|
||||
viewportHeight: json['viewport_height'] as int?,
|
||||
pageWidth: json['page_width'] as int?,
|
||||
pageHeight: json['page_height'] as int?,
|
||||
scrollTop: json['scroll_top'] as int?,
|
||||
scrollLeft: json['scroll_left'] as int?,
|
||||
recommendTraceId: json['recommend_trace_id'] as String? ?? '',
|
||||
);
|
||||
}
|
||||
|
||||
static PageClickEvent fromViewport({
|
||||
required String pageKey,
|
||||
required String pageName,
|
||||
required int clickPageX,
|
||||
required int clickPageY,
|
||||
required int clickViewportX,
|
||||
required int clickViewportY,
|
||||
required int viewportWidth,
|
||||
required int viewportHeight,
|
||||
int? clickDocumentX,
|
||||
int? clickDocumentY,
|
||||
int? pageWidth,
|
||||
int? pageHeight,
|
||||
int? scrollTop,
|
||||
int? scrollLeft,
|
||||
String recommendTraceId = '',
|
||||
}) {
|
||||
final documentMetrics = currentClickDocumentMetrics(
|
||||
clickViewportX: clickViewportX,
|
||||
clickViewportY: clickViewportY,
|
||||
);
|
||||
final clickXPercent = viewportWidth > 0
|
||||
? (clickViewportX / viewportWidth * 100).round().clamp(0, 100)
|
||||
: 0;
|
||||
final clickYPercent = viewportHeight > 0
|
||||
? (clickViewportY / viewportHeight * 100).round().clamp(0, 100)
|
||||
: 0;
|
||||
return PageClickEvent(
|
||||
pageKey: pageKey,
|
||||
pageName: pageName,
|
||||
clickPageX: clickPageX,
|
||||
clickPageY: clickPageY,
|
||||
clickXPercent: clickXPercent,
|
||||
clickYPercent: clickYPercent,
|
||||
screenWidth: viewportWidth,
|
||||
screenHeight: viewportHeight,
|
||||
clickViewportX: clickViewportX,
|
||||
clickViewportY: clickViewportY,
|
||||
clickDocumentX: clickDocumentX ?? documentMetrics.clickDocumentX,
|
||||
clickDocumentY: clickDocumentY ?? documentMetrics.clickDocumentY,
|
||||
viewportWidth: viewportWidth,
|
||||
viewportHeight: viewportHeight,
|
||||
pageWidth: pageWidth ?? documentMetrics.pageWidth,
|
||||
pageHeight: pageHeight ?? documentMetrics.pageHeight,
|
||||
scrollTop: scrollTop ?? documentMetrics.scrollTop,
|
||||
scrollLeft: scrollLeft ?? documentMetrics.scrollLeft,
|
||||
recommendTraceId: recommendTraceId,
|
||||
);
|
||||
}
|
||||
|
||||
static int _coordinateOrFallback(int? value, int fallback) {
|
||||
final resolved = value ?? fallback;
|
||||
return resolved < 0 ? -1 : resolved;
|
||||
}
|
||||
|
||||
static int _dimensionOrFallback(int? value, int fallback) {
|
||||
final resolved = value ?? fallback;
|
||||
return resolved < 0 ? -1 : resolved;
|
||||
}
|
||||
|
||||
static int _unknownable(int? value) {
|
||||
if (value == null || value < 0) return -1;
|
||||
return value;
|
||||
}
|
||||
|
||||
static bool _hasInvalidHorizontalDocumentMetrics({
|
||||
required int? pageWidth,
|
||||
required int? viewportWidth,
|
||||
required int screenWidth,
|
||||
required int? scrollLeft,
|
||||
}) {
|
||||
final resolvedPageWidth = _unknownable(pageWidth);
|
||||
final resolvedViewportWidth =
|
||||
_dimensionOrFallback(viewportWidth, screenWidth);
|
||||
final resolvedScrollLeft = _unknownable(scrollLeft);
|
||||
|
||||
if (resolvedPageWidth < 0 ||
|
||||
resolvedViewportWidth < 0 ||
|
||||
resolvedScrollLeft <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
final maxScrollLeft = resolvedPageWidth - resolvedViewportWidth;
|
||||
return maxScrollLeft < 0 || resolvedScrollLeft > maxScrollLeft;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import 'package:analytics_sdk/manager/page_name_manager.dart';
|
||||
import 'package:analytics_sdk/const/event_type.dart';
|
||||
import 'package:analytics_sdk/manager/sdk_context.dart';
|
||||
import 'package:analytics_sdk/enum/recommend_content_type_enum.dart';
|
||||
|
||||
/// 推荐引擎列表点击事件(服务端/客户端)
|
||||
class RecommendListClickEvent {
|
||||
/// 事件类型(固定)
|
||||
final String event = EventType.recommendListClick.event;
|
||||
|
||||
/// 去重 ID(动态计算)
|
||||
late final String eventId;
|
||||
|
||||
/// 页面标识:page_home(首页), page_user(我的), page_video(视频页)等
|
||||
final String pageKey;
|
||||
|
||||
/// 页面名称:首页, 我的, 视频页
|
||||
final String pageName;
|
||||
|
||||
/// 推荐列表的类型: video(视频),novel(小说),comic(漫画)
|
||||
final RecommendContentTypeEnum recommendContentType;
|
||||
|
||||
/// 推荐引擎的trace_id,未接推荐引擎传空字符串
|
||||
final String recommendTraceId;
|
||||
|
||||
/// 点击内容的ID
|
||||
final String recommendId;
|
||||
|
||||
/// 推荐引擎的trace_info,未接推荐引擎传空字符串
|
||||
final String recommendTraceInfo;
|
||||
|
||||
/// 客户端版本,三位数字点分隔,例如:1.0.1
|
||||
final String clientVersion;
|
||||
|
||||
late final Map<String, dynamic> _commonFields;
|
||||
|
||||
RecommendListClickEvent({
|
||||
required String pageKey,
|
||||
required this.pageName,
|
||||
required this.recommendContentType,
|
||||
required this.recommendId,
|
||||
this.recommendTraceId = '',
|
||||
this.recommendTraceInfo = '',
|
||||
this.clientVersion = '',
|
||||
}) : pageKey = PageNameMapper.normalizeKey(pageKey) {
|
||||
_commonFields = SdkContext.generateCommonFields(event);
|
||||
|
||||
eventId = SdkContext.generateEventIdFromCommonFields(_commonFields, [
|
||||
pageKey,
|
||||
pageName,
|
||||
recommendContentType.label,
|
||||
recommendTraceId,
|
||||
recommendId,
|
||||
recommendTraceInfo,
|
||||
clientVersion,
|
||||
]);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final payload = <String, dynamic>{
|
||||
'page_key': pageKey,
|
||||
'page_name': pageName,
|
||||
'recommend_content_type': recommendContentType.label,
|
||||
'recommend_trace_id': recommendTraceId,
|
||||
'recommend_id': recommendId,
|
||||
'recommend_trace_info': recommendTraceInfo,
|
||||
'client_version': clientVersion,
|
||||
};
|
||||
return {..._commonFields, 'event_id': eventId, 'payload': payload};
|
||||
}
|
||||
|
||||
factory RecommendListClickEvent.fromJson(Map<String, dynamic> json) {
|
||||
final contentTypeStr = json['recommend_content_type'] as String? ?? '';
|
||||
final contentType = RecommendContentTypeEnum(
|
||||
contentTypeStr.isNotEmpty ? contentTypeStr : RecommendContentTypeEnum.VIDEO.label,
|
||||
);
|
||||
return RecommendListClickEvent(
|
||||
pageKey: json['page_key'] as String,
|
||||
pageName: json['page_name'] as String,
|
||||
recommendContentType: contentType,
|
||||
recommendTraceId: json['recommend_trace_id'] as String? ?? '',
|
||||
recommendId: json['recommend_id'] as String? ?? '',
|
||||
recommendTraceInfo: json['recommend_trace_info'] as String? ?? '',
|
||||
clientVersion: json['client_version'] as String? ?? '',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import 'package:analytics_sdk/manager/page_name_manager.dart';
|
||||
import 'package:analytics_sdk/const/event_type.dart';
|
||||
import 'package:analytics_sdk/manager/sdk_context.dart';
|
||||
import 'package:analytics_sdk/enum/recommend_content_type_enum.dart';
|
||||
|
||||
/// 推荐列表内容展示事件(服务端/客户端)
|
||||
class RecommendListViewEvent {
|
||||
/// 事件类型(固定)
|
||||
final String event = EventType.recommendListView.event;
|
||||
|
||||
/// 去重 ID(动态计算)
|
||||
late final String eventId;
|
||||
|
||||
/// 页面标识:home(首页), video_detail(视频详情), user_center(个人中心), discover(发现页)等
|
||||
final String pageKey;
|
||||
|
||||
/// 页面名称:首页, 视频详情页, 个人中心, 发现页
|
||||
final String pageName;
|
||||
|
||||
/// 推荐列表的类型: video(视频),novel(小说),comic(漫画)
|
||||
final RecommendContentTypeEnum recommendContentType;
|
||||
|
||||
/// 推荐引擎的trace_id
|
||||
final String recommendTraceId;
|
||||
|
||||
/// 推荐引擎列表的video_id(视频),novel_id(小说),comic_id(漫画), 可视窗口有多个,使用英文逗号分隔
|
||||
final String recommendId;
|
||||
|
||||
/// 推荐引擎的trace_info,未接推荐引擎传空字符串
|
||||
final String recommendTraceInfo;
|
||||
|
||||
/// 客户端版本,三位数字点分隔,例如:1.0.1
|
||||
final String clientVersion;
|
||||
|
||||
late final Map<String, dynamic> _commonFields;
|
||||
|
||||
RecommendListViewEvent({
|
||||
required String pageKey,
|
||||
required this.pageName,
|
||||
required this.recommendContentType,
|
||||
required this.recommendId,
|
||||
this.recommendTraceId = '',
|
||||
this.recommendTraceInfo = '',
|
||||
this.clientVersion = '',
|
||||
}) : pageKey = PageNameMapper.normalizeKey(pageKey) {
|
||||
_commonFields = SdkContext.generateCommonFields(event);
|
||||
|
||||
eventId = SdkContext.generateEventIdFromCommonFields(_commonFields, [
|
||||
pageKey,
|
||||
pageName,
|
||||
recommendContentType.label,
|
||||
recommendTraceId,
|
||||
recommendId,
|
||||
recommendTraceInfo,
|
||||
clientVersion,
|
||||
]);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final payload = <String, dynamic>{
|
||||
'event': event,
|
||||
'page_key': pageKey,
|
||||
'page_name': pageName,
|
||||
'recommend_content_type': recommendContentType.label,
|
||||
'recommend_trace_id': recommendTraceId,
|
||||
'recommend_id': recommendId,
|
||||
'recommend_trace_info': recommendTraceInfo,
|
||||
'client_version': clientVersion,
|
||||
};
|
||||
return {..._commonFields, 'event_id': eventId, 'payload': payload};
|
||||
}
|
||||
|
||||
factory RecommendListViewEvent.fromJson(Map<String, dynamic> json) {
|
||||
final contentTypeStr = json['recommend_content_type'] as String? ?? '';
|
||||
final contentType = RecommendContentTypeEnum(
|
||||
contentTypeStr.isNotEmpty ? contentTypeStr : RecommendContentTypeEnum.VIDEO.label,
|
||||
);
|
||||
return RecommendListViewEvent(
|
||||
pageKey: json['page_key'] as String,
|
||||
pageName: json['page_name'] as String,
|
||||
recommendContentType: contentType,
|
||||
recommendTraceId: json['recommend_trace_id'] as String,
|
||||
recommendId: json['recommend_id'] as String,
|
||||
recommendTraceInfo: json['recommend_trace_info'] as String? ?? '',
|
||||
clientVersion: json['client_version'] as String? ?? '',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:analytics_sdk/const/event_type.dart';
|
||||
import 'package:analytics_sdk/manager/sdk_context.dart';
|
||||
|
||||
/// SDK 初始化快照事件。
|
||||
///
|
||||
/// `sdk_init` 不携带 `device_env`,硬件与环境快照字段直接写入 payload。
|
||||
class SdkInitEvent {
|
||||
final String event = EventType.sdkInit.event;
|
||||
late final String eventId;
|
||||
|
||||
final Map<String, dynamic> payload;
|
||||
|
||||
late final Map<String, dynamic> _commonFields;
|
||||
|
||||
SdkInitEvent({required Map<String, dynamic> payload})
|
||||
: payload = Map<String, dynamic>.from(payload) {
|
||||
_commonFields = SdkContext.generateCommonFields(event);
|
||||
eventId = SdkContext.generateEventIdFromCommonFields(_commonFields, [
|
||||
jsonEncode(this.payload),
|
||||
]);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
..._commonFields,
|
||||
'event_id': eventId,
|
||||
'payload': payload,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import 'package:analytics_sdk/const/event_type.dart';
|
||||
import 'package:analytics_sdk/manager/sdk_context.dart';
|
||||
import 'package:analytics_sdk/enum/video_content_type_enum.dart';
|
||||
import 'package:analytics_sdk/enum/video_event_enum.dart';
|
||||
|
||||
/// 视频事件 (手动)
|
||||
class VideoEvent {
|
||||
final String event = EventType.videoEvent.event;
|
||||
late final String eventId;
|
||||
|
||||
final String videoId;
|
||||
|
||||
/// 视频标题
|
||||
final String videoTitle;
|
||||
|
||||
/// 视频分类ID
|
||||
final String videoTypeId;
|
||||
|
||||
/// 视频分类名称
|
||||
final String videoTypeName;
|
||||
|
||||
/// 标签名称,多个标签使用英文逗号分隔
|
||||
final String videoTagName;
|
||||
|
||||
/// 标签KEY, 多个标签使用英文逗号分隔
|
||||
final String videoTagKey;
|
||||
|
||||
/// 视频总时长(秒)
|
||||
final int videoDuration;
|
||||
|
||||
/// 本次播放时长(秒)
|
||||
final int playDuration;
|
||||
|
||||
/// 播放进度百分比(0-100)
|
||||
final int playProgress;
|
||||
|
||||
/// 视频行为标识:video_play(播放), video_pause(暂停), video_share(分享), video_complete(播放完成), video_forward(快进), video_rewind(快退)等
|
||||
final VideoEventEnum videoBehavior;
|
||||
|
||||
/// 视频内容类型:video(长视频), short_video(短视频)
|
||||
final VideoContentTypeEnum? videoContentType;
|
||||
|
||||
/// 推荐引擎的trace_id,未接推荐引擎传空字符串
|
||||
final String recommendTraceId;
|
||||
|
||||
/// 老司机媒体资源ID,视频分析日志新加字段,可为空字符串
|
||||
final String mediaId;
|
||||
|
||||
late final Map<String, dynamic> _commonFields;
|
||||
|
||||
VideoEvent(
|
||||
{required this.videoId,
|
||||
required this.videoTitle,
|
||||
required this.videoTypeId,
|
||||
required this.videoTypeName,
|
||||
required this.videoTagKey,
|
||||
required this.videoTagName,
|
||||
required this.videoDuration,
|
||||
required this.playDuration,
|
||||
required this.playProgress,
|
||||
required this.videoBehavior,
|
||||
required this.videoContentType,
|
||||
this.recommendTraceId = '',
|
||||
this.mediaId = ''}) {
|
||||
_commonFields = SdkContext.generateCommonFields(event);
|
||||
|
||||
eventId = SdkContext.generateEventIdFromCommonFields(_commonFields, [
|
||||
videoId,
|
||||
videoTitle,
|
||||
videoTypeId,
|
||||
videoTypeName,
|
||||
videoTagKey,
|
||||
videoTagName,
|
||||
videoDuration.toString(),
|
||||
playDuration.toString(),
|
||||
playProgress.toString(),
|
||||
videoBehavior.key,
|
||||
videoBehavior.name,
|
||||
videoContentType?.label ?? '',
|
||||
recommendTraceId,
|
||||
]);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
Map<String, dynamic> payload = {
|
||||
'video_id': videoId,
|
||||
'video_title': videoTitle,
|
||||
'video_type_id': videoTypeId,
|
||||
'video_type_name': videoTypeName,
|
||||
'video_tag_key': videoTagKey,
|
||||
'video_tag_name': videoTagName,
|
||||
'video_duration': videoDuration,
|
||||
'play_duration': playDuration,
|
||||
'play_progress': playProgress,
|
||||
'video_behavior_key': videoBehavior.key,
|
||||
'video_behavior_name': videoBehavior.name,
|
||||
'video_content_type': videoContentType?.label ?? '',
|
||||
'recommend_trace_id': recommendTraceId,
|
||||
'media_id': mediaId,
|
||||
};
|
||||
return {..._commonFields, 'event_id': eventId, "payload": payload};
|
||||
}
|
||||
|
||||
factory VideoEvent.fromJson(Map<String, dynamic> json) {
|
||||
final behaviorKey = json['video_behavior_key'] as String? ?? '';
|
||||
final behaviorName = json['video_behavior_name'] as String? ?? '';
|
||||
final behavior = VideoEventEnum(
|
||||
behaviorKey.isNotEmpty ? behaviorKey : VideoEventEnum.VIDEO_PLAY.key,
|
||||
behaviorName.isNotEmpty ? behaviorName : VideoEventEnum.VIDEO_PLAY.name,
|
||||
);
|
||||
|
||||
final contentTypeStr = json['video_content_type'] as String? ?? '';
|
||||
final VideoContentTypeEnum? contentType =
|
||||
contentTypeStr.isNotEmpty ? VideoContentTypeEnum(contentTypeStr) : null;
|
||||
|
||||
return VideoEvent(
|
||||
videoId: json['video_id'] as String,
|
||||
videoTitle: json['video_title'] as String,
|
||||
videoTypeId: json['video_type_id'] as String,
|
||||
videoTypeName: json['video_type_name'] as String,
|
||||
videoTagKey: json['video_tag_key'] as String,
|
||||
videoTagName: json['video_tag_name'] as String,
|
||||
videoDuration: json['video_duration'] as int,
|
||||
playDuration: json['play_duration'] as int,
|
||||
playProgress: json['play_progress'] as int,
|
||||
videoBehavior: behavior,
|
||||
videoContentType: contentType,
|
||||
recommendTraceId: json['recommend_trace_id'] as String? ?? '',
|
||||
mediaId: json['media_id'] as String? ?? '',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
/// 广告类型枚举
|
||||
/// 对应埋点中的 ad_type 字段:icon、banner、feed、player、splash、text
|
||||
enum AdTypeEnum {
|
||||
/// 图标广告
|
||||
icon(label: 'icon'),
|
||||
|
||||
/// 横幅广告
|
||||
banner(label: 'banner'),
|
||||
|
||||
/// 信息流广告
|
||||
feed(label: 'feed'),
|
||||
|
||||
/// 播放器内广告
|
||||
player(label: 'player'),
|
||||
|
||||
/// 开屏广告
|
||||
splash(label: 'splash'),
|
||||
|
||||
/// 文字广告
|
||||
text(label: 'text'),
|
||||
|
||||
;
|
||||
|
||||
/// 上报时使用的字符串值
|
||||
final String label;
|
||||
|
||||
const AdTypeEnum({
|
||||
required this.label,
|
||||
});
|
||||
|
||||
/// 根据字符串解析为枚举,未匹配时返回 null
|
||||
static AdTypeEnum? fromLabel(String? value) {
|
||||
if (value == null || value.isEmpty) return null;
|
||||
for (final e in AdTypeEnum.values) {
|
||||
if (e.label == value) return e;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
/// 认证/注册/登录方式枚举
|
||||
/// 对应文档中的 type 字段:phone、deviceid、email、username
|
||||
enum AuthTypeEnum {
|
||||
/// 手机号
|
||||
PHONE(label: 'phone'),
|
||||
|
||||
/// 设备ID
|
||||
DEVICE_ID(label: 'deviceid'),
|
||||
|
||||
/// 邮箱
|
||||
EMAIL(label: 'email'),
|
||||
|
||||
/// 用户名/账号
|
||||
USERNAME(label: 'username'),
|
||||
|
||||
;
|
||||
|
||||
/// 上报时使用的字符串值
|
||||
final String label;
|
||||
|
||||
const AuthTypeEnum({
|
||||
required this.label,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
enum BannerEventEnum{
|
||||
CLICK(label: 'click'),
|
||||
CLOSE(label: 'close'),
|
||||
SHOW(label: 'show');
|
||||
|
||||
final String label;
|
||||
|
||||
const BannerEventEnum({
|
||||
required this.label,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
/// 点击项目类型。
|
||||
///
|
||||
/// 可直接使用预定义常量,也可传入自定义字符串:
|
||||
/// ```dart
|
||||
/// ClickItemTypeEnum.video // 预定义
|
||||
/// ClickItemTypeEnum('manga', '漫画类') // 自定义
|
||||
/// ```
|
||||
///
|
||||
/// 字段规则(debug 模式下触发 assert):
|
||||
/// - [key]:仅限字母、数字、`_`、`-`
|
||||
/// - [name]:字母、数字、`_`、`-`、中文
|
||||
class ClickItemTypeEnum {
|
||||
final String key;
|
||||
final String name;
|
||||
|
||||
const ClickItemTypeEnum._internal(this.key, this.name);
|
||||
|
||||
/// 自定义类型。[key] 仅限字母/数字/`_`/`-`;[name] 允许中文。
|
||||
factory ClickItemTypeEnum(String key, String name) {
|
||||
assert(
|
||||
RegExp(r'^[A-Za-z0-9_-]+$').hasMatch(key),
|
||||
'ClickItemTypeEnum.key 仅支持字母、数字、_ 和 -,实际传入: "$key"',
|
||||
);
|
||||
assert(
|
||||
RegExp(r'^[\u4e00-\u9fa5A-Za-z0-9_-]+$').hasMatch(name),
|
||||
'ClickItemTypeEnum.name 仅支持字母、数字、_、- 和中文,实际传入: "$name"',
|
||||
);
|
||||
return ClickItemTypeEnum._internal(key, name);
|
||||
}
|
||||
|
||||
static const VIDEO = ClickItemTypeEnum._internal('video', '视频');
|
||||
static const NOVEL = ClickItemTypeEnum._internal('novel', '小说');
|
||||
static const COMIC = ClickItemTypeEnum._internal('comic', '漫画');
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
/// 金币消耗原因枚举
|
||||
/// 对应文档中的 consume_reason_key 字段:
|
||||
/// - video_unlock(视频解锁)
|
||||
/// - gift_send(礼物赠送)
|
||||
/// - content_purchase(内容购买)
|
||||
enum ConsumeReasonEnum {
|
||||
/// 视频解锁
|
||||
VIDEO_UNLOCK(label: 'video_unlock'),
|
||||
|
||||
/// 礼物赠送
|
||||
GIFT_SEND(label: 'gift_send'),
|
||||
|
||||
/// 内容购买
|
||||
CONTENT_PURCHASE(label: 'content_purchase'),
|
||||
|
||||
;
|
||||
|
||||
/// 上报时使用的字符串值
|
||||
final String label;
|
||||
|
||||
const ConsumeReasonEnum({
|
||||
required this.label,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
/// 货币类型枚举
|
||||
/// 对应文档中的 currency 字段:CNY(人民币), USD(美元)
|
||||
enum CurrencyEnum {
|
||||
/// 人民币
|
||||
CNY(label: 'CNY'),
|
||||
|
||||
/// 美元
|
||||
USD(label: 'USD'),
|
||||
|
||||
;
|
||||
|
||||
/// 上报时使用的字符串值
|
||||
final String label;
|
||||
|
||||
const CurrencyEnum({
|
||||
required this.label,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
enum DeviceEnum {
|
||||
Android(label: 'Android'),
|
||||
iOS(label: 'iOS'),
|
||||
PC(label: 'PC');
|
||||
|
||||
final String label;
|
||||
|
||||
const DeviceEnum({
|
||||
required this.label,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
/// 订单类型枚举
|
||||
/// 对应文档中的 order_type 字段:coin_purchase(金币购买), vip_subscription(VIP订阅)
|
||||
enum OrderTypeEnum {
|
||||
/// 金币购买
|
||||
COIN_PURCHASE(label: 'coin_purchase'),
|
||||
|
||||
/// VIP 订阅
|
||||
VIP_SUBSCRIPTION(label: 'vip_subscription'),
|
||||
|
||||
;
|
||||
|
||||
/// 上报时使用的字符串值
|
||||
final String label;
|
||||
|
||||
const OrderTypeEnum({
|
||||
required this.label,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
/// 预定义页面路由枚举,提供标准 [key](页面唯一标识)和 [name](展示名称)。
|
||||
///
|
||||
/// **用法示例**
|
||||
///
|
||||
/// ```dart
|
||||
/// // 上报导航事件
|
||||
/// AnalyticsSdk.instance.trackNavigation(
|
||||
/// pageKey: AppPageEnum.video.key,
|
||||
/// pageName: AppPageEnum.video.name,
|
||||
/// );
|
||||
///
|
||||
/// // 配置底部/顶部 Tab
|
||||
/// BottomNavigationBar(...).withAnalytics(tabs: [
|
||||
/// AnalyticsTab(AppPageEnum.home.key, AppPageEnum.home.name),
|
||||
/// AnalyticsTab(AppPageEnum.video.key, AppPageEnum.video.name),
|
||||
/// AnalyticsTab(AppPageEnum.mine.key, AppPageEnum.mine.name),
|
||||
/// ]);
|
||||
///
|
||||
/// // PageView 子页面同步
|
||||
/// AnalyticsSdk.instance.updateCurrentPage(
|
||||
/// pageKey: AppPageEnum.videoList.key,
|
||||
/// pageName: AppPageEnum.videoList.name,
|
||||
/// );
|
||||
/// ```
|
||||
///
|
||||
/// **自定义页面**:直接传入字符串即可,无需扩展枚举:
|
||||
/// ```dart
|
||||
/// AnalyticsSdk.instance.trackNavigation(
|
||||
/// pageKey: 'live_room',
|
||||
/// pageName: '直播间',
|
||||
/// );
|
||||
/// ```
|
||||
enum AppPageEnum {
|
||||
// ── 公共类 ──────────────────────────────────────────────────
|
||||
|
||||
/// 启动页
|
||||
launch('launch', '启动页'),
|
||||
|
||||
/// 引导页(新用户首次打开时展示)
|
||||
guide('guide', '引导页'),
|
||||
|
||||
/// 首页(底部导航第一 Tab 常用值)
|
||||
home('home', '首页'),
|
||||
|
||||
/// 主页
|
||||
mainPage('main_page', '主页'),
|
||||
|
||||
/// 发现页
|
||||
discover('discover', '发现页'),
|
||||
|
||||
/// 搜索页(搜索入口页,含搜索框和热搜)
|
||||
search('search', '搜索页'),
|
||||
|
||||
/// 搜索结果页
|
||||
searchResult('search_result', '搜索结果页'),
|
||||
|
||||
/// 应用中心
|
||||
appCenter('app_center', '应用中心'),
|
||||
|
||||
/// 社区页
|
||||
community('community', '社区页'),
|
||||
|
||||
/// 广场页
|
||||
square('square', '广场页'),
|
||||
|
||||
/// 我的(底部导航"我的"Tab)
|
||||
mine('mine', '我的'),
|
||||
|
||||
/// 个人中心(登录用户的详情页)
|
||||
userCenter('user_center', '个人中心'),
|
||||
|
||||
/// 用户主页(查看他人主页)
|
||||
userHome('user_home', '用户主页'),
|
||||
|
||||
/// 作者主页
|
||||
authorHome('author_home', '作者主页'),
|
||||
|
||||
/// 登录页
|
||||
login('login', '登录页'),
|
||||
|
||||
/// 注册页
|
||||
register('register', '注册页'),
|
||||
|
||||
/// 消息中心
|
||||
messageCenter('message_center', '消息中心'),
|
||||
|
||||
/// 设置页
|
||||
setting('setting', '设置页'),
|
||||
|
||||
/// 充值页
|
||||
recharge('recharge', '充值页'),
|
||||
|
||||
/// 充值记录页
|
||||
rechargeRecord('recharge_record', '充值记录页'),
|
||||
|
||||
/// 我的收藏
|
||||
mineCollect('mine_collect', '我的收藏'),
|
||||
|
||||
/// 我的历史
|
||||
mineHistory('mine_history', '我的历史'),
|
||||
|
||||
/// 会员中心页
|
||||
mineVip('mine_vip', '会员中心页'),
|
||||
|
||||
// ── 视频类 ──────────────────────────────────────────────────
|
||||
|
||||
/// 视频列表页
|
||||
videoList('video_list', '视频列表页'),
|
||||
|
||||
/// 视频分类页
|
||||
videoCategory('video_category', '视频分类页'),
|
||||
|
||||
/// 视频榜单页
|
||||
videoRank('video_rank', '视频榜单页'),
|
||||
|
||||
/// 视频搜索页
|
||||
videoSearch('video_search', '视频搜索页'),
|
||||
|
||||
/// 视频详情页
|
||||
videoDetail('video_detail', '视频详情页'),
|
||||
|
||||
/// 视频播放页
|
||||
videoPlay('video_play', '视频播放页'),
|
||||
|
||||
// ── 小说类 ──────────────────────────────────────────────────
|
||||
|
||||
/// 小说列表页
|
||||
novelList('novel_list', '小说列表页'),
|
||||
|
||||
/// 小说分类页
|
||||
novelCategory('novel_category', '小说分类页'),
|
||||
|
||||
/// 小说榜单页
|
||||
novelRank('novel_rank', '小说榜单页'),
|
||||
|
||||
/// 小说详情页
|
||||
novelDetail('novel_detail', '小说详情页'),
|
||||
|
||||
/// 小说阅读页
|
||||
novelRead('novel_read', '小说阅读页'),
|
||||
|
||||
/// 小说章节页
|
||||
novelChapterList('novel_chapter_list', '小说章节页'),
|
||||
|
||||
// ── 漫画类 ──────────────────────────────────────────────────
|
||||
|
||||
/// 漫画列表页
|
||||
comicList('comic_list', '漫画列表页'),
|
||||
|
||||
/// 漫画分类页
|
||||
comicCategory('comic_category', '漫画分类页'),
|
||||
|
||||
/// 漫画榜单页
|
||||
comicRank('comic_rank', '漫画榜单页'),
|
||||
|
||||
/// 漫画详情页
|
||||
comicDetail('comic_detail', '漫画详情页'),
|
||||
|
||||
/// 漫画阅读页
|
||||
comicRead('comic_read', '漫画阅读页'),
|
||||
|
||||
/// 漫画章节页
|
||||
comicChapterList('comic_chapter_list', '漫画章节页');
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
const AppPageEnum(this.key, this.name);
|
||||
|
||||
/// 页面唯一标识,用于 `page_key` 字段。
|
||||
final String key;
|
||||
|
||||
/// 页面展示名称,用于 `page_name` 字段。
|
||||
final String name;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
/// 支付方式枚举
|
||||
/// 对应文档中的 pay_type 字段:wechat, alipay, bank_card, apple_pay 等
|
||||
enum PayTypeEnum {
|
||||
/// 微信支付
|
||||
WECHAT(label: 'wechat'),
|
||||
|
||||
/// 支付宝
|
||||
ALIPAY(label: 'alipay'),
|
||||
|
||||
/// 银行卡
|
||||
BANK_CARD(label: 'bank_card'),
|
||||
|
||||
/// Apple Pay
|
||||
APPLE_PAY(label: 'apple_pay'),
|
||||
|
||||
;
|
||||
|
||||
/// 上报时使用的字符串值
|
||||
final String label;
|
||||
|
||||
const PayTypeEnum({
|
||||
required this.label,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
/// 阅读行为类型,用于小说和漫画事件。
|
||||
///
|
||||
/// 可使用预定义常量,也可传入自定义字符串:
|
||||
/// ```dart
|
||||
/// ReadBehaviorEnum.view
|
||||
/// ReadBehaviorEnum('custom_key', '自定义行为')
|
||||
/// ```
|
||||
///
|
||||
/// 字段规则(debug 模式下触发 assert):
|
||||
/// - [key]:仅限字母、数字、`_`、`-`
|
||||
/// - [name]:字母、数字、`_`、`-`、中文
|
||||
class ReadBehaviorEnum {
|
||||
final String key;
|
||||
final String name;
|
||||
|
||||
const ReadBehaviorEnum._internal(this.key, this.name);
|
||||
|
||||
/// 自定义行为类型。[key] 仅限字母/数字/`_`/`-`;[name] 允许中文。
|
||||
factory ReadBehaviorEnum(String key, String name) {
|
||||
assert(
|
||||
RegExp(r'^[A-Za-z0-9_-]+$').hasMatch(key),
|
||||
'ReadBehaviorEnum.key 仅支持字母、数字、_ 和 -,实际传入: "$key"',
|
||||
);
|
||||
assert(
|
||||
RegExp(r'^[\u4e00-\u9fa5A-Za-z0-9_-]+$').hasMatch(name),
|
||||
'ReadBehaviorEnum.name 仅支持字母、数字、_、- 和中文,实际传入: "$name"',
|
||||
);
|
||||
return ReadBehaviorEnum._internal(key, name);
|
||||
}
|
||||
|
||||
static const VIEW = ReadBehaviorEnum._internal('view', '展示');
|
||||
static const PAGE_NEXT = ReadBehaviorEnum._internal('page_next', '下一页');
|
||||
static const PAGE_PREV = ReadBehaviorEnum._internal('page_prev', '上一页');
|
||||
static const COMPLETE = ReadBehaviorEnum._internal('complete', '读完');
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
/// 推荐列表内容类型,用于推荐列表展示/点击事件。
|
||||
///
|
||||
/// 可使用预定义常量,也可传入自定义字符串:
|
||||
/// ```dart
|
||||
/// RecommendContentTypeEnum.video
|
||||
/// RecommendContentTypeEnum('podcast')
|
||||
/// ```
|
||||
///
|
||||
/// 字段规则(debug 模式下触发 assert):
|
||||
/// - [label]:仅限字母、数字、`_`、`-`
|
||||
class RecommendContentTypeEnum {
|
||||
final String label;
|
||||
|
||||
const RecommendContentTypeEnum._internal(this.label);
|
||||
|
||||
/// 自定义内容类型。[label] 仅限字母/数字/`_`/`-`。
|
||||
factory RecommendContentTypeEnum(String label) {
|
||||
assert(
|
||||
RegExp(r'^[A-Za-z0-9_-]+$').hasMatch(label),
|
||||
'RecommendContentTypeEnum.label 仅支持字母、数字、_ 和 -,实际传入: "$label"',
|
||||
);
|
||||
return RecommendContentTypeEnum._internal(label);
|
||||
}
|
||||
|
||||
static const VIDEO = RecommendContentTypeEnum._internal('video');
|
||||
static const NOVEL = RecommendContentTypeEnum._internal('novel');
|
||||
static const COMIC = RecommendContentTypeEnum._internal('comic');
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
/// 用户类型,用于上报用户身份信息。
|
||||
///
|
||||
/// 可使用预定义常量,也可传入自定义字符串:
|
||||
/// ```dart
|
||||
/// UserTypeEnum.normal
|
||||
/// UserTypeEnum.vip
|
||||
/// UserTypeEnum('svip')
|
||||
/// ```
|
||||
///
|
||||
/// 字段规则(debug 模式下触发 assert):
|
||||
/// - [label]:仅限字母、数字、`_`、`-`
|
||||
class UserTypeEnum {
|
||||
final String label;
|
||||
|
||||
const UserTypeEnum._internal(this.label);
|
||||
|
||||
/// 自定义用户类型。[label] 仅限字母/数字/`_`/`-`。
|
||||
factory UserTypeEnum(String label) {
|
||||
assert(
|
||||
RegExp(r'^[A-Za-z0-9_-]+$').hasMatch(label),
|
||||
'UserTypeEnum.label 仅支持字母、数字、_ 和 -,实际传入: "$label"',
|
||||
);
|
||||
return UserTypeEnum._internal(label);
|
||||
}
|
||||
|
||||
static const normal = UserTypeEnum._internal('normal');
|
||||
static const vip = UserTypeEnum._internal('vip');
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
/// 视频内容类型,用于视频事件。
|
||||
///
|
||||
/// 可使用预定义常量,也可传入自定义字符串:
|
||||
/// ```dart
|
||||
/// VideoContentTypeEnum.video
|
||||
/// VideoContentTypeEnum('live')
|
||||
/// ```
|
||||
///
|
||||
/// 字段规则(debug 模式下触发 assert):
|
||||
/// - [label]:仅限字母、数字、`_`、`-`
|
||||
class VideoContentTypeEnum {
|
||||
final String label;
|
||||
|
||||
const VideoContentTypeEnum._internal(this.label);
|
||||
|
||||
/// 自定义内容类型。[label] 仅限字母/数字/`_`/`-`。
|
||||
factory VideoContentTypeEnum(String label) {
|
||||
assert(
|
||||
RegExp(r'^[A-Za-z0-9_-]+$').hasMatch(label),
|
||||
'VideoContentTypeEnum.label 仅支持字母、数字、_ 和 -,实际传入: "$label"',
|
||||
);
|
||||
return VideoContentTypeEnum._internal(label);
|
||||
}
|
||||
|
||||
/// 长视频
|
||||
static const video = VideoContentTypeEnum._internal('video');
|
||||
|
||||
/// 短视频
|
||||
static const shortVideo = VideoContentTypeEnum._internal('short_video');
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
/// 视频行为类型,用于视频事件。
|
||||
///
|
||||
/// 可使用预定义常量,也可传入自定义字符串:
|
||||
/// ```dart
|
||||
/// VideoEventEnum.play
|
||||
/// VideoEventEnum('custom_key', '自定义行为')
|
||||
/// ```
|
||||
///
|
||||
/// 字段规则(debug 模式下触发 assert):
|
||||
/// - [key]:仅限字母、数字、`_`、`-`
|
||||
/// - [name]:字母、数字、`_`、`-`、中文
|
||||
class VideoEventEnum {
|
||||
final String key;
|
||||
final String name;
|
||||
|
||||
const VideoEventEnum._internal(this.key, this.name);
|
||||
|
||||
/// 自定义行为类型。[key] 仅限字母/数字/`_`/`-`;[name] 允许中文。
|
||||
factory VideoEventEnum(String key, String name) {
|
||||
assert(
|
||||
RegExp(r'^[A-Za-z0-9_-]+$').hasMatch(key),
|
||||
'VideoEventEnum.key 仅支持字母、数字、_ 和 -,实际传入: "$key"',
|
||||
);
|
||||
assert(
|
||||
RegExp(r'^[\u4e00-\u9fa5A-Za-z0-9_-]+$').hasMatch(name),
|
||||
'VideoEventEnum.name 仅支持字母、数字、_、- 和中文,实际传入: "$name"',
|
||||
);
|
||||
return VideoEventEnum._internal(key, name);
|
||||
}
|
||||
|
||||
static const VIDEO_VIEW = VideoEventEnum._internal('video_view', '视频展示');
|
||||
static const VIDEO_PLAY = VideoEventEnum._internal('video_play', '视频播放');
|
||||
static const VIDEO_PAUSE = VideoEventEnum._internal('video_pause', '暂停');
|
||||
static const VIDEO_SHARE = VideoEventEnum._internal('video_share', '分享');
|
||||
static const VIDEO_COMPLETE = VideoEventEnum._internal('video_complete', '播放完成');
|
||||
static const VIDEO_FORWARD = VideoEventEnum._internal('video_forward', '快进');
|
||||
static const VIDEO_REWIND = VideoEventEnum._internal('video_rewind', '快退');
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
// lib/extension/tab_analytics_extension.dart
|
||||
import 'package:analytics_sdk/entity/analytics_tab.dart';
|
||||
import 'package:analytics_sdk/widget/analytics_bottom_nav_wrapper.dart';
|
||||
import 'package:analytics_sdk/widget/analytics_tab_bar_wrapper.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// 为 [BottomNavigationBar] 提供 analytics 自动埋点能力。
|
||||
///
|
||||
/// 示例:
|
||||
/// ```dart
|
||||
/// BottomNavigationBar(
|
||||
/// currentIndex: _index,
|
||||
/// onTap: (i) => setState(() => _index = i),
|
||||
/// items: const [...],
|
||||
/// ).withAnalytics(tabs: [
|
||||
/// AnalyticsTab('home', '首页'),
|
||||
/// AnalyticsTab('discover'), // pageName 走 PageNameMapper 自动解析
|
||||
/// ])
|
||||
/// ```
|
||||
extension AnalyticsBottomNavExtension on BottomNavigationBar {
|
||||
/// 切换 Tab 时自动上报 AppPageViewEvent + NavigationEvent,
|
||||
/// 并更新 PageLifecycleObserver.currentPageKey。
|
||||
Widget withAnalytics({required List<AnalyticsTab> tabs}) {
|
||||
return AnalyticsBottomNavWrapper(
|
||||
tabs: tabs,
|
||||
child: this,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 为 [TabBar] 提供 analytics 轻导航埋点能力。
|
||||
///
|
||||
/// 示例:
|
||||
/// ```dart
|
||||
/// TabBar(
|
||||
/// controller: _tabController,
|
||||
/// tabs: const [Tab(text: '视频'), Tab(text: '小说')],
|
||||
/// ).withAnalytics(tabs: [
|
||||
/// AnalyticsTab('video', '视频'),
|
||||
/// AnalyticsTab('novel'),
|
||||
/// ])
|
||||
/// ```
|
||||
extension AnalyticsTabBarExtension on TabBar {
|
||||
/// 切换 Tab 时自动上报 NavigationEvent(轻导航,不更新 currentPageKey)。
|
||||
AnalyticsTabBarWrapper withAnalytics({required List<AnalyticsTab> tabs}) {
|
||||
return AnalyticsTabBarWrapper(
|
||||
tabs: tabs,
|
||||
child: this,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import 'package:analytics_sdk/config/sdk_config.dart';
|
||||
import 'package:analytics_sdk/utils/logger.dart';
|
||||
|
||||
/// 广告展示去重管理器
|
||||
///
|
||||
/// 以页面为粒度做去重:同一页面访问内不重复上报同一广告,
|
||||
/// 页面退出后去重状态随之清除,下次进入同一页面广告可重新上报。
|
||||
/// 新 session 时调用 [clear] 清除所有页面记录。
|
||||
class AdImpressionManager {
|
||||
static final AdImpressionManager instance = AdImpressionManager._internal();
|
||||
|
||||
factory AdImpressionManager() => instance;
|
||||
|
||||
AdImpressionManager._internal();
|
||||
|
||||
/// 页面级广告去重表:pageKey → 已上报 adId 集合
|
||||
final Map<String, Set<String>> _reportedAdIdsByPage = {};
|
||||
|
||||
/// 广告 SDK 曝光已上报的 trace_id;同一 session 内全局去重。
|
||||
final Set<String> _reportedSdkTraceIds = {};
|
||||
|
||||
/// 当前已存储的广告ID总数(跨所有页面)
|
||||
int get reportedCount =>
|
||||
_reportedAdIdsByPage.values.fold(0, (sum, s) => sum + s.length);
|
||||
|
||||
/// 批量检查多个广告ID,返回在指定页面内尚未上报的列表(不修改状态)
|
||||
List<String> getUnreportedAdIds(String adIds, {required String pageKey}) {
|
||||
if (adIds.isEmpty) return [];
|
||||
|
||||
final adIdList = adIds
|
||||
.split(',')
|
||||
.map((id) => id.trim())
|
||||
.where((id) => id.isNotEmpty)
|
||||
.toList();
|
||||
|
||||
if (adIdList.isEmpty) return [];
|
||||
|
||||
final pageSet = _reportedAdIdsByPage[pageKey];
|
||||
if (pageSet == null || pageSet.isEmpty) return adIdList;
|
||||
|
||||
return adIdList.where((id) => !pageSet.contains(id)).toList();
|
||||
}
|
||||
|
||||
/// 在指定页面内批量标记广告ID为已上报
|
||||
void markAsReportedBatch(String adIds, {required String pageKey}) {
|
||||
if (adIds.isEmpty) return;
|
||||
|
||||
final adIdList =
|
||||
adIds.split(',').map((id) => id.trim()).where((id) => id.isNotEmpty);
|
||||
|
||||
final pageSet = _reportedAdIdsByPage.putIfAbsent(pageKey, () => {});
|
||||
for (final id in adIdList) {
|
||||
if (reportedCount >= SdkConfig.maxAdImpressionCapacity) {
|
||||
Logger.adImpressionManager(
|
||||
'广告去重表已达上限(${SdkConfig.maxAdImpressionCapacity}),停止写入,跳过 id: $id',
|
||||
level: LogLevel.warn);
|
||||
break;
|
||||
}
|
||||
pageSet.add(id);
|
||||
}
|
||||
}
|
||||
|
||||
/// 指定 trace_id 是否已作为广告 SDK 曝光上报。
|
||||
bool isSdkTraceReported(String traceId) {
|
||||
return _reportedSdkTraceIds.contains(traceId.trim());
|
||||
}
|
||||
|
||||
/// 在广告 SDK 曝光成功入队后标记其 trace_id。
|
||||
void markSdkTraceReported(String traceId) {
|
||||
final normalized = traceId.trim();
|
||||
if (normalized.isEmpty || isSdkTraceReported(normalized)) return;
|
||||
if (_reportedSdkTraceIds.length >= SdkConfig.maxAdImpressionCapacity) {
|
||||
Logger.adImpressionManager(
|
||||
'广告 SDK 曝光 trace_id 去重表已达上限(${SdkConfig.maxAdImpressionCapacity}),停止写入: $normalized',
|
||||
level: LogLevel.warn,
|
||||
);
|
||||
return;
|
||||
}
|
||||
_reportedSdkTraceIds.add(normalized);
|
||||
}
|
||||
|
||||
/// 清除指定页面的广告去重记录(页面退出时调用,下次进入可重新上报)
|
||||
void clearPage(String pageKey) {
|
||||
if (_reportedAdIdsByPage.remove(pageKey) != null) {
|
||||
Logger.adImpressionManager('页面 "$pageKey" 广告去重记录已清除');
|
||||
}
|
||||
}
|
||||
|
||||
/// 清除所有页面的广告去重记录(新 session 开始时调用)
|
||||
void clear() {
|
||||
_reportedAdIdsByPage.clear();
|
||||
_reportedSdkTraceIds.clear();
|
||||
}
|
||||
|
||||
// ── 以下方法供测试使用 ──────────────────────────────
|
||||
|
||||
/// 检查广告ID在指定页面内是否已上报(不修改状态)
|
||||
bool isReported(String adId, {required String pageKey}) {
|
||||
return _reportedAdIdsByPage[pageKey]?.contains(adId.trim()) ?? false;
|
||||
}
|
||||
|
||||
/// [isReported] 的别名,用于断言
|
||||
bool contains(String adId, {required String pageKey}) =>
|
||||
isReported(adId, pageKey: pageKey);
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'package:analytics_sdk/config/sdk_config.dart';
|
||||
import 'package:analytics_sdk/utils/logger.dart';
|
||||
import 'package:analytics_sdk/utils/platform_storage.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
/// 域名管理器:对业务方传入的域名列表进行并发测速,选择最快的上报域名
|
||||
class DomainManager {
|
||||
static final DomainManager instance = DomainManager._internal();
|
||||
|
||||
factory DomainManager() => instance;
|
||||
|
||||
DomainManager._internal();
|
||||
|
||||
List<String> _reportDomains = [];
|
||||
String? _fastestDomain;
|
||||
|
||||
/// 每次 initWithDomains / reset 时递增,用于丢弃过期测速结果
|
||||
int _generation = 0;
|
||||
|
||||
http.Client? _httpClient;
|
||||
http.Client get _client => _httpClient ??= http.Client();
|
||||
|
||||
/// 域名更新回调,当最快域名选出后调用
|
||||
Function(String? fastestDomain)? onFastestDomainChanged;
|
||||
|
||||
/// 获取最快的上报域名
|
||||
String? get fastestDomain => _fastestDomain;
|
||||
|
||||
/// 获取所有上报域名
|
||||
List<String> get reportDomains => List.unmodifiable(_reportDomains);
|
||||
|
||||
/// 传入已解密的域名列表,执行并发测速并选出最快域名
|
||||
///
|
||||
/// [domains] 每项为带协议的完整域名,如 "https://api.example.com"
|
||||
///
|
||||
/// 注意:此方法设计为 fire-and-forget,调用方无需 await,
|
||||
/// 测速完成后通过 [onFastestDomainChanged] 回调通知结果。
|
||||
Future<String?> initWithDomains(List<String> domains) async {
|
||||
// 记录本次调用的代次,测速完成后用于判断结果是否已过期
|
||||
final gen = ++_generation;
|
||||
try {
|
||||
if (domains.isEmpty) {
|
||||
Logger.domainManager('域名列表为空,跳过测速', level: LogLevel.warn);
|
||||
return null;
|
||||
}
|
||||
|
||||
_reportDomains = domains;
|
||||
Logger.domainManager('开始域名测速,共 ${domains.length} 个: $domains');
|
||||
|
||||
String? fastest;
|
||||
if (domains.length == 1) {
|
||||
fastest = domains.first;
|
||||
Logger.domainManager('只有一个域名,跳过测速: $fastest');
|
||||
} else {
|
||||
fastest = await _speedTestAndSelect(domains);
|
||||
Logger.domainManager('测速完成,最快域名: $fastest');
|
||||
}
|
||||
|
||||
// 若 reset() 或新一轮 initWithDomains 已被调用,丢弃本次过期结果
|
||||
if (gen != _generation) {
|
||||
Logger.domainManager('测速结果已过期(generation 不匹配),忽略本次结果');
|
||||
return null;
|
||||
}
|
||||
|
||||
_fastestDomain = fastest;
|
||||
// 测速完成后持久化域名列表(供下次 init 时解密失败时作为降级)
|
||||
_saveDomainCache();
|
||||
if (onFastestDomainChanged != null && _fastestDomain != null) {
|
||||
try {
|
||||
onFastestDomainChanged!(_fastestDomain);
|
||||
} catch (e) {
|
||||
Logger.domainManager('域名更新回调异常: $e');
|
||||
}
|
||||
}
|
||||
return _fastestDomain;
|
||||
} catch (e) {
|
||||
Logger.domainManager('域名测速失败: $e', level: LogLevel.warn);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// 重置域名管理器状态,供 re-init 时使用
|
||||
///
|
||||
/// 清除内存中的域名列表和最快域名,不关闭 HttpClient(复用连接池)。
|
||||
/// 不删除磁盘缓存(缓存代表最近已知可用状态,跨 init 保留)。
|
||||
void reset() {
|
||||
_generation++; // 使所有进行中的测速结果失效
|
||||
_fastestDomain = null;
|
||||
_reportDomains = [];
|
||||
onFastestDomainChanged = null; // 防止旧回调在新一轮测速前残留
|
||||
Logger.domainManager('域名管理器状态已重置');
|
||||
}
|
||||
|
||||
/// 释放 HttpClient 资源,通常在 SDK dispose 时调用
|
||||
void dispose() {
|
||||
_httpClient?.close();
|
||||
_httpClient = null;
|
||||
Logger.domainManager('域名管理器已销毁');
|
||||
}
|
||||
|
||||
/// 从磁盘加载上次缓存的域名列表
|
||||
///
|
||||
/// 返回非空列表时可直接传入 [initWithDomains]。
|
||||
/// 无缓存文件或读取失败时返回空列表(不抛出异常)。
|
||||
Future<List<String>> loadCachedDomains() async {
|
||||
try {
|
||||
final content =
|
||||
await PlatformStorage.read(SdkConfig.domainCacheFileName);
|
||||
if (content == null) return [];
|
||||
final decoded = jsonDecode(content);
|
||||
if (decoded is List) {
|
||||
return (decoded as List)
|
||||
.map<String>((item) => item.toString().trim())
|
||||
.where((d) => d.isNotEmpty)
|
||||
.toList();
|
||||
}
|
||||
return [];
|
||||
} catch (e) {
|
||||
Logger.domainManager('加载域名缓存失败: $e');
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 私有方法 ====================
|
||||
|
||||
/// 将当前 _reportDomains 写入磁盘缓存(fire-and-forget)
|
||||
void _saveDomainCache() {
|
||||
if (_reportDomains.isEmpty) return;
|
||||
final domainsSnapshot = List<String>.from(_reportDomains);
|
||||
Future(() async {
|
||||
try {
|
||||
await PlatformStorage.write(
|
||||
SdkConfig.domainCacheFileName,
|
||||
jsonEncode(domainsSnapshot),
|
||||
);
|
||||
Logger.domainManager('域名缓存已写入: $domainsSnapshot');
|
||||
} catch (e) {
|
||||
Logger.domainManager('写入域名缓存失败: $e');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// 对所有域名进行并发测速,返回最快的域名
|
||||
Future<String?> _speedTestAndSelect(List<String> domains) async {
|
||||
if (domains.isEmpty) return null;
|
||||
|
||||
final speedResults = <String, Duration>{};
|
||||
|
||||
final futures = domains.map((domain) async {
|
||||
try {
|
||||
final speed = await _speedTest(domain);
|
||||
if (speed != null) {
|
||||
speedResults[domain] = speed;
|
||||
Logger.domainManager('域名 $domain 测速: ${speed.inMilliseconds}ms');
|
||||
}
|
||||
} catch (e) {
|
||||
Logger.domainManager('域名 $domain 测速失败: $e');
|
||||
}
|
||||
});
|
||||
|
||||
await Future.wait(futures);
|
||||
|
||||
if (speedResults.isEmpty) {
|
||||
Logger.domainManager('所有域名测速失败,回退到使用第一个域名作为上报域名',
|
||||
level: LogLevel.warn);
|
||||
return domains.first;
|
||||
}
|
||||
|
||||
final fastest =
|
||||
speedResults.entries.reduce((a, b) => a.value < b.value ? a : b);
|
||||
|
||||
Logger.domainManager(
|
||||
'最快域名: ${fastest.key} (${fastest.value.inMilliseconds}ms)');
|
||||
return fastest.key;
|
||||
}
|
||||
|
||||
/// 测速单个域名,返回连接耗时;连接超时或网络异常返回 null
|
||||
///
|
||||
/// 仅 HTTP 200 视为可达。
|
||||
/// 非 200(3xx 重定向、4xx 客户端错误、5xx 服务端错误)/ 连接失败 / 超时 视为不可达,返回 null。
|
||||
Future<Duration?> _speedTest(String domain) async {
|
||||
try {
|
||||
final uri = Uri.parse(domain);
|
||||
final startTime = DateTime.now();
|
||||
final response =
|
||||
await _client.get(uri).timeout(SdkConfig.speedTestTimeout);
|
||||
final elapsed = DateTime.now().difference(startTime);
|
||||
if (response.statusCode != 200) {
|
||||
Logger.domainManager(
|
||||
'域名 $domain 返回 ${response.statusCode},视为不可达',
|
||||
level: LogLevel.warn);
|
||||
return null;
|
||||
}
|
||||
Logger.domainManager(
|
||||
'域名 $domain 可达,状态码: ${response.statusCode}');
|
||||
return elapsed;
|
||||
} catch (e) {
|
||||
Logger.domainManager('域名 $domain 测速失败: $e', level: LogLevel.warn);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export 'event_persistence_native.dart'
|
||||
// ignore: uri_does_not_exist
|
||||
if (dart.library.html) 'event_persistence_web.dart';
|
||||
|
||||
// EventPersistenceImpl 由条件导入文件提供
|
||||
// Native:lib/manager/event_persistence_native.dart
|
||||
// Web :lib/manager/event_persistence_web.dart
|
||||
@@ -0,0 +1,398 @@
|
||||
import 'dart:async';
|
||||
import 'dart:collection';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:analytics_sdk/config/sdk_config.dart';
|
||||
import 'package:analytics_sdk/utils/logger.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:synchronized/synchronized.dart';
|
||||
|
||||
/// Native 平台事件持久化:基于本地文件 + tombstone 机制。
|
||||
class EventPersistenceImpl {
|
||||
String? _cacheFilePath;
|
||||
String? _debugReportEventLogFilePath;
|
||||
String? _tombstoneFilePath;
|
||||
String? _inFlightFilePath;
|
||||
final Lock _cacheLock = Lock();
|
||||
final List<Map<String, dynamic>> _cacheBuffer = [];
|
||||
static int get _cacheBufferThreshold => SdkConfig.cacheBufferThreshold;
|
||||
static Duration get _cacheFlushInterval => SdkConfig.cacheFlushInterval;
|
||||
Timer? _cacheFlushTimer;
|
||||
|
||||
Future<void> init() async {
|
||||
try {
|
||||
final dir = await getApplicationDocumentsDirectory();
|
||||
_cacheFilePath = '${dir.path}/${SdkConfig.cacheFileName}';
|
||||
_debugReportEventLogFilePath =
|
||||
'${dir.path}/${SdkConfig.debugReportEventLogFileName}';
|
||||
_tombstoneFilePath = '${dir.path}/${SdkConfig.tombstoneFileName}';
|
||||
_inFlightFilePath = '${dir.path}/data_plus_events_inflight.txt';
|
||||
} catch (e) {
|
||||
Logger.analyticsSdk('持久化初始化失败: $e');
|
||||
_cacheFilePath = null;
|
||||
_debugReportEventLogFilePath = null;
|
||||
_tombstoneFilePath = null;
|
||||
}
|
||||
}
|
||||
|
||||
String? get debugReportEventLogFilePath =>
|
||||
SdkConfig.enableDebugReportLog ? _debugReportEventLogFilePath : null;
|
||||
|
||||
void appendDebugReportEvent(Map<String, dynamic> event) {
|
||||
if (!SdkConfig.enableDebugReportLog ||
|
||||
_debugReportEventLogFilePath == null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
File(_debugReportEventLogFilePath!).writeAsStringSync(
|
||||
'${jsonEncode(event)}\n',
|
||||
mode: FileMode.append,
|
||||
flush: true,
|
||||
);
|
||||
} catch (e) {
|
||||
Logger.analyticsSdk('debug 完整事件 JSONL 写入失败: $e', level: LogLevel.warn);
|
||||
}
|
||||
}
|
||||
|
||||
void bufferEvent(Map<String, dynamic> event) {
|
||||
if (_cacheFilePath == null) return;
|
||||
_cacheBuffer.add(event);
|
||||
if (_cacheBuffer.length >= _cacheBufferThreshold) {
|
||||
flushBuffer();
|
||||
} else {
|
||||
_cacheFlushTimer ??= Timer(_cacheFlushInterval, () {
|
||||
_cacheFlushTimer = null;
|
||||
flushBuffer();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// 同步写入关键事件,供应用终止阶段的会话汇总使用。
|
||||
///
|
||||
/// 此时 Dart isolate 可能在下一个 await 前被系统终止,不能依赖延迟
|
||||
/// buffer 或异步文件写入。事件已包含 event_id,后续仍通过 tombstone
|
||||
/// 和服务端 event_id 去重保证幂等。
|
||||
void persistCriticalEvent(Map<String, dynamic> event) {
|
||||
if (_cacheFilePath == null) return;
|
||||
try {
|
||||
final line = jsonEncode(event);
|
||||
File(_cacheFilePath!).writeAsStringSync(
|
||||
'$line\n',
|
||||
mode: FileMode.append,
|
||||
flush: true,
|
||||
);
|
||||
} catch (e) {
|
||||
Logger.analyticsSdk('关键事件同步缓存写入失败: $e', level: LogLevel.warn);
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<Map<String, dynamic>>> loadEvents({
|
||||
required Queue<Map<String, dynamic>> queue,
|
||||
required int maxQueueSize,
|
||||
required int maxCacheLines,
|
||||
required bool Function(String) isEventTypeEnabled,
|
||||
}) async {
|
||||
if (_cacheFilePath == null) return const [];
|
||||
final file = File(_cacheFilePath!);
|
||||
if (!await file.exists()) return const [];
|
||||
|
||||
final recovered = <Map<String, dynamic>>[];
|
||||
try {
|
||||
await _cacheLock.synchronized(() async {
|
||||
if (await file.length() > SdkConfig.maxCacheBytes) {
|
||||
await _trimCacheFileIfNeeded(file);
|
||||
}
|
||||
|
||||
final tombstonedIds = await _loadTombstoneIds();
|
||||
final inFlightIds = await _loadInFlightIds();
|
||||
// 请求开始不代表服务端已经确认。进程在请求途中被终止时,in-flight
|
||||
// 事件必须随缓存一起恢复,交由服务端按 event_id 去重,不能在客户端
|
||||
// 为了避免重复而丢失会话汇总等终止事件。
|
||||
if (inFlightIds.isNotEmpty) {
|
||||
Logger.analyticsSdk(
|
||||
'检测到 ${inFlightIds.length} 条未确认 in-flight 事件,将恢复重试');
|
||||
}
|
||||
final lines = await file.readAsLines();
|
||||
final linesToProcess = lines.length > maxCacheLines
|
||||
? lines.sublist(lines.length - maxCacheLines)
|
||||
: lines;
|
||||
|
||||
for (final line in linesToProcess) {
|
||||
if (line.trim().isEmpty) continue;
|
||||
try {
|
||||
final decoded = jsonDecode(line);
|
||||
if (decoded is Map<String, dynamic>) {
|
||||
final eventId = decoded['event_id'];
|
||||
if (eventId != null && tombstonedIds.contains(eventId)) {
|
||||
continue;
|
||||
}
|
||||
final eventType = decoded['event'] as String?;
|
||||
if (eventType != null && !isEventTypeEnabled(eventType)) {
|
||||
Logger.analyticsSdk('缓存恢复:事件类型 $eventType 未启用,跳过');
|
||||
continue;
|
||||
}
|
||||
recovered.add(decoded);
|
||||
}
|
||||
} catch (_) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// 缓存和 tombstone 都不能在“恢复到内存队列”时删除。恢复后的
|
||||
// 事件尚未得到本次请求的 200 确认,进程再次终止时仍需能从磁盘
|
||||
// 重试;只有 removeEvents() 在 200 后写入 tombstone。
|
||||
if (_inFlightFilePath != null) {
|
||||
final inFlight = File(_inFlightFilePath!);
|
||||
if (await inFlight.exists()) await inFlight.delete();
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
Logger.analyticsSdk('缓存加载失败: $e');
|
||||
try {
|
||||
await file.delete();
|
||||
} catch (_) {}
|
||||
}
|
||||
return recovered;
|
||||
}
|
||||
|
||||
Future<void> removeEvents(List<Map<String, dynamic>> reportedEvents) async {
|
||||
if (_tombstoneFilePath == null || reportedEvents.isEmpty) return;
|
||||
|
||||
final ids = reportedEvents
|
||||
.map((e) => e['event_id'])
|
||||
.whereType<String>()
|
||||
.where((id) => id.isNotEmpty && !id.contains('\n'))
|
||||
.toList();
|
||||
|
||||
if (ids.isEmpty) return;
|
||||
|
||||
try {
|
||||
await _cacheLock.synchronized(() async {
|
||||
try {
|
||||
final tombstone = File(_tombstoneFilePath!);
|
||||
await tombstone.writeAsString(
|
||||
'${ids.join('\n')}\n',
|
||||
mode: FileMode.append,
|
||||
);
|
||||
Logger.analyticsSdk('tombstone 追加 ${ids.length} 条 event_id');
|
||||
|
||||
final tombstoneContent = await tombstone.readAsString();
|
||||
final tombstoneCount =
|
||||
tombstoneContent.split('\n').where((l) => l.isNotEmpty).length;
|
||||
if (tombstoneCount >= SdkConfig.tombstoneCompactionThreshold) {
|
||||
await _compactCache(tombstoneContent);
|
||||
}
|
||||
} catch (e) {
|
||||
Logger.analyticsSdk('tombstone 写入失败: $e');
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
Logger.analyticsSdk('缓存移除操作异常: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> clearCache() async {
|
||||
if (_cacheFilePath == null) return;
|
||||
try {
|
||||
await _cacheLock.synchronized(() async {
|
||||
try {
|
||||
final file = File(_cacheFilePath!);
|
||||
if (await file.exists()) await file.delete();
|
||||
if (_tombstoneFilePath != null) {
|
||||
final tombstone = File(_tombstoneFilePath!);
|
||||
if (await tombstone.exists()) await tombstone.delete();
|
||||
}
|
||||
} catch (e) {
|
||||
Logger.analyticsSdk('缓存清除失败: $e');
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
Logger.analyticsSdk('缓存清除操作异常: $e');
|
||||
}
|
||||
}
|
||||
|
||||
void flushBuffer() {
|
||||
if (_cacheFilePath == null || _cacheBuffer.isEmpty) return;
|
||||
_cacheFlushTimer?.cancel();
|
||||
_cacheFlushTimer = null;
|
||||
final batch = List<Map<String, dynamic>>.from(_cacheBuffer);
|
||||
_cacheBuffer.clear();
|
||||
Future.microtask(() async {
|
||||
await _saveBatchToCache(batch);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> flushBufferAsync() async {
|
||||
if (_cacheFilePath == null || _cacheBuffer.isEmpty) return;
|
||||
_cacheFlushTimer?.cancel();
|
||||
_cacheFlushTimer = null;
|
||||
final batch = List<Map<String, dynamic>>.from(_cacheBuffer);
|
||||
_cacheBuffer.clear();
|
||||
await _saveBatchToCache(batch);
|
||||
}
|
||||
|
||||
/// 发送前记录 batch 中的 event_id,便于诊断未确认请求。
|
||||
///
|
||||
/// 崩溃重启后这些事件会与缓存一起恢复并重试,采用 at-least-once
|
||||
/// 语义;服务端应按 event_id 去重。
|
||||
Future<void> markInFlight(List<Map<String, dynamic>> batch) async {
|
||||
if (_inFlightFilePath == null) return;
|
||||
final ids = batch
|
||||
.map((e) => e['event_id'])
|
||||
.whereType<String>()
|
||||
.where((id) => id.isNotEmpty && !id.contains('\n'))
|
||||
.toList();
|
||||
if (ids.isEmpty) return;
|
||||
try {
|
||||
await File(_inFlightFilePath!).writeAsString('${ids.join('\n')}\n');
|
||||
} catch (e) {
|
||||
Logger.analyticsSdk('in-flight 标记写入失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// 清除 in-flight 文件(上报完成或明确失败后调用,幂等)。
|
||||
Future<void> clearInFlight() async {
|
||||
if (_inFlightFilePath == null) return;
|
||||
try {
|
||||
final file = File(_inFlightFilePath!);
|
||||
if (await file.exists()) await file.delete();
|
||||
} catch (e) {
|
||||
Logger.analyticsSdk('in-flight 文件清除失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 私有方法 ====================
|
||||
|
||||
Future<void> _trimCacheFileIfNeeded(File file) async {
|
||||
try {
|
||||
final len = await file.length();
|
||||
if (len <= SdkConfig.maxCacheBytes) return;
|
||||
final lines = await file.readAsLines();
|
||||
int total = 0;
|
||||
int startIndex = 0;
|
||||
for (int i = lines.length - 1; i >= 0; i--) {
|
||||
total += utf8.encode(lines[i]).length + 1;
|
||||
if (total > SdkConfig.maxCacheBytes) {
|
||||
startIndex = i + 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (startIndex <= 0) return;
|
||||
final kept = lines.sublist(startIndex);
|
||||
await file.writeAsString(kept.isEmpty ? '' : '${kept.join('\n')}\n');
|
||||
Logger.analyticsSdk(
|
||||
'缓存文件超过 ${SdkConfig.maxCacheBytes} 字节,已裁剪掉前 $startIndex 条');
|
||||
} catch (e) {
|
||||
Logger.analyticsSdk('缓存文件裁剪失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _saveBatchToCache(List<Map<String, dynamic>> batch) async {
|
||||
if (_cacheFilePath == null || batch.isEmpty) return;
|
||||
try {
|
||||
await _cacheLock.synchronized(() async {
|
||||
try {
|
||||
final file = File(_cacheFilePath!);
|
||||
final lines = batch
|
||||
.map((item) {
|
||||
try {
|
||||
return jsonEncode(item);
|
||||
} catch (e) {
|
||||
Logger.analyticsSdk('单个事件编码失败,跳过: $e');
|
||||
return null;
|
||||
}
|
||||
})
|
||||
.where((line) => line != null)
|
||||
.join('\n');
|
||||
if (lines.isNotEmpty) {
|
||||
await file.writeAsString('$lines\n', mode: FileMode.append);
|
||||
await _trimCacheFileIfNeeded(file);
|
||||
}
|
||||
} catch (e) {
|
||||
Logger.analyticsSdk('批量缓存写入失败: $e');
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
Logger.analyticsSdk('缓存保存异常: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<Set<String>> _loadTombstoneIds() async {
|
||||
if (_tombstoneFilePath == null) return {};
|
||||
try {
|
||||
final tombstone = File(_tombstoneFilePath!);
|
||||
if (!await tombstone.exists()) return {};
|
||||
final content = await tombstone.readAsString();
|
||||
return content.split('\n').where((l) => l.isNotEmpty).toSet();
|
||||
} catch (e) {
|
||||
Logger.analyticsSdk('tombstone 读取失败: $e');
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
Future<Set<String>> _loadInFlightIds() async {
|
||||
if (_inFlightFilePath == null) return {};
|
||||
try {
|
||||
final file = File(_inFlightFilePath!);
|
||||
if (!await file.exists()) return {};
|
||||
final content = await file.readAsString();
|
||||
return content.split('\n').where((l) => l.isNotEmpty).toSet();
|
||||
} catch (e) {
|
||||
Logger.analyticsSdk('in-flight 文件读取失败: $e');
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _compactCache(String tombstoneContent) async {
|
||||
if (_cacheFilePath == null || _tombstoneFilePath == null) return;
|
||||
try {
|
||||
final tombstonedIds =
|
||||
tombstoneContent.split('\n').where((l) => l.isNotEmpty).toSet();
|
||||
|
||||
final cacheFile = File(_cacheFilePath!);
|
||||
if (!await cacheFile.exists()) {
|
||||
await File(_tombstoneFilePath!).delete();
|
||||
return;
|
||||
}
|
||||
|
||||
final lines = await cacheFile.readAsLines();
|
||||
final kept = <String>[];
|
||||
int removed = 0;
|
||||
|
||||
for (final line in lines) {
|
||||
if (line.trim().isEmpty) continue;
|
||||
try {
|
||||
final decoded = jsonDecode(line);
|
||||
if (decoded is Map<String, dynamic>) {
|
||||
final id = decoded['event_id'];
|
||||
if (id != null && tombstonedIds.contains(id)) {
|
||||
removed++;
|
||||
} else {
|
||||
kept.add(line);
|
||||
}
|
||||
} else {
|
||||
kept.add(line);
|
||||
}
|
||||
} catch (_) {
|
||||
kept.add(line);
|
||||
}
|
||||
}
|
||||
|
||||
if (kept.isEmpty) {
|
||||
await cacheFile.delete();
|
||||
} else {
|
||||
await cacheFile.writeAsString('${kept.join('\n')}\n');
|
||||
}
|
||||
try {
|
||||
await File(_tombstoneFilePath!).delete();
|
||||
} catch (e) {
|
||||
Logger.analyticsSdk('tombstone 删除失败(可忽略,下次压缩会重试): $e');
|
||||
}
|
||||
|
||||
Logger.analyticsSdk('缓存压缩完成:删除 $removed 条,保留 ${kept.length} 条');
|
||||
} catch (e) {
|
||||
Logger.analyticsSdk('缓存压缩失败: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import 'dart:collection';
|
||||
|
||||
/// Web 平台事件持久化:内存 only,所有持久化操作为空实现。
|
||||
/// Web 会话通常短暂,不需要跨页面持久化事件。
|
||||
class EventPersistenceImpl {
|
||||
Future<void> init() async {}
|
||||
|
||||
String? get debugReportEventLogFilePath => null;
|
||||
|
||||
void appendDebugReportEvent(Map<String, dynamic> event) {}
|
||||
|
||||
void bufferEvent(Map<String, dynamic> event) {}
|
||||
|
||||
void persistCriticalEvent(Map<String, dynamic> event) {}
|
||||
|
||||
Future<List<Map<String, dynamic>>> loadEvents({
|
||||
required Queue<Map<String, dynamic>> queue,
|
||||
required int maxQueueSize,
|
||||
required int maxCacheLines,
|
||||
required bool Function(String) isEventTypeEnabled,
|
||||
}) async =>
|
||||
const [];
|
||||
|
||||
Future<void> removeEvents(List<Map<String, dynamic>> events) async {}
|
||||
|
||||
Future<void> markInFlight(List<Map<String, dynamic>> events) async {}
|
||||
|
||||
Future<void> clearInFlight() async {}
|
||||
|
||||
Future<void> clearCache() async {}
|
||||
|
||||
void flushBuffer() {}
|
||||
|
||||
Future<void> flushBufferAsync() async {}
|
||||
}
|
||||
@@ -0,0 +1,552 @@
|
||||
import 'dart:async';
|
||||
import 'dart:collection';
|
||||
import 'dart:convert';
|
||||
import 'package:analytics_sdk/manager/event_persistence.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:analytics_sdk/config/sdk_config.dart';
|
||||
import 'package:analytics_sdk/manager/event_type_config_manager.dart';
|
||||
import 'package:analytics_sdk/manager/session_behavior_manager.dart';
|
||||
import 'package:analytics_sdk/manager/session_manager.dart';
|
||||
import 'package:analytics_sdk/manager/session_metrics_manager.dart';
|
||||
import 'package:analytics_sdk/manager/session_sensor_sampler.dart';
|
||||
import 'package:analytics_sdk/manager/sdk_context.dart';
|
||||
import 'package:analytics_sdk/utils/logger.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:synchronized/synchronized.dart';
|
||||
|
||||
/// 事件处理与上报管理器
|
||||
///
|
||||
/// 负责:
|
||||
/// - 维护事件队列
|
||||
/// - 本地持久化(缓存文件)
|
||||
/// - 批量上报 & 指数退避重试
|
||||
/// - 定时上报定时器
|
||||
///
|
||||
/// 对外由 `AnalyticsSdk` 持有并调用,不直接暴露给业务方。
|
||||
class EventProcessor {
|
||||
String? _reportUrl;
|
||||
final Queue<Map<String, dynamic>> _eventQueue = Queue<Map<String, dynamic>>();
|
||||
|
||||
Timer? _uploadTimer;
|
||||
|
||||
final EventPersistenceImpl _persistence = EventPersistenceImpl();
|
||||
|
||||
int _backoffLevel = 0;
|
||||
Timer? _retryTimer;
|
||||
|
||||
/// JSON 过大时折半缩小批次;成功后重置为 null(恢复用 maxBatchSize)
|
||||
int? _overrideBatchSize;
|
||||
|
||||
http.Client? _httpClient;
|
||||
|
||||
http.Client get _client => _httpClient ??= http.Client();
|
||||
|
||||
/// 上报锁,防止并发上报
|
||||
bool _isUploading = false;
|
||||
final Lock _uploadLock = Lock();
|
||||
|
||||
/// event_id 防抖:记录最近见到该 id 的时间,窗口内重复则丢弃
|
||||
final Map<String, DateTime> _recentEventIds = {};
|
||||
|
||||
/// 防抖窗口:1000ms 秒级时间戳粒度 + 100ms 时钟抖动容差。
|
||||
/// event_id 的哈希因子包含秒级 client_ts,同秒内同事件会产生同一 id;
|
||||
/// 额外 100ms 用于覆盖调用链路上的微小时间偏移,避免临界点误判。
|
||||
static const _dedupWindow = Duration(milliseconds: 1100);
|
||||
|
||||
/// 测试包用:最近一次上报结果
|
||||
bool? lastUploadSuccess;
|
||||
String? lastUploadError;
|
||||
DateTime? lastUploadTime;
|
||||
|
||||
/// 是否已配置可用的上报 URL
|
||||
bool get hasValidReportUrl =>
|
||||
_reportUrl != null && _reportUrl!.isNotEmpty == true;
|
||||
|
||||
/// 测试包用:队列中待上报事件数
|
||||
int get queueLength => _eventQueue.length;
|
||||
|
||||
/// 测试包用:当前上报地址(脱敏)
|
||||
String? get debugReportUrl => _reportUrl;
|
||||
|
||||
/// 测试包用:完整事件 JSONL 调试文件路径(仅 Native debug 包)
|
||||
String? get debugReportEventLogPath =>
|
||||
kDebugMode && SdkConfig.enableDebugReportLog
|
||||
? _persistence.debugReportEventLogFilePath
|
||||
: null;
|
||||
|
||||
/// 更新上报 URL(域名管理器回调时调用)
|
||||
void updateReportUrl(String? reportUrl) {
|
||||
_reportUrl = reportUrl;
|
||||
}
|
||||
|
||||
/// 启动定时上报定时器
|
||||
void startAutoUploadTimer() {
|
||||
_uploadTimer?.cancel();
|
||||
_uploadTimer = Timer.periodic(SdkConfig.uploadInterval, (_) {
|
||||
try {
|
||||
uploadBatch().catchError((e) {
|
||||
Logger.analyticsSdk('定时上报 Future 异常: $e');
|
||||
});
|
||||
} catch (e) {
|
||||
Logger.analyticsSdk('定时上报异常: $e');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// 停止定时上报定时器
|
||||
void stopAutoUploadTimer() {
|
||||
_uploadTimer?.cancel();
|
||||
_uploadTimer = null;
|
||||
}
|
||||
|
||||
/// 重置退避和上报状态(SDK 重新初始化时调用)
|
||||
void resetState() {
|
||||
_backoffLevel = 0;
|
||||
_overrideBatchSize = null;
|
||||
_retryTimer?.cancel();
|
||||
_retryTimer = null;
|
||||
lastUploadSuccess = null;
|
||||
lastUploadError = null;
|
||||
lastUploadTime = null;
|
||||
_isUploading = false;
|
||||
_recentEventIds.clear();
|
||||
}
|
||||
|
||||
/// 事件入队:
|
||||
/// - 负责队列容量检查
|
||||
/// - 触发必要的上报
|
||||
/// - 写入本地缓存
|
||||
///
|
||||
/// 返回值:
|
||||
/// - `true`:事件成功进入队列
|
||||
/// - `false`:因队列已满等原因被丢弃
|
||||
bool enqueueEvent(Map<String, dynamic> json) {
|
||||
// 队列容量检查,防止内存无限增长
|
||||
if (_eventQueue.length >= SdkConfig.maxQueueSize) {
|
||||
Logger.analyticsSdk('警告:事件队列已满(${_eventQueue.length}),尝试立即上报');
|
||||
// 尝试立即上报以释放队列空间(不 await)
|
||||
try {
|
||||
uploadBatch().catchError((e) {
|
||||
Logger.analyticsSdk('立即上报 Future 异常: $e');
|
||||
});
|
||||
} catch (e) {
|
||||
Logger.analyticsSdk('立即上报异常: $e');
|
||||
}
|
||||
// 队列已满时直接丢弃新事件
|
||||
return false;
|
||||
}
|
||||
|
||||
// event_id 防抖:1100ms 窗口内同一 id 只保留第一条
|
||||
final eventId = json['event_id'] as String?;
|
||||
if (eventId != null) {
|
||||
final now = DateTime.now();
|
||||
_recentEventIds.removeWhere((_, t) => now.difference(t) > _dedupWindow);
|
||||
final lastSeen = _recentEventIds[eventId];
|
||||
if (lastSeen != null && now.difference(lastSeen) <= _dedupWindow) {
|
||||
Logger.analyticsSdk('event_id 防抖,已丢弃: $eventId');
|
||||
return false;
|
||||
}
|
||||
_recentEventIds[eventId] = now;
|
||||
}
|
||||
|
||||
_eventQueue.add(json);
|
||||
SessionManager.instance.recordActivityForEvent(json);
|
||||
SessionMetricsManager.instance.recordAcceptedEvent(json);
|
||||
SessionBehaviorManager.instance.recordAcceptedEvent(json);
|
||||
SessionSensorSampler.instance.recordAcceptedEvent(json);
|
||||
if (json['event'] == 'app_session_summary') {
|
||||
_persistence.persistCriticalEvent(json);
|
||||
Logger.analyticsSdk(
|
||||
'app_session_summary 已同步写入本地缓存: ${json['event_id'] ?? ''}');
|
||||
} else {
|
||||
_persistence.bufferEvent(json);
|
||||
}
|
||||
_writeDebugReportEvent(json);
|
||||
_logAcceptedEvent(json);
|
||||
_flushIfNeeded();
|
||||
return true;
|
||||
}
|
||||
|
||||
/// 当队列长度达到阈值时触发上报
|
||||
void _flushIfNeeded() {
|
||||
if (_eventQueue.length >= SdkConfig.autoUploadThreshold) {
|
||||
uploadBatch().catchError((e) {
|
||||
Logger.analyticsSdk('阈值触发上报 Future 异常: $e');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// 强制立即上报
|
||||
/// 该方法永远不会抛出异常
|
||||
Future<void> flush() async {
|
||||
try {
|
||||
await uploadBatch();
|
||||
} catch (e) {
|
||||
Logger.analyticsSdk('flush() 发生异常,已安全处理: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// 批量上报队列中的事件
|
||||
Future<void> uploadBatch() async {
|
||||
if (_eventQueue.isEmpty || _reportUrl == null) return;
|
||||
|
||||
return _uploadLock.synchronized(() async {
|
||||
if (_eventQueue.isEmpty || _reportUrl == null || _isUploading) return;
|
||||
|
||||
_isUploading = true;
|
||||
// 限制单次上报的批次大小,避免请求过大导致超时或内存问题
|
||||
// _overrideBatchSize 在 JSON 过大时折半缩小,上报成功后重置
|
||||
final maxBatch = _overrideBatchSize ?? SdkConfig.maxBatchSize;
|
||||
final batchSize =
|
||||
_eventQueue.length > maxBatch ? maxBatch : _eventQueue.length;
|
||||
|
||||
// 安全检查:确保批次大小有效
|
||||
if (batchSize <= 0) {
|
||||
Logger.analyticsSdk('批次大小无效: $batchSize');
|
||||
_isUploading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// 取出批次但不从队列移除,上报成功后再移除(at-least-once 语义)
|
||||
final batch = List<Map<String, dynamic>>.from(
|
||||
_eventQueue.take(batchSize),
|
||||
);
|
||||
|
||||
try {
|
||||
// 安全检查:确保批次不为空
|
||||
if (batch.isEmpty) {
|
||||
Logger.analyticsSdk('批次为空,跳过上报');
|
||||
return;
|
||||
}
|
||||
|
||||
final requestBatch = batch
|
||||
.map(_ensureEventSignatureBeforeUpload)
|
||||
.toList(growable: false);
|
||||
|
||||
// 在后台线程执行 JSON 编码,避免阻塞主线程
|
||||
final batchJson = await _encodeBatchAsync(requestBatch);
|
||||
|
||||
// 检查编码结果
|
||||
if (batchJson == '[]' || batchJson.isEmpty) {
|
||||
Logger.analyticsSdk('JSON 编码失败或结果为空');
|
||||
_scheduleRetry();
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查 JSON 大小,避免请求过大
|
||||
final jsonSize = utf8.encode(batchJson).length;
|
||||
if (jsonSize > SdkConfig.maxJsonSize) {
|
||||
Logger.analyticsSdk(
|
||||
'警告:JSON 大小过大 (${(jsonSize / 1024 / 1024).toStringAsFixed(2)}MB),缩小批次重试');
|
||||
if (batchSize > 1) {
|
||||
// 折半批次后重试,避免同一批次反复触发 JSON 过大
|
||||
_overrideBatchSize = (batchSize / 2).ceil();
|
||||
_isUploading = false;
|
||||
Future.microtask(() {
|
||||
uploadBatch().catchError((e) {
|
||||
Logger.analyticsSdk('缩小批次后重试上报 Future 异常: $e');
|
||||
});
|
||||
});
|
||||
} else {
|
||||
// 单条事件过大,移除该事件避免阻塞队列
|
||||
if (_eventQueue.isNotEmpty) {
|
||||
_eventQueue.removeFirst();
|
||||
}
|
||||
Logger.analyticsSdk(
|
||||
'警告:单条事件过大 (${(jsonSize / 1024).toStringAsFixed(2)}KB),已丢弃');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// 解析 URL,添加错误处理
|
||||
Uri uri;
|
||||
try {
|
||||
uri = Uri.parse(_reportUrl!);
|
||||
if (!uri.hasScheme || (!uri.scheme.startsWith('http'))) {
|
||||
throw const FormatException('无效的 URL scheme');
|
||||
}
|
||||
} catch (e) {
|
||||
Logger.analyticsSdk('URL 解析失败: $_reportUrl, 错误: $e');
|
||||
_scheduleRetry();
|
||||
return;
|
||||
}
|
||||
|
||||
// 终止窗口内也要保证批次已落盘:否则请求已开始但进程被杀时,
|
||||
// in-flight 文件虽存在,主缓存却没有这批事件可供重试。
|
||||
// 服务端通过 event_id 去重,客户端采用 at-least-once 投递。
|
||||
await _persistence.flushBufferAsync();
|
||||
await _persistence.markInFlight(requestBatch);
|
||||
|
||||
Logger.analyticsSdk(
|
||||
'上报中:${requestBatch.length} 条事件,JSON大小: ${(jsonSize / 1024).toStringAsFixed(2)}KB(已落盘)');
|
||||
|
||||
// 复用 http.Client,利用连接池
|
||||
final response = await _client
|
||||
.post(
|
||||
uri,
|
||||
headers: {
|
||||
'Content-Type': 'application/json; charset=utf-8',
|
||||
},
|
||||
body: batchJson,
|
||||
)
|
||||
.timeout(SdkConfig.totalRequestTimeout);
|
||||
final statusCode = response.statusCode;
|
||||
if (statusCode == 200) {
|
||||
// 上报成功,按 event_id 从队列中移除已上报的事件
|
||||
final reportedIds =
|
||||
batch.map((e) => e['event_id']).whereType<String>().toSet();
|
||||
if (reportedIds.isNotEmpty) {
|
||||
_eventQueue
|
||||
.removeWhere((e) => reportedIds.contains(e['event_id']));
|
||||
} else {
|
||||
// event_id 全缺失时回退到按数量移除(兜底)
|
||||
for (int i = 0; i < batchSize && _eventQueue.isNotEmpty; i++) {
|
||||
_eventQueue.removeFirst();
|
||||
}
|
||||
}
|
||||
lastUploadSuccess = true;
|
||||
lastUploadError = null;
|
||||
lastUploadTime = DateTime.now();
|
||||
_overrideBatchSize = null; // 上报成功,恢复正常批次大小
|
||||
_resetBackoff();
|
||||
await _persistence.removeEvents(requestBatch);
|
||||
final confirmedEvents = requestBatch
|
||||
.map((event) =>
|
||||
'${event['event'] ?? 'unknown'}(${event['event_id'] ?? 'missing_event_id'})')
|
||||
.join(', ');
|
||||
Logger.analyticsSdk(
|
||||
'上报成功: ${requestBatch.length} 条事件,HTTP $statusCode 批次确认,'
|
||||
'已写入 tombstone;确认事件: [$confirmedEvents]');
|
||||
} else {
|
||||
lastUploadSuccess = false;
|
||||
lastUploadError = 'HTTP $statusCode';
|
||||
lastUploadTime = DateTime.now();
|
||||
_scheduleRetry();
|
||||
if (statusCode >= 400 && statusCode < 500) {
|
||||
Logger.analyticsSdk('上报失败: 客户端错误,状态码: $statusCode(可能是数据格式问题)');
|
||||
} else if (statusCode >= 500) {
|
||||
Logger.analyticsSdk('上报失败: 服务器错误,状态码: $statusCode(服务器问题,将重试)');
|
||||
} else {
|
||||
Logger.analyticsSdk('上报失败: 未知状态码 $statusCode');
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
lastUploadSuccess = false;
|
||||
lastUploadError = e is TimeoutException
|
||||
? '请求超时'
|
||||
: e is http.ClientException
|
||||
? '网络连接失败'
|
||||
: e.toString().length > 30
|
||||
? '${e.toString().substring(0, 30)}...'
|
||||
: e.toString();
|
||||
lastUploadTime = DateTime.now();
|
||||
_scheduleRetry();
|
||||
if (e is TimeoutException) {
|
||||
Logger.analyticsSdk('上报异常: 请求超时 - ${e.message ?? "未知超时"}',
|
||||
level: LogLevel.error);
|
||||
} else if (e is http.ClientException) {
|
||||
Logger.analyticsSdk('上报异常: 网络连接失败 - ${e.message}',
|
||||
level: LogLevel.error);
|
||||
} else {
|
||||
final errorMessage = e.toString();
|
||||
Logger.analyticsSdk('上报异常: $errorMessage', level: LogLevel.error);
|
||||
}
|
||||
} finally {
|
||||
// 无论成功、失败还是异常,确保 in-flight 文件被清除(幂等)
|
||||
try {
|
||||
await _persistence.clearInFlight();
|
||||
} catch (_) {}
|
||||
}
|
||||
} finally {
|
||||
_isUploading = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Map<String, dynamic> _ensureEventSignatureBeforeUpload(
|
||||
Map<String, dynamic> event,
|
||||
) {
|
||||
final eventSig = event['event_sig'];
|
||||
if (eventSig is String && eventSig.trim().isNotEmpty) {
|
||||
return event;
|
||||
}
|
||||
|
||||
final signed = SdkContext.attachDeviceEnvIfNeeded(event);
|
||||
final signedEventSig = signed['event_sig'];
|
||||
if (signedEventSig is String && signedEventSig.trim().isNotEmpty) {
|
||||
return signed;
|
||||
}
|
||||
return event;
|
||||
}
|
||||
|
||||
void _logAcceptedEvent(Map<String, dynamic> json) {
|
||||
if (!SdkConfig.enableDebugReportLog) return;
|
||||
try {
|
||||
Logger.analyticsSdkDeveloperLog(
|
||||
'accepted event json: ${jsonEncode(json)}',
|
||||
);
|
||||
} catch (e) {
|
||||
Logger.analyticsSdk('事件日志打印失败: $e', level: LogLevel.warn);
|
||||
}
|
||||
}
|
||||
|
||||
void _writeDebugReportEvent(Map<String, dynamic> json) {
|
||||
if (!kDebugMode || !SdkConfig.enableDebugReportLog) return;
|
||||
try {
|
||||
_persistence.appendDebugReportEvent(json);
|
||||
} catch (e) {
|
||||
Logger.analyticsSdk('debug 完整事件 JSONL 记录失败: $e', level: LogLevel.warn);
|
||||
}
|
||||
}
|
||||
|
||||
/// JSON 编码的静态方法,用于 compute isolate
|
||||
static String _encodeBatchJson(List<Map<String, dynamic>> batch) {
|
||||
return jsonEncode(batch);
|
||||
}
|
||||
|
||||
/// 在后台 isolate 中异步执行 JSON 编码,避免阻塞主线程
|
||||
Future<String> _encodeBatchAsync(List<Map<String, dynamic>> batch) async {
|
||||
try {
|
||||
if (batch.isEmpty) {
|
||||
return '[]';
|
||||
}
|
||||
if (batch.length <= SdkConfig.isolateEncodingThreshold) {
|
||||
return jsonEncode(batch);
|
||||
}
|
||||
try {
|
||||
return await compute(_encodeBatchJson, batch);
|
||||
} catch (e) {
|
||||
Logger.analyticsSdk('compute 失败,回退到主线程编码: $e');
|
||||
return jsonEncode(batch);
|
||||
}
|
||||
} catch (e) {
|
||||
Logger.analyticsSdk('JSON 编码失败: $e');
|
||||
return '[]';
|
||||
}
|
||||
}
|
||||
|
||||
/// 安排指数退避重试(事件仍在队列中,无需重新入队)
|
||||
void _scheduleRetry() {
|
||||
_backoffLevel = (_backoffLevel + 1).clamp(0, SdkConfig.maxBackoffLevel);
|
||||
_retryTimer?.cancel();
|
||||
final delay = _nextDelay();
|
||||
_retryTimer = Timer(delay, () {
|
||||
try {
|
||||
Logger.analyticsSdk('指数退避重试 (延迟 ${delay.inSeconds}s)');
|
||||
uploadBatch();
|
||||
} catch (e) {
|
||||
Logger.analyticsSdk('重试上报异常: $e');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// 计算下次延迟时间(指数退避 + 上限)
|
||||
Duration _nextDelay() {
|
||||
final exponential = SdkConfig.baseRetryDelay * pow(2, _backoffLevel);
|
||||
return exponential > SdkConfig.maxRetryDelay
|
||||
? SdkConfig.maxRetryDelay
|
||||
: exponential;
|
||||
}
|
||||
|
||||
/// 上报成功时重置退避状态
|
||||
void _resetBackoff() {
|
||||
if (_backoffLevel > 0) {
|
||||
_backoffLevel = 0;
|
||||
_retryTimer?.cancel();
|
||||
_retryTimer = null;
|
||||
Logger.analyticsSdk('上报成功,退避机制已重置');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> initPersistence() async {
|
||||
try {
|
||||
await _persistence.init();
|
||||
final debugPath = debugReportEventLogPath;
|
||||
if (debugPath != null && debugPath.isNotEmpty) {
|
||||
Logger.analyticsSdk('debug 完整事件 JSONL 文件: $debugPath');
|
||||
}
|
||||
} catch (e) {
|
||||
Logger.analyticsSdk('持久化初始化失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> loadCachedEvents() async {
|
||||
try {
|
||||
final recovered = await _persistence.loadEvents(
|
||||
queue: _eventQueue,
|
||||
maxQueueSize: SdkConfig.maxQueueSize,
|
||||
maxCacheLines: SdkConfig.maxCacheLines,
|
||||
isEventTypeEnabled: EventTypeConfigManager.instance.isEventTypeEnabled,
|
||||
);
|
||||
|
||||
if (recovered.isNotEmpty) {
|
||||
final availableSpace = SdkConfig.maxQueueSize - _eventQueue.length;
|
||||
if (availableSpace > 0) {
|
||||
final eventsToRecover = recovered.length > availableSpace
|
||||
? recovered.sublist(0, availableSpace)
|
||||
: recovered;
|
||||
_eventQueue.addAll(eventsToRecover);
|
||||
Logger.analyticsSdk('从缓存恢复 ${eventsToRecover.length} 条事件');
|
||||
} else {
|
||||
Logger.analyticsSdk('警告:队列已满,无法恢复缓存事件');
|
||||
}
|
||||
}
|
||||
|
||||
_flushIfNeeded();
|
||||
} catch (e) {
|
||||
Logger.analyticsSdk('缓存加载失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> clearCache() async {
|
||||
await _persistence.clearCache();
|
||||
}
|
||||
|
||||
/// 释放资源(定时器、HttpClient、缓存 buffer 等)
|
||||
void dispose() {
|
||||
try {
|
||||
stopAutoUploadTimer();
|
||||
_retryTimer?.cancel();
|
||||
_retryTimer = null;
|
||||
_isUploading = false;
|
||||
_persistence.flushBuffer();
|
||||
_httpClient?.close();
|
||||
_httpClient = null;
|
||||
} catch (e) {
|
||||
Logger.analyticsSdk('EventProcessor.dispose 异常: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// 测试专用入口:触发 tombstone 写入以供单元测试验证缓存移除行为
|
||||
@visibleForTesting
|
||||
Future<void> removeEventsFromCacheForTest(
|
||||
List<Map<String, dynamic>> events) =>
|
||||
_persistence.removeEvents(events);
|
||||
|
||||
/// 测试专用入口:注入 HTTP client 以验证最终请求体。
|
||||
@visibleForTesting
|
||||
void setHttpClientForTest(http.Client client) {
|
||||
_httpClient?.close();
|
||||
_httpClient = client;
|
||||
}
|
||||
|
||||
/// 异步释放资源(先 await 缓存刷写,再释放其他资源)
|
||||
///
|
||||
/// 推荐在 SDK dispose 时使用此方法替代 [dispose],确保缓存 buffer 写入磁盘。
|
||||
Future<void> disposeAsync() async {
|
||||
try {
|
||||
stopAutoUploadTimer();
|
||||
_retryTimer?.cancel();
|
||||
_retryTimer = null;
|
||||
_isUploading = false;
|
||||
await _persistence.flushBufferAsync();
|
||||
_httpClient?.close();
|
||||
_httpClient = null;
|
||||
} catch (e) {
|
||||
Logger.analyticsSdk('EventProcessor.disposeAsync 异常: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
import 'dart:convert';
|
||||
import 'package:analytics_sdk/config/sdk_config.dart';
|
||||
import 'package:analytics_sdk/utils/logger.dart';
|
||||
import 'package:analytics_sdk/utils/platform_storage.dart';
|
||||
|
||||
/// 事件类型配置管理器:解析并缓存业务方传入的事件类型配置
|
||||
class EventTypeConfigManager {
|
||||
static final EventTypeConfigManager instance =
|
||||
EventTypeConfigManager._internal();
|
||||
|
||||
factory EventTypeConfigManager() => instance;
|
||||
|
||||
EventTypeConfigManager._internal();
|
||||
|
||||
Set<String> _enabledEventTypes = {};
|
||||
|
||||
/// 获取已启用的事件类型集合
|
||||
Set<String> get enabledEventTypes => Set.unmodifiable(_enabledEventTypes);
|
||||
|
||||
/// 检查某个事件类型是否被启用
|
||||
/// 如果配置为空,默认允许所有事件上报(向后兼容)
|
||||
bool isEventTypeEnabled(String eventType) {
|
||||
if (_enabledEventTypes.isEmpty) return true;
|
||||
return _enabledEventTypes.contains(eventType);
|
||||
}
|
||||
|
||||
/// 传入已解密的事件类型配置 JSON 字符串,解析后写入本地缓存
|
||||
///
|
||||
/// 支持格式:
|
||||
/// - 字符串数组:`["app_page_view", "ad_impression", ...]`
|
||||
/// - JSON 对象:`{"enabled_event_types": [...]}`
|
||||
///
|
||||
/// 返回解析到的事件类型数量
|
||||
Future<int> initWithConfig(String decryptedJson) async {
|
||||
try {
|
||||
final eventTypes = _parseEventTypes(decryptedJson);
|
||||
if (eventTypes.isNotEmpty) {
|
||||
_enabledEventTypes = eventTypes;
|
||||
await _saveConfigToCache(eventTypes);
|
||||
Logger.eventTypeConfigManager(
|
||||
'事件类型配置已更新,共 ${eventTypes.length} 个: ${eventTypes.join(", ")}');
|
||||
} else {
|
||||
Logger.eventTypeConfigManager('解析结果为空,保持原有配置');
|
||||
}
|
||||
return eventTypes.length;
|
||||
} catch (e) {
|
||||
Logger.eventTypeConfigManager('initWithConfig 异常: $e');
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/// 从本地缓存加载配置(若无缓存则保持 allow-all 状态)
|
||||
Future<void> loadCachedConfig() async {
|
||||
try {
|
||||
await _loadCachedConfig();
|
||||
} catch (e) {
|
||||
Logger.eventTypeConfigManager('加载缓存配置异常: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// 清理资源
|
||||
void dispose() {
|
||||
_enabledEventTypes.clear();
|
||||
}
|
||||
|
||||
/// 解析 JSON 字符串,提取事件类型列表(供外部验证用,如 validateEncryptedConfig)
|
||||
///
|
||||
/// 返回 List<String>,解析失败或结果为空则返回空列表,不抛出异常。
|
||||
static List<String> parseForValidation(String json) {
|
||||
try {
|
||||
dynamic decoded = jsonDecode(json);
|
||||
if (decoded is String && decoded.trim().isNotEmpty) {
|
||||
try {
|
||||
decoded = jsonDecode(decoded);
|
||||
} catch (_) {}
|
||||
}
|
||||
if (decoded is List) {
|
||||
return (decoded as List)
|
||||
.map<String>((e) => e.toString().trim())
|
||||
.where((e) => e.isNotEmpty)
|
||||
.toList();
|
||||
} else if (decoded is Map<String, dynamic>) {
|
||||
final list = decoded['enabled_event_types'] as List? ??
|
||||
decoded['event_types'] as List? ??
|
||||
decoded['data'] as List?;
|
||||
if (list != null) {
|
||||
return list
|
||||
.map<String>((e) => e.toString().trim())
|
||||
.where((e) => e.isNotEmpty)
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
return [];
|
||||
}
|
||||
|
||||
// ==================== 私有方法 ====================
|
||||
|
||||
/// 解析 JSON 字符串,提取事件类型列表
|
||||
Set<String> _parseEventTypes(String json) {
|
||||
try {
|
||||
dynamic decoded = jsonDecode(json);
|
||||
// 兼容外层为字符串的格式:"[\"event_type\", ...]"
|
||||
if (decoded is String && decoded.trim().isNotEmpty) {
|
||||
try {
|
||||
decoded = jsonDecode(decoded);
|
||||
} catch (_) {}
|
||||
}
|
||||
if (decoded is List) {
|
||||
return (decoded as List)
|
||||
.map<String>((e) => e.toString().trim())
|
||||
.where((e) => e.isNotEmpty)
|
||||
.toSet();
|
||||
} else if (decoded is Map<String, dynamic>) {
|
||||
final list = decoded['enabled_event_types'] as List? ??
|
||||
decoded['event_types'] as List? ??
|
||||
decoded['data'] as List?;
|
||||
if (list != null) {
|
||||
return list
|
||||
.map<String>((e) => e.toString().trim())
|
||||
.where((e) => e.isNotEmpty)
|
||||
.toSet();
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
Logger.eventTypeConfigManager('事件类型 JSON 解析失败: $e');
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
Future<void> _saveConfigToCache(Set<String> eventTypes) async {
|
||||
try {
|
||||
await PlatformStorage.write(
|
||||
SdkConfig.eventTypeConfigCacheFileName,
|
||||
jsonEncode({
|
||||
'enabled_event_types': eventTypes.toList(),
|
||||
'updated_at': DateTime.now().toIso8601String(),
|
||||
}),
|
||||
);
|
||||
Logger.eventTypeConfigManager('配置已保存到缓存');
|
||||
} catch (e) {
|
||||
Logger.eventTypeConfigManager('保存缓存失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadCachedConfig() async {
|
||||
try {
|
||||
final content =
|
||||
await PlatformStorage.read(SdkConfig.eventTypeConfigCacheFileName);
|
||||
if (content == null) {
|
||||
Logger.eventTypeConfigManager('缓存文件不存在');
|
||||
return;
|
||||
}
|
||||
final json = jsonDecode(content) as Map<String, dynamic>;
|
||||
final list = json['enabled_event_types'];
|
||||
if (list is List) {
|
||||
_enabledEventTypes = (list as List)
|
||||
.map<String>((e) => e.toString().trim())
|
||||
.where((e) => e.isNotEmpty)
|
||||
.toSet();
|
||||
Logger.eventTypeConfigManager(
|
||||
'从缓存加载 ${_enabledEventTypes.length} 个事件类型');
|
||||
}
|
||||
} catch (e) {
|
||||
Logger.eventTypeConfigManager('加载缓存失败: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
Future<Map<String, dynamic>> collectNetworkStateFields() async {
|
||||
return const {};
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
// ignore: avoid_web_libraries_in_flutter
|
||||
import 'dart:html' as html;
|
||||
|
||||
import 'package:analytics_sdk/utils/js_interop_compat.dart';
|
||||
|
||||
Future<Map<String, dynamic>> collectNetworkStateFields() async {
|
||||
final connection = _connection(html.window.navigator);
|
||||
if (connection == null) return const {};
|
||||
final fields = <String, dynamic>{};
|
||||
fields['connection_type'] = _string(connection, 'type', 'unknown');
|
||||
fields['effective_type'] = _effectiveType(connection);
|
||||
fields['downlink'] = _downlinkKbps(connection);
|
||||
fields['rtt'] = _number(connection, 'rtt', -1).round();
|
||||
fields.removeWhere((key, value) {
|
||||
if (value is String) return value.trim().isEmpty;
|
||||
if (value is num) return value < 0;
|
||||
return value == null;
|
||||
});
|
||||
return fields;
|
||||
}
|
||||
|
||||
Object? _connection(html.Navigator navigator) {
|
||||
for (final key in ['connection', 'mozConnection', 'webkitConnection']) {
|
||||
try {
|
||||
if (hasJsProperty(navigator, key)) {
|
||||
return getJsProperty(navigator, key);
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
String _string(Object? object, String key, String fallback) {
|
||||
if (object == null) return fallback;
|
||||
try {
|
||||
final value = getJsProperty(object, key);
|
||||
final string = value?.toString().trim() ?? '';
|
||||
return string.isEmpty ? fallback : string;
|
||||
} catch (_) {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
num _number(Object? object, String key, num fallback) {
|
||||
if (object == null) return fallback;
|
||||
try {
|
||||
final value = getJsProperty(object, key);
|
||||
if (value is num && value.isFinite) return value;
|
||||
return num.tryParse(value?.toString() ?? '') ?? fallback;
|
||||
} catch (_) {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
String _effectiveType(Object? connection) {
|
||||
final value = _string(connection, 'effectiveType', 'unknown').toLowerCase();
|
||||
if (value == 'slow-2g') return '2g';
|
||||
const allowed = {'2g', '3g', '4g', '5g'};
|
||||
return allowed.contains(value) ? value : 'unknown';
|
||||
}
|
||||
|
||||
int _downlinkKbps(Object? connection) {
|
||||
final downlink = _number(connection, 'downlink', -1);
|
||||
return downlink >= 0 ? (downlink * 1000).round() : -1;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export 'page_click_auto_fields_stub.dart'
|
||||
if (dart.library.html) 'page_click_auto_fields_web.dart';
|
||||
@@ -0,0 +1,33 @@
|
||||
class ClickDocumentMetrics {
|
||||
final int clickDocumentX;
|
||||
final int clickDocumentY;
|
||||
final int pageWidth;
|
||||
final int pageHeight;
|
||||
final int scrollTop;
|
||||
final int scrollLeft;
|
||||
|
||||
const ClickDocumentMetrics({
|
||||
required this.clickDocumentX,
|
||||
required this.clickDocumentY,
|
||||
required this.pageWidth,
|
||||
required this.pageHeight,
|
||||
required this.scrollTop,
|
||||
required this.scrollLeft,
|
||||
});
|
||||
|
||||
static const unknown = ClickDocumentMetrics(
|
||||
clickDocumentX: -1,
|
||||
clickDocumentY: -1,
|
||||
pageWidth: -1,
|
||||
pageHeight: -1,
|
||||
scrollTop: -1,
|
||||
scrollLeft: -1,
|
||||
);
|
||||
}
|
||||
|
||||
ClickDocumentMetrics currentClickDocumentMetrics({
|
||||
required int clickViewportX,
|
||||
required int clickViewportY,
|
||||
}) {
|
||||
return ClickDocumentMetrics.unknown;
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
// ignore: avoid_web_libraries_in_flutter
|
||||
import 'dart:html' as html;
|
||||
|
||||
class ClickDocumentMetrics {
|
||||
final int clickDocumentX;
|
||||
final int clickDocumentY;
|
||||
final int pageWidth;
|
||||
final int pageHeight;
|
||||
final int scrollTop;
|
||||
final int scrollLeft;
|
||||
|
||||
const ClickDocumentMetrics({
|
||||
required this.clickDocumentX,
|
||||
required this.clickDocumentY,
|
||||
required this.pageWidth,
|
||||
required this.pageHeight,
|
||||
required this.scrollTop,
|
||||
required this.scrollLeft,
|
||||
});
|
||||
|
||||
static const unknown = ClickDocumentMetrics(
|
||||
clickDocumentX: -1,
|
||||
clickDocumentY: -1,
|
||||
pageWidth: -1,
|
||||
pageHeight: -1,
|
||||
scrollTop: -1,
|
||||
scrollLeft: -1,
|
||||
);
|
||||
}
|
||||
|
||||
ClickDocumentMetrics currentClickDocumentMetrics({
|
||||
required int clickViewportX,
|
||||
required int clickViewportY,
|
||||
}) {
|
||||
try {
|
||||
final documentElement = html.document.documentElement;
|
||||
final body = html.document.body;
|
||||
final pageWidth = _maxPositive([
|
||||
documentElement?.scrollWidth,
|
||||
body?.scrollWidth,
|
||||
documentElement?.clientWidth,
|
||||
body?.clientWidth,
|
||||
]);
|
||||
final pageHeight = _maxPositive([
|
||||
documentElement?.scrollHeight,
|
||||
body?.scrollHeight,
|
||||
documentElement?.clientHeight,
|
||||
body?.clientHeight,
|
||||
]);
|
||||
if (pageWidth <= 0 || pageHeight <= 0) {
|
||||
return ClickDocumentMetrics.unknown;
|
||||
}
|
||||
|
||||
final scrollLeft = _nonNegative(html.window.pageXOffset.round());
|
||||
final scrollTop = _nonNegative(html.window.pageYOffset.round());
|
||||
return ClickDocumentMetrics(
|
||||
clickDocumentX: _nonNegative(clickViewportX + scrollLeft),
|
||||
clickDocumentY: _nonNegative(clickViewportY + scrollTop),
|
||||
pageWidth: pageWidth,
|
||||
pageHeight: pageHeight,
|
||||
scrollTop: scrollTop,
|
||||
scrollLeft: scrollLeft,
|
||||
);
|
||||
} catch (_) {
|
||||
return ClickDocumentMetrics.unknown;
|
||||
}
|
||||
}
|
||||
|
||||
int _maxPositive(List<int?> values) {
|
||||
var max = -1;
|
||||
for (final value in values) {
|
||||
if (value != null && value > max) max = value;
|
||||
}
|
||||
return max > 0 ? max : -1;
|
||||
}
|
||||
|
||||
int _nonNegative(int value) => value < 0 ? 0 : value;
|
||||
@@ -0,0 +1,177 @@
|
||||
// lib/utils/page_name_mapper.dart
|
||||
|
||||
import 'package:analytics_sdk/utils/logger.dart';
|
||||
|
||||
/// 统一的页面 pageKey → pageName 映射管理器
|
||||
/// 支持静态默认映射 + 动态添加/覆盖(业务方可在初始化时自定义)
|
||||
///
|
||||
/// ## 两种注册格式
|
||||
///
|
||||
/// **老格式(精确匹配)**:值为字符串,pageKey 与路由名称完全一致时命中
|
||||
/// ```dart
|
||||
/// PageNameMapper.addMappings({'home': '首页'});
|
||||
/// ```
|
||||
///
|
||||
/// **新格式(支持 `:param` 动态路由)**:值为 `{'key': ..., 'name': ...}`
|
||||
/// - `key`:上报时使用的稳定 pageKey(替换掉含真实 ID 的原始路由名)
|
||||
/// - `name`:页面展示名称
|
||||
/// ```dart
|
||||
/// PageNameMapper.addMappings({
|
||||
/// '/video/:id': {'key': 'video_detail', 'name': '视频详情'},
|
||||
/// });
|
||||
/// ```
|
||||
class PageNameMapper {
|
||||
// 默认映射表(SDK 内置,精确匹配)
|
||||
static const Map<String, String> _defaultMap = {};
|
||||
|
||||
// 老格式:精确匹配映射表(路由名 → pageName)
|
||||
static final Map<String, String> _customMap = {};
|
||||
|
||||
// 新格式::param 模式映射表(pattern → ResolvedPageInfo)
|
||||
static final Map<String, ResolvedPageInfo> _patternMap = {};
|
||||
|
||||
// 新格式 resolvedKey 缓存(resolvedKey → pageName),与 _customMap 隔离
|
||||
// 供 getPageName 在 currentPageKey 已被解析为稳定 key 时反查 pageName
|
||||
static final Map<String, String> _resolvedKeyMap = {};
|
||||
|
||||
static const String _tag = 'PageNameMapper';
|
||||
|
||||
/// 去除 pageKey 前导斜杠,结果为空时保留原值(如纯 "/" 保持为 "/")
|
||||
static String normalizeKey(String pageKey) {
|
||||
if (!pageKey.startsWith('/')) return pageKey;
|
||||
final stripped = pageKey.substring(1);
|
||||
return stripped.isEmpty ? pageKey : stripped;
|
||||
}
|
||||
|
||||
/// 获取页面名称(老格式精确匹配,行为与之前完全一致)
|
||||
///
|
||||
/// 同时查 _resolvedKeyMap,确保新格式解析后的稳定 key 也能反查到 pageName
|
||||
static String getPageName(String pageKey) {
|
||||
final key = normalizeKey(pageKey);
|
||||
return _customMap[key] ?? _resolvedKeyMap[key] ?? _defaultMap[key] ?? key;
|
||||
}
|
||||
|
||||
/// 解析路由名称,返回上报用的稳定 [ResolvedPageInfo]。
|
||||
///
|
||||
/// 查找优先级:
|
||||
/// 1. 老格式精确匹配(_customMap / _defaultMap)
|
||||
/// 2. 新格式精确匹配(_patternMap)
|
||||
/// 3. 新格式 `:param` 通配匹配(_patternMap)
|
||||
/// 4. Fallback:key = name = 归一化后的路由名
|
||||
static ResolvedPageInfo resolvePageInfo(String routeName) {
|
||||
final normalized = normalizeKey(routeName);
|
||||
|
||||
// 1. 老格式精确匹配(只查 _customMap / _defaultMap,不查 _resolvedKeyMap)
|
||||
final directName = _customMap[normalized] ?? _defaultMap[normalized];
|
||||
if (directName != null) return ResolvedPageInfo(normalized, directName);
|
||||
|
||||
// 2. 新格式精确匹配
|
||||
final exactPattern = _patternMap[normalized];
|
||||
if (exactPattern != null) return exactPattern;
|
||||
|
||||
// 3. 新格式 :param 通配匹配
|
||||
for (final entry in _patternMap.entries) {
|
||||
if (_matchesPattern(entry.key, normalized)) {
|
||||
return entry.value;
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Fallback
|
||||
return ResolvedPageInfo(normalized, normalized);
|
||||
}
|
||||
|
||||
/// 动态添加或覆盖单个映射
|
||||
///
|
||||
/// [value] 可为字符串(老格式)或 `{'key': String, 'name': String}`(新格式)
|
||||
static void addMapping(String routePattern, dynamic value) {
|
||||
final normalized = normalizeKey(routePattern);
|
||||
if (value is String) {
|
||||
_customMap[normalized] = value;
|
||||
} else if (value is Map) {
|
||||
// ── 风险 1 修复:严格校验 key / name 字段,避免运行时 crash ──
|
||||
final resolvedKey = value['key'];
|
||||
final name = value['name'];
|
||||
if (resolvedKey is! String || resolvedKey.isEmpty) {
|
||||
Logger.warn(_tag, 'addMapping("$routePattern") 的 key 字段无效或为空,已忽略');
|
||||
return;
|
||||
}
|
||||
if (name is! String || name.isEmpty) {
|
||||
Logger.warn(_tag, 'addMapping("$routePattern") 的 name 字段无效或为空,已忽略');
|
||||
return;
|
||||
}
|
||||
|
||||
// ── 风险 3 修复:检测与已注册 pattern 的歧义冲突 ──
|
||||
if (_hasConflictingPattern(normalized)) {
|
||||
Logger.warn(
|
||||
_tag,
|
||||
'addMapping("$routePattern") 与已注册的某个 pattern 存在歧义,'
|
||||
'将按注册顺序匹配,请检查是否重复注册',
|
||||
);
|
||||
}
|
||||
|
||||
_patternMap[normalized] = ResolvedPageInfo(resolvedKey, name);
|
||||
// ── 风险 2 修复:写入独立的 _resolvedKeyMap,不污染 _customMap ──
|
||||
_resolvedKeyMap[resolvedKey] = name;
|
||||
}
|
||||
}
|
||||
|
||||
/// 批量动态添加或覆盖映射,兼容老格式与新格式混传
|
||||
static void addMappings(Map<String, dynamic> mappings) {
|
||||
for (final entry in mappings.entries) {
|
||||
addMapping(entry.key, entry.value);
|
||||
}
|
||||
}
|
||||
|
||||
/// 清空自定义映射(测试或重置用)
|
||||
static void clearCustom() {
|
||||
_customMap.clear();
|
||||
_patternMap.clear();
|
||||
_resolvedKeyMap.clear();
|
||||
}
|
||||
|
||||
/// 判断路由名称是否匹配某个 :param 模式
|
||||
static bool _matchesPattern(String pattern, String routeName) {
|
||||
final patternParts = pattern.split('/');
|
||||
final routeParts = routeName.split('/');
|
||||
if (patternParts.length != routeParts.length) return false;
|
||||
for (int i = 0; i < patternParts.length; i++) {
|
||||
if (patternParts[i].startsWith(':')) continue; // 通配符,跳过
|
||||
if (patternParts[i] != routeParts[i]) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/// 检测新 pattern 是否与已注册的 pattern 存在歧义(可能命中同一路由)
|
||||
static bool _hasConflictingPattern(String newPattern) {
|
||||
for (final existing in _patternMap.keys) {
|
||||
if (existing == newPattern) continue; // 相同 pattern,属于覆盖,不是冲突
|
||||
if (_patternsConflict(existing, newPattern)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// 判断两个 pattern 是否可能匹配同一条路由
|
||||
static bool _patternsConflict(String p1, String p2) {
|
||||
final parts1 = p1.split('/');
|
||||
final parts2 = p2.split('/');
|
||||
if (parts1.length != parts2.length) return false;
|
||||
for (int i = 0; i < parts1.length; i++) {
|
||||
final isParam1 = parts1[i].startsWith(':');
|
||||
final isParam2 = parts2[i].startsWith(':');
|
||||
// 两段都是固定字面量且不相等 → 不可能同时匹配
|
||||
if (!isParam1 && !isParam2 && parts1[i] != parts2[i]) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/// [PageNameMapper.resolvePageInfo] 的返回值:路由解析后的稳定标识
|
||||
class ResolvedPageInfo {
|
||||
/// 上报用的稳定 pageKey(新格式下为业务方自定义 key,老格式下为归一化路由名)
|
||||
final String key;
|
||||
|
||||
/// 页面展示名称
|
||||
final String name;
|
||||
|
||||
const ResolvedPageInfo(this.key, this.name);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import 'package:analytics_sdk/utils/logger.dart';
|
||||
|
||||
/// 校验页面生命周期 Observer 的接入状态,并避免重复刷屏。
|
||||
class PageObserverRegistrationGuard {
|
||||
bool _missingWarningLogged = false;
|
||||
bool _duplicateWarningLogged = false;
|
||||
|
||||
void reset() {
|
||||
_missingWarningLogged = false;
|
||||
_duplicateWarningLogged = false;
|
||||
}
|
||||
|
||||
void check({
|
||||
required bool pageObserverAttached,
|
||||
required bool sessionObserverAttached,
|
||||
}) {
|
||||
if (!pageObserverAttached && !sessionObserverAttached) {
|
||||
if (_missingWarningLogged) return;
|
||||
_missingWarningLogged = true;
|
||||
Logger.pageLifecycleObserver(
|
||||
'未检测到页面 Observer:请在 MaterialApp.navigatorObservers 中注册 '
|
||||
'AnalyticsSdk.instance.pageObserver 或 '
|
||||
'AnalyticsSdk.instance.sessionObserver,二选一。否则 '
|
||||
'app_session_summary 的 page_stay_list 可能不完整。',
|
||||
level: LogLevel.warn,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (pageObserverAttached && sessionObserverAttached) {
|
||||
if (_duplicateWarningLogged) return;
|
||||
_duplicateWarningLogged = true;
|
||||
Logger.pageLifecycleObserver(
|
||||
'检测到 pageObserver 与 sessionObserver 同时注册:两者必须二选一。'
|
||||
'SDK 已对页面停留和自动 app_page_view 做幂等保护,'
|
||||
'但请尽快修正接入配置。',
|
||||
level: LogLevel.warn,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export 'page_view_auto_fields_stub.dart'
|
||||
if (dart.library.html) 'page_view_auto_fields_web.dart';
|
||||
@@ -0,0 +1 @@
|
||||
String currentPageUrl() => '';
|
||||
@@ -0,0 +1,27 @@
|
||||
// ignore: avoid_web_libraries_in_flutter
|
||||
import 'dart:html' as html;
|
||||
|
||||
String currentPageUrl() {
|
||||
try {
|
||||
final uri = Uri.tryParse(html.window.location.href);
|
||||
if (uri == null) return '';
|
||||
final buffer = StringBuffer();
|
||||
if (uri.scheme.isNotEmpty) {
|
||||
buffer
|
||||
..write(uri.scheme)
|
||||
..write('://');
|
||||
}
|
||||
if (uri.host.isNotEmpty) {
|
||||
buffer.write(uri.host);
|
||||
}
|
||||
if (uri.hasPort) {
|
||||
buffer.write(':${uri.port}');
|
||||
}
|
||||
if (uri.path.isNotEmpty) {
|
||||
buffer.write(uri.path);
|
||||
}
|
||||
return buffer.toString();
|
||||
} catch (_) {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import 'package:analytics_sdk/utils/platform_storage.dart';
|
||||
|
||||
class SdkActivationStore {
|
||||
SdkActivationStore._();
|
||||
|
||||
static const String _storageKey = 'analytics_sdk_activation_time.txt';
|
||||
|
||||
static Future<int> getOrCreateActivationTime() async {
|
||||
try {
|
||||
final cached = await PlatformStorage.read(_storageKey);
|
||||
final cachedTs = int.tryParse(cached?.trim() ?? '');
|
||||
if (cachedTs != null && cachedTs > 0) return cachedTs;
|
||||
|
||||
final now = DateTime.now().millisecondsSinceEpoch ~/ 1000;
|
||||
await PlatformStorage.write(_storageKey, now.toString());
|
||||
return now;
|
||||
} catch (_) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,439 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:analytics_sdk/utils/device_fingerprint_util.dart';
|
||||
import 'package:analytics_sdk/utils/logger.dart';
|
||||
import 'package:crypto/crypto.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
/// SDK 全局上下文:持有所有公共字段、提供 event_id 计算。
|
||||
///
|
||||
/// 作为 SDK 内部的单一状态来源(SSOT),所有事件构造时从此处读取公共字段。
|
||||
/// 不对外暴露,接入方无需直接使用。
|
||||
class SdkContext {
|
||||
/// SDK 版本号(写死,与 pubspec.yaml 保持一致)
|
||||
static const String kSdkVersion = '1.4.6';
|
||||
|
||||
/// appVersion 公共字段默认值。
|
||||
/// 接入方未传 `appVersion`、或 release 模式下传入非法值时均使用该值,
|
||||
/// 保证事件 payload 与 `AnalyticsSdk.getParams()` 返回值保持一致。
|
||||
static const String kDefaultAppVersion = '1.0.0';
|
||||
|
||||
/// 客户端事件签名规则版本。
|
||||
static const String kEventSignatureVersion = '2.0.0';
|
||||
|
||||
static String _eventSignatureHmacKey = '';
|
||||
static String _eventSignatureVersion = kEventSignatureVersion;
|
||||
|
||||
static String? _appId;
|
||||
static String? _channel;
|
||||
static String? _uid;
|
||||
static String? _appVersion;
|
||||
static String? _device;
|
||||
static String? _deviceId;
|
||||
static String? _deviceBrand;
|
||||
static String? _deviceModel;
|
||||
static String? _userAgent;
|
||||
static String? _systemName;
|
||||
static String? _systemVersion;
|
||||
static String? _deviceFingerprint;
|
||||
static String _fingerprintVersion = DeviceFingerprintUtil.kVersion;
|
||||
static Map<String, dynamic> _environmentFields = _defaultEnvironmentFields();
|
||||
|
||||
/// SID 提供者(由 AnalyticsSdk.init() 注入)。
|
||||
/// 调用时同时返回当前 SID 并记录用户行为,避免 entity 层直接依赖 SessionManager。
|
||||
static String Function()? sidProvider;
|
||||
|
||||
/// 配置公共字段
|
||||
///
|
||||
/// 所有参数均为可选,非 null 值才会覆盖现有值。
|
||||
/// 通常在 [AnalyticsSdk.init] 中调用,也可在 init 之后按需更新。
|
||||
/// deviceFingerprint / fingerprintVersion 不再支持从外部传入。
|
||||
static void configure({
|
||||
String? appId,
|
||||
String? channel,
|
||||
String? uid,
|
||||
String? appVersion,
|
||||
String? device,
|
||||
String? deviceId,
|
||||
String? deviceBrand,
|
||||
String? deviceModel,
|
||||
String? userAgent,
|
||||
String? systemName,
|
||||
String? systemVersion,
|
||||
String? deviceFingerprint,
|
||||
String? fingerprintVersion,
|
||||
Map<String, dynamic>? environmentFields,
|
||||
}) {
|
||||
if (appId != null) _appId = appId;
|
||||
if (channel != null) _channel = channel;
|
||||
if (uid != null) _uid = uid;
|
||||
if (appVersion != null) {
|
||||
if (RegExp(r'^\d+\.\d+\.\d+$').hasMatch(appVersion)) {
|
||||
_appVersion = appVersion;
|
||||
} else if (kDebugMode) {
|
||||
Logger.analyticsSdk('appVersion 格式不正确,应为 x.y.z,当前值:$appVersion,已忽略',
|
||||
level: LogLevel.error);
|
||||
} else {
|
||||
_appVersion = kDefaultAppVersion;
|
||||
}
|
||||
}
|
||||
if (device != null) _device = device;
|
||||
if (deviceId != null) _deviceId = deviceId;
|
||||
if (deviceBrand != null) _deviceBrand = deviceBrand;
|
||||
if (deviceModel != null) _deviceModel = deviceModel;
|
||||
if (userAgent != null) _userAgent = userAgent;
|
||||
if (systemName != null) _systemName = systemName;
|
||||
if (systemVersion != null) _systemVersion = systemVersion;
|
||||
if (environmentFields != null) {
|
||||
_environmentFields = {
|
||||
..._defaultEnvironmentFields(),
|
||||
..._normalizeEnvironmentFields(environmentFields),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// SDK 内部调用:重置自动生成的设备指纹状态。
|
||||
static void resetGeneratedDeviceFingerprint() {
|
||||
_deviceFingerprint = null;
|
||||
_fingerprintVersion = DeviceFingerprintUtil.kVersion;
|
||||
}
|
||||
|
||||
/// SDK 内部调用:写入已生成的设备指纹。
|
||||
static void configureGeneratedDeviceFingerprint(String deviceFingerprint) {
|
||||
final normalized = deviceFingerprint.trim();
|
||||
_deviceFingerprint = normalized.isEmpty ? null : normalized;
|
||||
_fingerprintVersion = DeviceFingerprintUtil.kVersion;
|
||||
}
|
||||
|
||||
/// 更新用户 ID(登录或切换账号时调用)
|
||||
static void setUid(String newUid) {
|
||||
_uid = newUid;
|
||||
}
|
||||
|
||||
/// 更新渠道(渠道变更时调用)
|
||||
static void setChannel(String channel) {
|
||||
_channel = channel;
|
||||
}
|
||||
|
||||
/// 增量更新系统、网络、硬件和浏览器环境快照字段。
|
||||
///
|
||||
/// 用于网络、电量、充电状态等运行期可能变化的字段刷新。
|
||||
static void updateEnvironmentFields(Map<String, dynamic> fields) {
|
||||
if (fields.isEmpty) return;
|
||||
_environmentFields = {
|
||||
..._environmentFields,
|
||||
..._normalizeEnvironmentFields(fields),
|
||||
};
|
||||
}
|
||||
|
||||
/// 配置客户端事件签名密钥。
|
||||
///
|
||||
/// 密钥来自加密配置解密后的 `signKey`,不写入事件、payload、日志或缓存。
|
||||
static void configureEventSignature({
|
||||
String? signKey,
|
||||
String? version,
|
||||
}) {
|
||||
if (signKey != null) {
|
||||
_eventSignatureHmacKey = signKey.trim();
|
||||
}
|
||||
final normalizedVersion = version?.trim();
|
||||
if (normalizedVersion != null && normalizedVersion.isNotEmpty) {
|
||||
_eventSignatureVersion = normalizedVersion;
|
||||
}
|
||||
}
|
||||
|
||||
/// 重置所有公共字段为 null(SDK 重新初始化时使用)
|
||||
///
|
||||
/// 与 [configure] 不同,此方法会将所有字段清为 null,
|
||||
/// 而 configure 中传 null 表示"不修改"。
|
||||
static void reset() {
|
||||
_appId = null;
|
||||
_channel = null;
|
||||
_uid = null;
|
||||
_appVersion = null;
|
||||
_device = null;
|
||||
_deviceId = null;
|
||||
_deviceBrand = null;
|
||||
_deviceModel = null;
|
||||
_userAgent = null;
|
||||
_systemName = null;
|
||||
_systemVersion = null;
|
||||
_deviceFingerprint = null;
|
||||
_fingerprintVersion = DeviceFingerprintUtil.kVersion;
|
||||
_environmentFields = _defaultEnvironmentFields();
|
||||
_eventSignatureHmacKey = '';
|
||||
_eventSignatureVersion = kEventSignatureVersion;
|
||||
sidProvider = null;
|
||||
}
|
||||
|
||||
// ──────── Getters(调试 / 外部复用) ────────────────────────────────────
|
||||
|
||||
static String? get appId => _appId;
|
||||
static String? get channel => _channel;
|
||||
static String? get uid => _uid;
|
||||
static String? get appVersion => _appVersion;
|
||||
static String? get deviceId => _deviceId;
|
||||
static String? get deviceBrand => _deviceBrand;
|
||||
static String? get deviceModel => _deviceModel;
|
||||
static String get deviceFingerprint => _deviceFingerprint ?? '';
|
||||
static String get fingerprintVersion => _fingerprintVersion;
|
||||
static Map<String, dynamic> get environmentFields =>
|
||||
Map<String, dynamic>.from(_environmentFields);
|
||||
|
||||
/// 获取设备公共字段(供产品方在服务端上报事件时复用)
|
||||
///
|
||||
/// 返回的 Map 包含:sid、device、device_id、device_brand、device_model、
|
||||
/// user_agent、system_name、system_version、device_fingerprint、fp_version。
|
||||
/// 未配置的字段返回空字符串。
|
||||
static Map<String, String> getDeviceCommonFields() {
|
||||
String sid = '';
|
||||
try {
|
||||
sid = sidProvider?.call() ?? '';
|
||||
} catch (_) {
|
||||
// 未初始化或 SID 获取异常时保持为空,确保该读取 API 不抛异常。
|
||||
}
|
||||
|
||||
return {
|
||||
'sid': sid,
|
||||
'device': _device ?? '',
|
||||
'device_id': _deviceId ?? '',
|
||||
'device_brand': _deviceBrand ?? '',
|
||||
'device_model': _deviceModel ?? '',
|
||||
'user_agent': _userAgent ?? '',
|
||||
'system_name': _systemName ?? '',
|
||||
'system_version': _systemVersion ?? '',
|
||||
'device_fingerprint': _deviceFingerprint ?? '',
|
||||
'fp_version': _fingerprintVersion,
|
||||
};
|
||||
}
|
||||
|
||||
/// 获取系统、网络、硬件和浏览器环境快照字段。
|
||||
///
|
||||
/// 仅供 SDK 内部构建 `sdk_init` 启动快照和少量汇总事件使用,普通事件公共字段
|
||||
/// 不再重复携带这些环境快照。
|
||||
static Map<String, dynamic> getEnvironmentSnapshotFields() {
|
||||
return Map<String, dynamic>.from(_environmentFields);
|
||||
}
|
||||
|
||||
/// 给 SDK 客户端事件补充外层签名字段。
|
||||
///
|
||||
/// 历史版本曾把轻量环境签名写入 payload;这里会清理这些保留字段,
|
||||
/// 避免同一事件同时出现新旧两套签名。
|
||||
static Map<String, dynamic> attachDeviceEnvIfNeeded(
|
||||
Map<String, dynamic> event,
|
||||
) {
|
||||
final eventName = event['event']?.toString() ?? '';
|
||||
final isServerOnlyEvent = _serverOnlyEvents.contains(eventName);
|
||||
final result = Map<String, dynamic>.from(event);
|
||||
final existingPayload = result['payload'];
|
||||
final existingDeviceEnv = result.remove('device_env');
|
||||
|
||||
if (existingPayload is Map || existingPayload == null) {
|
||||
final payload = <String, dynamic>{
|
||||
if (existingPayload is Map)
|
||||
...Map<String, dynamic>.from(existingPayload),
|
||||
if (existingDeviceEnv is Map)
|
||||
...Map<String, dynamic>.from(existingDeviceEnv),
|
||||
};
|
||||
payload.removeWhere(
|
||||
(key, _) => _reservedPayloadSignatureFields.contains(key));
|
||||
result['payload'] = payload;
|
||||
}
|
||||
|
||||
if (!isServerOnlyEvent && _eventSignatureHmacKey.isNotEmpty) {
|
||||
final eventForSignature = Map<String, dynamic>.from(result);
|
||||
result['event_sig_version'] = _eventSignatureVersion;
|
||||
result['event_sig'] = _eventSignature(eventForSignature);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/// 生成公共字段 Map
|
||||
///
|
||||
/// 包含所有客户端事件上报所需的公共字段:
|
||||
/// event、channel、app_id、uid、sid、client_ts、
|
||||
/// device、device_id、device_brand、device_model、
|
||||
/// user_agent、system_name、system_version、
|
||||
/// device_fingerprint、fp_version、sdk_version、app_version。
|
||||
///
|
||||
/// 本方法永远不会抛出异常。
|
||||
static Map<String, dynamic> generateCommonFields(String event) {
|
||||
final now = DateTime.now();
|
||||
final clientTs = now.millisecondsSinceEpoch ~/ 1000;
|
||||
|
||||
String sid;
|
||||
try {
|
||||
sid = sidProvider?.call() ?? 'fallback_${now.millisecondsSinceEpoch}';
|
||||
} catch (_) {
|
||||
sid = 'fallback_${now.millisecondsSinceEpoch}';
|
||||
}
|
||||
|
||||
return {
|
||||
'event': event,
|
||||
'channel': _channel ?? '',
|
||||
'app_id': _appId ?? '',
|
||||
'uid': _uid ?? '',
|
||||
'sid': sid,
|
||||
'client_ts': clientTs,
|
||||
'device': _device ?? '',
|
||||
'device_id': _deviceId ?? '',
|
||||
'device_brand': _deviceBrand ?? '',
|
||||
'device_model': _deviceModel ?? '',
|
||||
'user_agent': _userAgent ?? '',
|
||||
'system_name': _systemName ?? '',
|
||||
'system_version': _systemVersion ?? '',
|
||||
'device_fingerprint': _deviceFingerprint ?? '',
|
||||
'fp_version': _fingerprintVersion,
|
||||
'sdk_version': kSdkVersion,
|
||||
'app_version': _appVersion ?? kDefaultAppVersion,
|
||||
};
|
||||
}
|
||||
|
||||
/// 根据字段列表生成 event_id
|
||||
///
|
||||
/// 计算规则:
|
||||
/// 1. 对每个字段值单独做 MD5
|
||||
/// 2. 将所有 MD5 用 `|` 拼接
|
||||
/// 3. 对拼接结果再做一次 MD5,作为最终 event_id
|
||||
///
|
||||
/// **注意**:`device_fingerprint` 不参与 event_id 计算。
|
||||
/// 本方法永远不会抛出异常,失败时返回基于时间戳的备选 ID。
|
||||
static String generateEventId(List<String> fields) {
|
||||
try {
|
||||
final fieldDigests = <String>[];
|
||||
for (final value in fields) {
|
||||
final v = value;
|
||||
final bytes = utf8.encode(v);
|
||||
final digest = md5.convert(bytes);
|
||||
fieldDigests.add(digest.toString());
|
||||
}
|
||||
|
||||
final joined = fieldDigests.join('|');
|
||||
final finalDigest = md5.convert(utf8.encode(joined));
|
||||
return finalDigest.toString();
|
||||
} catch (e) {
|
||||
try {
|
||||
final content = fields.join('|');
|
||||
return 'h${(content.hashCode & 0x7FFFFFFF).toRadixString(16)}';
|
||||
} catch (_) {
|
||||
return 't${DateTime.now().millisecondsSinceEpoch}';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static Map<String, dynamic> _defaultEnvironmentFields() => {
|
||||
'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,
|
||||
};
|
||||
|
||||
static Map<String, dynamic> _normalizeEnvironmentFields(
|
||||
Map<String, dynamic> fields,
|
||||
) {
|
||||
final normalized = Map<String, dynamic>.from(fields);
|
||||
if (normalized.containsKey('device_pixel_ratio')) {
|
||||
normalized['device_pixel_ratio'] =
|
||||
_normalizeDevicePixelRatio(normalized['device_pixel_ratio']);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
static int _normalizeDevicePixelRatio(dynamic value) {
|
||||
if (value == null) return -1;
|
||||
final ratio =
|
||||
value is num ? value.toDouble() : double.tryParse(value.toString());
|
||||
if (ratio == null || !ratio.isFinite) return -1;
|
||||
if (ratio > 10) return ratio.round();
|
||||
return (ratio * 100).round();
|
||||
}
|
||||
|
||||
static const Set<String> _serverOnlyEvents = {
|
||||
'user_register',
|
||||
'user_login',
|
||||
'realtime_online',
|
||||
'order_created',
|
||||
'order_paid',
|
||||
'coin_consume',
|
||||
'video_status_change',
|
||||
};
|
||||
|
||||
static const Set<String> _reservedPayloadSignatureFields = {
|
||||
'sdk_type',
|
||||
'client_type',
|
||||
'id_type',
|
||||
'event_sig',
|
||||
'event_sig_version',
|
||||
'security_key_version',
|
||||
'sign_algorithm',
|
||||
'sign_ts',
|
||||
};
|
||||
|
||||
static String _eventSignature(
|
||||
Map<String, dynamic> event,
|
||||
) {
|
||||
try {
|
||||
if (_eventSignatureHmacKey.isEmpty) return '';
|
||||
final factors = [
|
||||
event['event']?.toString() ?? '',
|
||||
event['event_id']?.toString() ?? '',
|
||||
event['app_id']?.toString() ?? '',
|
||||
event['client_ts']?.toString() ?? '',
|
||||
event['device_fingerprint']?.toString() ?? '',
|
||||
];
|
||||
final signatureInput = factors.join('|');
|
||||
final signature = Hmac(sha256, utf8.encode(_eventSignatureHmacKey))
|
||||
.convert(utf8.encode(signatureInput))
|
||||
.toString();
|
||||
Logger.analyticsSdk(
|
||||
'event_sig input="$signatureInput" '
|
||||
'event_sig=$signature '
|
||||
'event_sig_version=$_eventSignatureVersion',
|
||||
);
|
||||
return signature;
|
||||
} catch (_) {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/// 从公共字段 Map 和业务特有字段列表生成 event_id。
|
||||
///
|
||||
/// 本方法永远不会抛出异常。
|
||||
static String generateEventIdFromCommonFields(
|
||||
Map<String, dynamic> commonFields, List<String> extraFields) {
|
||||
return generateEventId([
|
||||
commonFields['event'] as String? ?? '',
|
||||
commonFields['app_id'] as String? ?? '',
|
||||
commonFields['channel'] as String? ?? '',
|
||||
commonFields['uid'] as String? ?? '',
|
||||
commonFields['device'] as String? ?? '',
|
||||
commonFields['device_id'] as String? ?? '',
|
||||
commonFields['device_brand'] as String? ?? '',
|
||||
commonFields['device_model'] as String? ?? '',
|
||||
(commonFields['client_ts'] ?? '').toString(),
|
||||
commonFields['sid'] as String? ?? '',
|
||||
...extraFields,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
/// Builds the sdk_init payload from the SDK common fields plus platform-only
|
||||
/// fields. Web and app clients intentionally carry different field families.
|
||||
class SdkInitPayloadBuilder {
|
||||
SdkInitPayloadBuilder._();
|
||||
|
||||
static Map<String, dynamic> build({
|
||||
required Map<String, dynamic> commonFields,
|
||||
required bool isWeb,
|
||||
Map<String, dynamic> platformFields = const {},
|
||||
}) {
|
||||
final fields = {
|
||||
...commonFields,
|
||||
...platformFields,
|
||||
};
|
||||
|
||||
final osFamily = _osFamily(_string(fields['system_name'], 'unknown'));
|
||||
final payload = <String, dynamic>{
|
||||
'platform': isWeb ? 'web' : 'flutter',
|
||||
'os_family': osFamily,
|
||||
'device_type': _deviceType(_string(fields['device'], '')),
|
||||
'language': _string(fields['language'], 'unknown'),
|
||||
'timezone': _string(fields['timezone'], 'unknown'),
|
||||
'network_type': _string(fields['network_type'], 'unknown'),
|
||||
'screen_width': _int(fields['screen_width'], -1),
|
||||
'screen_height': _int(fields['screen_height'], -1),
|
||||
'screen_dpi': _int(fields['screen_dpi'], -1),
|
||||
'device_pixel_ratio': _devicePixelRatio(fields['device_pixel_ratio']),
|
||||
'orientation': _string(fields['orientation'], 'unknown'),
|
||||
'cpu_cores': _int(fields['cpu_cores'], -1),
|
||||
'device_memory_gb': _int(fields['device_memory_gb'], -1),
|
||||
'is_debug': kDebugMode ? 1 : 0,
|
||||
'flutter_build_mode': _string(fields['flutter_build_mode'], _buildMode()),
|
||||
'touch_support': _int(fields['touch_support'], -1),
|
||||
'max_touch_points': _int(fields['max_touch_points'], -1),
|
||||
};
|
||||
|
||||
if (isWeb) {
|
||||
payload.addAll(_webPayload(fields));
|
||||
} else if (osFamily == 'android') {
|
||||
payload.addAll(_androidPayload(fields));
|
||||
} else if (osFamily == 'ios') {
|
||||
payload.addAll(_iosPayload(fields));
|
||||
} else {
|
||||
payload.addAll(_flutterPayload());
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
static Map<String, dynamic> _nativeSharedPayload(
|
||||
Map<String, dynamic> fields,
|
||||
) {
|
||||
return {
|
||||
'carrier': _string(fields['carrier'], ''),
|
||||
'mcc': _string(fields['mcc'], 'unknown'),
|
||||
'mnc': _string(fields['mnc'], 'unknown'),
|
||||
'has_sim': _int(fields['has_sim'], -1),
|
||||
'storage': _int(fields['storage'], -1),
|
||||
'available_storage': _int(fields['available_storage'], -1),
|
||||
'battery_level': _int(fields['battery_level'], -1),
|
||||
'is_charging': _int(fields['is_charging'], -1),
|
||||
'vpn_enabled': _int(fields['vpn_enabled'], -1),
|
||||
'proxy_enabled': _int(fields['proxy_enabled'], -1),
|
||||
};
|
||||
}
|
||||
|
||||
static Map<String, dynamic> _androidPayload(Map<String, dynamic> fields) {
|
||||
return {
|
||||
..._nativeSharedPayload(fields),
|
||||
'activation_time': _int(fields['activation_time'], 0),
|
||||
'install_source': _string(fields['install_source'], ''),
|
||||
'android_id': _string(fields['android_id'], ''),
|
||||
'android_id_o': _string(fields['android_id_o'], ''),
|
||||
'android_id_status': _string(fields['android_id_status'], 'unknown'),
|
||||
'oaid': _string(fields['oaid'], ''),
|
||||
'oaid_o': _string(fields['oaid_o'], ''),
|
||||
'aaid': _string(fields['aaid'], ''),
|
||||
'aaid_o': _string(fields['aaid_o'], ''),
|
||||
'guid': _string(fields['guid'], ''),
|
||||
'guid_o': _string(fields['guid_o'], ''),
|
||||
'brand':
|
||||
_string(fields['brand'], _string(fields['device_brand'], 'unknown')),
|
||||
'manufacturer': _string(fields['manufacturer'], 'unknown'),
|
||||
'model':
|
||||
_string(fields['model'], _string(fields['device_model'], 'unknown')),
|
||||
'package_name': _string(fields['package_name'], 'unknown'),
|
||||
'abi': _string(fields['abi'], 'unknown'),
|
||||
'is_rooted': _int(fields['is_rooted'], -1),
|
||||
'usb_debug_enabled': _int(fields['usb_debug_enabled'], -1),
|
||||
'developer_options_enabled':
|
||||
_int(fields['developer_options_enabled'], -1),
|
||||
'adb_enabled': _int(fields['adb_enabled'], -1),
|
||||
'has_baseband': _int(fields['has_baseband'], -1),
|
||||
'sensor_count_supported': _int(fields['sensor_count_supported'], -1),
|
||||
'has_accelerometer': _int(fields['has_accelerometer'], -1),
|
||||
'has_gyroscope': _int(fields['has_gyroscope'], -1),
|
||||
'has_magnetometer': _int(fields['has_magnetometer'], -1),
|
||||
'has_light_sensor': _int(fields['has_light_sensor'], -1),
|
||||
'has_proximity_sensor': _int(fields['has_proximity_sensor'], -1),
|
||||
'build_hardware': _string(fields['build_hardware'], 'unknown'),
|
||||
'build_product': _string(fields['build_product'], 'unknown'),
|
||||
'build_fingerprint': _string(fields['build_fingerprint'], 'unknown'),
|
||||
};
|
||||
}
|
||||
|
||||
static Map<String, dynamic> _iosPayload(Map<String, dynamic> fields) {
|
||||
return {
|
||||
..._nativeSharedPayload(fields),
|
||||
'bundle_id': _string(fields['bundle_id'], ''),
|
||||
'ios_device_model_raw': _string(fields['ios_device_model_raw'], ''),
|
||||
'idfv': _string(fields['idfv'], ''),
|
||||
'idfa': _string(fields['idfa'], ''),
|
||||
'att_status': _string(fields['att_status'], 'unknown'),
|
||||
'lat': _int(fields['lat'], -1),
|
||||
'is_jailbroken': _int(fields['is_jailbroken'], -1),
|
||||
};
|
||||
}
|
||||
|
||||
static Map<String, dynamic> _flutterPayload() {
|
||||
return const {};
|
||||
}
|
||||
|
||||
static Map<String, dynamic> _webPayload(Map<String, dynamic> fields) {
|
||||
return {
|
||||
'current_url': _string(fields['current_url'], ''),
|
||||
'current_domain': _string(fields['current_domain'], ''),
|
||||
'current_path': _string(fields['current_path'], ''),
|
||||
'current_query_hash': _string(fields['current_query_hash'], ''),
|
||||
'referer': _string(fields['referer'], ''),
|
||||
'referer_domain': _string(fields['referer_domain'], ''),
|
||||
'referer_path': _string(fields['referer_path'], ''),
|
||||
'browser_name': _string(fields['browser_name'], 'unknown'),
|
||||
'browser_version': _string(fields['browser_version'], 'unknown'),
|
||||
'browser_kernel': _string(fields['browser_kernel'], 'unknown'),
|
||||
'display_mode': _string(fields['display_mode'], 'unknown'),
|
||||
'visibility_state': _string(fields['visibility_state'], 'unknown'),
|
||||
'cookie_enabled': _int(fields['cookie_enabled'], -1),
|
||||
'local_storage_enabled': _int(fields['local_storage_enabled'], -1),
|
||||
'session_storage_enabled': _int(fields['session_storage_enabled'], -1),
|
||||
'indexeddb_enabled': _int(fields['indexeddb_enabled'], -1),
|
||||
'service_worker_enabled': _int(fields['service_worker_enabled'], -1),
|
||||
'connection_type': _string(fields['connection_type'], 'unknown'),
|
||||
'effective_type': _string(fields['effective_type'], 'unknown'),
|
||||
'downlink': _int(fields['downlink'], -1),
|
||||
'rtt': _int(fields['rtt'], -1),
|
||||
'canvas_fp': _string(fields['canvas_fp'], ''),
|
||||
'webgl_fp': _string(fields['webgl_fp'], ''),
|
||||
'webgl_vendor': _string(fields['webgl_vendor'], 'unknown'),
|
||||
'webgl_renderer': _string(fields['webgl_renderer'], 'unknown'),
|
||||
'webgl_status': _string(fields['webgl_status'], 'unknown'),
|
||||
'webgl_extensions_hash': _string(fields['webgl_extensions_hash'], ''),
|
||||
'webgl_params_hash': _string(fields['webgl_params_hash'], ''),
|
||||
'audio_sample_rate': _int(fields['audio_sample_rate'], -1),
|
||||
'fonts': _string(fields['fonts'], ''),
|
||||
'fonts_hash': _string(fields['fonts_hash'], ''),
|
||||
'font_count': _int(fields['font_count'], -1),
|
||||
'plugins': _string(fields['plugins'], ''),
|
||||
'plugins_hash': _string(fields['plugins_hash'], ''),
|
||||
'plugin_count': _int(fields['plugin_count'], -1),
|
||||
'mimes': _string(fields['mimes'], ''),
|
||||
'mimes_hash': _string(fields['mimes_hash'], ''),
|
||||
'mime_count': _int(fields['mime_count'], -1),
|
||||
'has_webdriver': _int(fields['has_webdriver'], 0),
|
||||
'hardware_concurrency': _int(fields['hardware_concurrency'], -1),
|
||||
'browser_platform': _string(fields['browser_platform'], 'unknown'),
|
||||
'color_depth': _int(fields['color_depth'], -1),
|
||||
'audio_fp': _string(fields['audio_fp'], ''),
|
||||
'viewport_width': _int(fields['viewport_width'], -1),
|
||||
'viewport_height': _int(fields['viewport_height'], -1),
|
||||
'is_headless': _int(fields['is_headless'], -1),
|
||||
'headless_reason': _string(fields['headless_reason'], ''),
|
||||
};
|
||||
}
|
||||
|
||||
static String _string(dynamic value, String fallback) {
|
||||
if (value == null) return fallback;
|
||||
final string = value.toString().trim();
|
||||
return string.isEmpty ? fallback : string;
|
||||
}
|
||||
|
||||
static int _int(dynamic value, int fallback) {
|
||||
if (value is int) return value;
|
||||
if (value is double && value.isFinite) return value.round();
|
||||
if (value is String) {
|
||||
final asInt = int.tryParse(value.trim());
|
||||
if (asInt != null) return asInt;
|
||||
final asDouble = double.tryParse(value.trim());
|
||||
if (asDouble != null && asDouble.isFinite) return asDouble.round();
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
static int _devicePixelRatio(dynamic value) {
|
||||
if (value == null) return -1;
|
||||
final ratio =
|
||||
value is num ? value.toDouble() : double.tryParse(value.toString());
|
||||
if (ratio == null || !ratio.isFinite) return -1;
|
||||
if (ratio > 10) return ratio.round();
|
||||
return (ratio * 100).round();
|
||||
}
|
||||
|
||||
static String _deviceType(String device) {
|
||||
switch (device.toLowerCase()) {
|
||||
case 'android':
|
||||
case 'ios':
|
||||
return 'phone';
|
||||
case 'pc':
|
||||
return 'desktop';
|
||||
default:
|
||||
return 'unknown';
|
||||
}
|
||||
}
|
||||
|
||||
static String _osFamily(String systemName) {
|
||||
switch (systemName.toLowerCase()) {
|
||||
case 'android':
|
||||
return 'android';
|
||||
case 'ios':
|
||||
return 'ios';
|
||||
case 'macos':
|
||||
return 'macos';
|
||||
case 'windows':
|
||||
return 'windows';
|
||||
case 'linux':
|
||||
return 'linux';
|
||||
default:
|
||||
return 'unknown';
|
||||
}
|
||||
}
|
||||
|
||||
static String _buildMode() {
|
||||
if (kReleaseMode) return 'release';
|
||||
if (kProfileMode) return 'profile';
|
||||
if (kDebugMode) return 'debug';
|
||||
return 'unknown';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export 'sdk_init_platform_fields_native.dart'
|
||||
// ignore: uri_does_not_exist
|
||||
if (dart.library.html) 'sdk_init_platform_fields_web.dart';
|
||||
@@ -0,0 +1,61 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:device_info_plus/device_info_plus.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:package_info_plus/package_info_plus.dart';
|
||||
|
||||
const MethodChannel _nativeChannel = MethodChannel('analytics_sdk');
|
||||
|
||||
Future<Map<String, dynamic>> collectSdkInitPlatformFields() async {
|
||||
final fields = <String, dynamic>{};
|
||||
|
||||
try {
|
||||
final info = await PackageInfo.fromPlatform();
|
||||
fields['package_name'] = info.packageName;
|
||||
fields['bundle_id'] = info.packageName;
|
||||
} catch (_) {}
|
||||
|
||||
try {
|
||||
final plugin = DeviceInfoPlugin();
|
||||
if (Platform.isAndroid) {
|
||||
final info = await plugin.androidInfo;
|
||||
fields.addAll({
|
||||
'brand': info.brand,
|
||||
'manufacturer': info.manufacturer,
|
||||
'model': info.model,
|
||||
'abi': info.supportedAbis.isNotEmpty
|
||||
? info.supportedAbis.first
|
||||
: 'unknown',
|
||||
'is_rooted': -1,
|
||||
'build_hardware': info.hardware,
|
||||
'build_product': info.product,
|
||||
'build_fingerprint': info.fingerprint,
|
||||
});
|
||||
} else if (Platform.isIOS) {
|
||||
final info = await plugin.iosInfo;
|
||||
fields.addAll({
|
||||
'bundle_id': fields['bundle_id'] ?? '',
|
||||
'ios_device_model_raw': info.utsname.machine,
|
||||
'is_jailbroken': -1,
|
||||
});
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
fields.addAll(await _collectNativeChannelFields());
|
||||
|
||||
return fields;
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> _collectNativeChannelFields() async {
|
||||
try {
|
||||
final result = await _nativeChannel
|
||||
.invokeMapMethod<String, dynamic>('getSdkInitPlatformFields');
|
||||
if (result == null) return const {};
|
||||
return Map<String, dynamic>.from(result)
|
||||
..removeWhere((key, value) => value == null);
|
||||
} on MissingPluginException {
|
||||
return const {};
|
||||
} catch (_) {
|
||||
return const {};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,540 @@
|
||||
// ignore: avoid_web_libraries_in_flutter
|
||||
import 'dart:convert';
|
||||
import 'dart:html' as html;
|
||||
|
||||
import 'package:crypto/crypto.dart';
|
||||
import 'package:analytics_sdk/utils/js_interop_compat.dart';
|
||||
|
||||
Future<Map<String, dynamic>> collectSdkInitPlatformFields() async {
|
||||
final navigator = html.window.navigator;
|
||||
final screen = html.window.screen;
|
||||
final uri = Uri.tryParse(html.window.location.href);
|
||||
final refererUri = Uri.tryParse(html.document.referrer);
|
||||
final connection = _connection(navigator);
|
||||
final browser = _parseBrowser(navigator.userAgent);
|
||||
final canvasFp = _canvasFingerprint();
|
||||
final webgl = _webglInfo();
|
||||
final audio = _audioInfo();
|
||||
final fonts = _fonts();
|
||||
final plugins = _plugins(navigator);
|
||||
final mimes = _mimes(navigator);
|
||||
final headless = _headlessReasons(navigator, plugins);
|
||||
headless.addAll(webgl.headlessReasons);
|
||||
if (fonts != null && fonts.isNotEmpty && fonts.length < 3) {
|
||||
headless.add('fonts_low');
|
||||
}
|
||||
|
||||
return {
|
||||
'current_url': _urlWithoutQueryAndFragment(uri),
|
||||
'current_domain': uri?.host ?? '',
|
||||
'current_path': uri?.path ?? '',
|
||||
'current_query_hash': _hash(uri?.query ?? ''),
|
||||
'referer': _urlWithoutQueryAndFragment(refererUri),
|
||||
'referer_domain': refererUri?.host ?? '',
|
||||
'referer_path': refererUri?.path ?? '',
|
||||
'browser_name': browser.name,
|
||||
'browser_version': browser.version,
|
||||
'browser_kernel': _browserKernel(navigator.userAgent),
|
||||
'display_mode': _displayMode(),
|
||||
'visibility_state': _nonEmpty(html.document.visibilityState, 'unknown'),
|
||||
'cookie_enabled': navigator.cookieEnabled == true ? 1 : 0,
|
||||
'local_storage_enabled': _storageEnabled(html.window.localStorage),
|
||||
'session_storage_enabled': _storageEnabled(html.window.sessionStorage),
|
||||
'indexeddb_enabled': _hasProperty(html.window, 'indexedDB'),
|
||||
'service_worker_enabled': _hasProperty(navigator, 'serviceWorker'),
|
||||
'connection_type': _string(connection, 'type', 'unknown'),
|
||||
'effective_type': _effectiveType(connection),
|
||||
'downlink': _downlinkKbps(connection),
|
||||
'rtt': _number(connection, 'rtt', -1).round(),
|
||||
'canvas_fp': canvasFp,
|
||||
'webgl_fp': webgl.fp,
|
||||
'webgl_vendor': webgl.vendor,
|
||||
'webgl_renderer': webgl.renderer,
|
||||
'webgl_status': webgl.status,
|
||||
'webgl_extensions_hash': webgl.extensionsHash,
|
||||
'webgl_params_hash': webgl.paramsHash,
|
||||
'audio_sample_rate': audio.sampleRate,
|
||||
'fonts': fonts?.join(',') ?? '',
|
||||
'fonts_hash': _hash(fonts?.join('|') ?? ''),
|
||||
'font_count': fonts?.length ?? -1,
|
||||
'plugins': plugins.join(','),
|
||||
'plugins_hash': _hash(plugins.join('|')),
|
||||
'plugin_count': plugins.length,
|
||||
'mimes': mimes.join(','),
|
||||
'mimes_hash': _hash(mimes.join('|')),
|
||||
'mime_count': mimes.length,
|
||||
'has_webdriver': navigator.webdriver == true ? 1 : 0,
|
||||
'hardware_concurrency': navigator.hardwareConcurrency ?? -1,
|
||||
'browser_platform': _nonEmpty(navigator.platform, 'unknown'),
|
||||
'color_depth': screen?.colorDepth ?? -1,
|
||||
'audio_fp': audio.fp,
|
||||
'viewport_width': html.window.innerWidth ?? -1,
|
||||
'viewport_height': html.window.innerHeight ?? -1,
|
||||
'touch_support': (navigator.maxTouchPoints ?? 0) > 0 ? 1 : 0,
|
||||
'max_touch_points': navigator.maxTouchPoints ?? -1,
|
||||
'is_headless': headless.isEmpty ? 0 : 1,
|
||||
'headless_reason': _unique(headless).join(','),
|
||||
};
|
||||
}
|
||||
|
||||
class _BrowserInfo {
|
||||
final String name;
|
||||
final String version;
|
||||
|
||||
const _BrowserInfo(this.name, this.version);
|
||||
}
|
||||
|
||||
class _WebglInfo {
|
||||
final String fp;
|
||||
final String vendor;
|
||||
final String renderer;
|
||||
final String status;
|
||||
final String extensionsHash;
|
||||
final String paramsHash;
|
||||
final List<String> headlessReasons;
|
||||
|
||||
const _WebglInfo({
|
||||
required this.fp,
|
||||
required this.vendor,
|
||||
required this.renderer,
|
||||
required this.status,
|
||||
required this.extensionsHash,
|
||||
required this.paramsHash,
|
||||
required this.headlessReasons,
|
||||
});
|
||||
}
|
||||
|
||||
class _AudioInfo {
|
||||
final int sampleRate;
|
||||
final String fp;
|
||||
|
||||
const _AudioInfo(this.sampleRate, this.fp);
|
||||
}
|
||||
|
||||
String _urlWithoutQueryAndFragment(Uri? uri) {
|
||||
if (uri == null) return '';
|
||||
final buffer = StringBuffer();
|
||||
if (uri.scheme.isNotEmpty) {
|
||||
buffer
|
||||
..write(uri.scheme)
|
||||
..write('://');
|
||||
}
|
||||
if (uri.host.isNotEmpty) {
|
||||
buffer.write(uri.host);
|
||||
}
|
||||
if (uri.hasPort) {
|
||||
buffer.write(':${uri.port}');
|
||||
}
|
||||
if (uri.path.isNotEmpty) {
|
||||
buffer.write(uri.path);
|
||||
}
|
||||
return buffer.toString();
|
||||
}
|
||||
|
||||
Object? _connection(html.Navigator navigator) {
|
||||
for (final key in ['connection', 'mozConnection', 'webkitConnection']) {
|
||||
try {
|
||||
if (hasJsProperty(navigator, key)) {
|
||||
return getJsProperty(navigator, key);
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
_BrowserInfo _parseBrowser(String ua) {
|
||||
final rules = <String, RegExp>{
|
||||
'Edge': RegExp(r'(?:Edg|EdgA|EdgiOS|Edge)/([\d.]+)'),
|
||||
'Opera': RegExp(r'(?:OPR|Opera)/([\d.]+)'),
|
||||
'Chrome': RegExp(r'(?:Chrome|CriOS)/([\d.]+)'),
|
||||
'Firefox': RegExp(r'(?:Firefox|FxiOS)/([\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, _nonEmpty(match.group(1), 'unknown'));
|
||||
}
|
||||
}
|
||||
return const _BrowserInfo('unknown', 'unknown');
|
||||
}
|
||||
|
||||
String _string(Object? object, String key, String fallback) {
|
||||
if (object == null) return fallback;
|
||||
try {
|
||||
final value = getJsProperty(object, key);
|
||||
final string = value?.toString().trim() ?? '';
|
||||
return string.isEmpty ? fallback : string;
|
||||
} catch (_) {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
num _number(Object? object, String key, num fallback) {
|
||||
if (object == null) return fallback;
|
||||
try {
|
||||
final value = getJsProperty(object, key);
|
||||
if (value is num && value.isFinite) return value;
|
||||
return num.tryParse(value?.toString() ?? '') ?? fallback;
|
||||
} catch (_) {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
String _effectiveType(Object? connection) {
|
||||
final value = _string(connection, 'effectiveType', 'unknown').toLowerCase();
|
||||
if (value == 'slow-2g') return '2g';
|
||||
const allowed = {'2g', '3g', '4g', '5g'};
|
||||
return allowed.contains(value) ? value : 'unknown';
|
||||
}
|
||||
|
||||
int _downlinkKbps(Object? connection) {
|
||||
final downlink = _number(connection, 'downlink', -1);
|
||||
return downlink >= 0 ? (downlink * 1000).round() : -1;
|
||||
}
|
||||
|
||||
int _hasProperty(Object object, String key) {
|
||||
try {
|
||||
return hasJsProperty(object, key) ? 1 : 0;
|
||||
} catch (_) {
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
int _storageEnabled(html.Storage storage) {
|
||||
try {
|
||||
const key = '__analytics_sdk_storage_test__';
|
||||
storage[key] = '1';
|
||||
final ok = storage[key] == '1';
|
||||
storage.remove(key);
|
||||
return ok ? 1 : 0;
|
||||
} catch (_) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
String _canvasFingerprint() {
|
||||
try {
|
||||
final canvas = html.CanvasElement(width: 240, height: 60);
|
||||
final context = canvas.context2D;
|
||||
context
|
||||
..textBaseline = 'top'
|
||||
..font = '14px Arial'
|
||||
..fillStyle = '#f60';
|
||||
context.fillRect(125, 1, 62, 20);
|
||||
context
|
||||
..fillStyle = '#069'
|
||||
..fillText('analytics_sdk_canvas_fp', 2, 15)
|
||||
..fillStyle = 'rgba(102, 204, 0, 0.7)'
|
||||
..fillText('analytics_sdk_canvas_fp', 4, 17);
|
||||
return _hash(canvas.toDataUrl());
|
||||
} catch (_) {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
_WebglInfo _webglInfo() {
|
||||
Object? gl;
|
||||
try {
|
||||
final canvas = html.CanvasElement(width: 1, height: 1);
|
||||
gl = canvas.getContext('webgl') ?? canvas.getContext('experimental-webgl');
|
||||
} catch (_) {
|
||||
return const _WebglInfo(
|
||||
fp: '',
|
||||
vendor: 'unknown',
|
||||
renderer: 'unknown',
|
||||
status: 'error',
|
||||
extensionsHash: '',
|
||||
paramsHash: '',
|
||||
headlessReasons: ['webgl_unavailable'],
|
||||
);
|
||||
}
|
||||
|
||||
if (gl == null) {
|
||||
return const _WebglInfo(
|
||||
fp: '',
|
||||
vendor: 'unknown',
|
||||
renderer: 'unknown',
|
||||
status: 'unsupported',
|
||||
extensionsHash: '',
|
||||
paramsHash: '',
|
||||
headlessReasons: ['webgl_unavailable'],
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
final debugInfo = callJsMethod(gl, 'getExtension', [
|
||||
'WEBGL_debug_renderer_info',
|
||||
]);
|
||||
final vendor =
|
||||
_webglParameter(gl, debugInfo, 'UNMASKED_VENDOR_WEBGL') ??
|
||||
_webglParameterByConstant(gl, 0x1F00) ??
|
||||
'unknown';
|
||||
final renderer =
|
||||
_webglParameter(gl, debugInfo, 'UNMASKED_RENDERER_WEBGL') ??
|
||||
_webglParameterByConstant(gl, 0x1F01) ??
|
||||
'unknown';
|
||||
final extensions = _jsStringArray(
|
||||
callJsMethod(gl, 'getSupportedExtensions'),
|
||||
)..sort();
|
||||
final params = _webglParams(gl);
|
||||
final extensionsHash = _hash(extensions.join('|'));
|
||||
final paramsHash = _hash(params.join('|'));
|
||||
final fp = _hash(
|
||||
[vendor, renderer, extensions.join('|'), params.join('|')].join('|'),
|
||||
);
|
||||
|
||||
return _WebglInfo(
|
||||
fp: fp,
|
||||
vendor: vendor,
|
||||
renderer: renderer,
|
||||
status: 'available',
|
||||
extensionsHash: extensionsHash,
|
||||
paramsHash: paramsHash,
|
||||
headlessReasons: const [],
|
||||
);
|
||||
} catch (_) {
|
||||
return const _WebglInfo(
|
||||
fp: '',
|
||||
vendor: 'unknown',
|
||||
renderer: 'unknown',
|
||||
status: 'error',
|
||||
extensionsHash: '',
|
||||
paramsHash: '',
|
||||
headlessReasons: ['webgl_unavailable'],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String? _webglParameter(Object gl, Object? debugInfo, String key) {
|
||||
if (debugInfo == null) return null;
|
||||
try {
|
||||
final parameter = getJsProperty(debugInfo, key);
|
||||
if (parameter == null) return null;
|
||||
final value = callJsMethod(gl, 'getParameter', [parameter]);
|
||||
final string = _jsValueToString(value).trim();
|
||||
return string.isEmpty ? null : string;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
String? _webglParameterByConstant(Object gl, int parameter) {
|
||||
try {
|
||||
final value = callJsMethod(gl, 'getParameter', [parameter]);
|
||||
final string = _jsValueToString(value).trim();
|
||||
return string.isEmpty ? null : string;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
List<String> _webglParams(Object gl) {
|
||||
const parameters = <int>[
|
||||
0x0D33, // MAX_TEXTURE_SIZE
|
||||
0x0D3A, // MAX_VIEWPORT_DIMS
|
||||
0x846D, // ALIASED_POINT_SIZE_RANGE
|
||||
0x846E, // ALIASED_LINE_WIDTH_RANGE
|
||||
0x84E8, // MAX_RENDERBUFFER_SIZE
|
||||
0x8869, // MAX_VERTEX_ATTRIBS
|
||||
0x8872, // MAX_TEXTURE_IMAGE_UNITS
|
||||
0x8B4C, // MAX_VERTEX_TEXTURE_IMAGE_UNITS
|
||||
0x8B8C, // SHADING_LANGUAGE_VERSION
|
||||
];
|
||||
final values = <String>[];
|
||||
for (final parameter in parameters) {
|
||||
final value = _webglParameterByConstant(gl, parameter);
|
||||
if (value != null) values.add('$parameter=$value');
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
_AudioInfo _audioInfo() {
|
||||
Object? context;
|
||||
try {
|
||||
final constructor =
|
||||
getJsProperty(html.window, 'AudioContext') ??
|
||||
getJsProperty(html.window, 'webkitAudioContext');
|
||||
if (constructor == null) return const _AudioInfo(-1, '');
|
||||
context = constructJsObject(constructor);
|
||||
final sampleRate = _number(context, 'sampleRate', -1).round();
|
||||
final fp = sampleRate > 0 ? _hash('sampleRate:$sampleRate') : '';
|
||||
return _AudioInfo(sampleRate, fp);
|
||||
} catch (_) {
|
||||
return const _AudioInfo(-1, '');
|
||||
} finally {
|
||||
if (context != null) {
|
||||
try {
|
||||
callJsMethod(context, 'close');
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
List<String>? _fonts() {
|
||||
try {
|
||||
final canvas = html.CanvasElement(width: 200, height: 50);
|
||||
final context = canvas.context2D;
|
||||
const text = 'mmmmmmmmmmlli';
|
||||
const baseFonts = ['monospace', 'serif', 'sans-serif'];
|
||||
final baseline = <String, num>{};
|
||||
for (final base in baseFonts) {
|
||||
context.font = '72px $base';
|
||||
baseline[base] = context.measureText(text).width ?? -1;
|
||||
}
|
||||
|
||||
const candidates = [
|
||||
'Arial',
|
||||
'Arial Black',
|
||||
'Calibri',
|
||||
'Cambria',
|
||||
'Comic Sans MS',
|
||||
'Courier New',
|
||||
'Georgia',
|
||||
'Helvetica',
|
||||
'Impact',
|
||||
'Menlo',
|
||||
'Microsoft YaHei',
|
||||
'Monaco',
|
||||
'Noto Sans',
|
||||
'PingFang SC',
|
||||
'Roboto',
|
||||
'San Francisco',
|
||||
'Segoe UI',
|
||||
'Tahoma',
|
||||
'Times New Roman',
|
||||
'Trebuchet MS',
|
||||
'Verdana',
|
||||
];
|
||||
|
||||
final detected = <String>[];
|
||||
for (final font in candidates) {
|
||||
var found = false;
|
||||
for (final base in baseFonts) {
|
||||
context.font = '72px "$font", $base';
|
||||
final width = context.measureText(text).width ?? -1;
|
||||
if (width != baseline[base]) {
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (found) detected.add(font);
|
||||
}
|
||||
return detected;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
List<String> _plugins(html.Navigator navigator) {
|
||||
try {
|
||||
return _indexedPropertyList(getJsProperty(navigator, 'plugins'), 'name');
|
||||
} catch (_) {
|
||||
return const [];
|
||||
}
|
||||
}
|
||||
|
||||
List<String> _mimes(html.Navigator navigator) {
|
||||
try {
|
||||
return _indexedPropertyList(getJsProperty(navigator, 'mimeTypes'), 'type');
|
||||
} catch (_) {
|
||||
return const [];
|
||||
}
|
||||
}
|
||||
|
||||
List<String> _indexedPropertyList(Object? collection, String property) {
|
||||
if (collection == null) return const [];
|
||||
final length = _number(collection, 'length', 0).round();
|
||||
if (length <= 0) return const [];
|
||||
final values = <String>[];
|
||||
for (var i = 0; i < length; i++) {
|
||||
try {
|
||||
final item = getJsProperty(collection, i);
|
||||
if (item == null) continue;
|
||||
final value = getJsProperty(item, property);
|
||||
final string = value?.toString().trim() ?? '';
|
||||
if (string.isNotEmpty) values.add(string);
|
||||
} catch (_) {}
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
List<String> _jsStringArray(Object? collection) {
|
||||
if (collection == null) return const [];
|
||||
final length = _number(collection, 'length', 0).round();
|
||||
if (length <= 0) return const [];
|
||||
final values = <String>[];
|
||||
for (var i = 0; i < length; i++) {
|
||||
try {
|
||||
final value = getJsProperty(collection, i);
|
||||
final string = value?.toString().trim() ?? '';
|
||||
if (string.isNotEmpty) values.add(string);
|
||||
} catch (_) {}
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
String _jsValueToString(Object? value) {
|
||||
if (value == null) return '';
|
||||
if (value is String || value is num || value is bool) return '$value';
|
||||
try {
|
||||
final values = _jsStringArray(value);
|
||||
if (values.isNotEmpty) return values.join(',');
|
||||
} catch (_) {}
|
||||
return value.toString();
|
||||
}
|
||||
|
||||
List<String> _headlessReasons(html.Navigator navigator, List<String> plugins) {
|
||||
final reasons = <String>[];
|
||||
try {
|
||||
if (navigator.webdriver == true) reasons.add('webdriver');
|
||||
} catch (_) {}
|
||||
if (plugins.isEmpty) reasons.add('plugins_empty');
|
||||
try {
|
||||
final width = html.window.innerWidth ?? 0;
|
||||
final height = html.window.innerHeight ?? 0;
|
||||
if (width <= 0 || height <= 0) reasons.add('viewport_zero');
|
||||
} catch (_) {}
|
||||
return reasons;
|
||||
}
|
||||
|
||||
String _displayMode() {
|
||||
const modes = ['standalone', 'minimal-ui', 'fullscreen', 'browser'];
|
||||
for (final mode in modes) {
|
||||
try {
|
||||
if (html.window.matchMedia('(display-mode: $mode)').matches) {
|
||||
return mode;
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
String _browserKernel(String ua) {
|
||||
if (ua.contains('Edge/')) return 'edgehtml';
|
||||
if (ua.contains('Edg/')) return 'chromium';
|
||||
if (ua.contains('OPR/') || ua.contains('Opera/')) return 'chromium';
|
||||
if (ua.contains('Chrome/') || ua.contains('Chromium/')) return 'chromium';
|
||||
if (ua.contains('Firefox/')) return 'gecko';
|
||||
if (ua.contains('Trident/')) return 'trident';
|
||||
if (ua.contains('Safari/')) return 'webkit';
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
String _nonEmpty(String? value, String fallback) {
|
||||
final string = value?.trim() ?? '';
|
||||
return string.isEmpty ? fallback : string;
|
||||
}
|
||||
|
||||
List<String> _unique(List<String> values) {
|
||||
final seen = <String>{};
|
||||
return [
|
||||
for (final value in values)
|
||||
if (seen.add(value)) value,
|
||||
];
|
||||
}
|
||||
|
||||
String _hash(String value) {
|
||||
if (value.isEmpty) return '';
|
||||
return md5.convert(utf8.encode(value)).toString();
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
class SessionBehaviorManager {
|
||||
static final SessionBehaviorManager instance =
|
||||
SessionBehaviorManager._internal();
|
||||
|
||||
factory SessionBehaviorManager() => instance;
|
||||
|
||||
SessionBehaviorManager._internal() : _nowProvider = DateTime.now;
|
||||
|
||||
SessionBehaviorManager.forTest({
|
||||
DateTime Function()? nowProvider,
|
||||
}) : _nowProvider = nowProvider ?? DateTime.now;
|
||||
|
||||
static const int _scrollThresholdPx = 8;
|
||||
static const int _bottomTolerancePx = 1;
|
||||
static const _scrollMinInterval = Duration(milliseconds: 200);
|
||||
static const _touchDedupWindow = Duration(milliseconds: 50);
|
||||
|
||||
final DateTime Function() _nowProvider;
|
||||
|
||||
String _sid = '';
|
||||
DateTime? _pageStartedAt;
|
||||
int? _lastScrollTop;
|
||||
DateTime? _lastScrollProcessedAt;
|
||||
DateTime? _lastTouchStartAt;
|
||||
DateTime? _lastSwipeAt;
|
||||
DateTime? _lastLongPressAt;
|
||||
DateTime? _lastPinchAt;
|
||||
int _lastScrollDirection = 0;
|
||||
|
||||
int _scrollTotalCount = 0;
|
||||
int _scrollTotalDistancePx = 0;
|
||||
int _scrollUpCount = 0;
|
||||
int _scrollDownCount = 0;
|
||||
int _scrollDirectionChangeCount = 0;
|
||||
int _firstScrollDelayMs = -1;
|
||||
int _maxScrollDepthPercent = -1;
|
||||
int _isReachBottom = -1;
|
||||
int _contentHeight = -1;
|
||||
int _touchTotalCount = 0;
|
||||
int _swipeTotalCount = 0;
|
||||
int _longPressTotalCount = 0;
|
||||
int _pinchTotalCount = 0;
|
||||
int _firstInteractionDelayMs = -1;
|
||||
|
||||
void reset({String sid = ''}) {
|
||||
_sid = sid;
|
||||
_pageStartedAt = _nowProvider();
|
||||
_lastScrollTop = null;
|
||||
_lastScrollProcessedAt = null;
|
||||
_lastTouchStartAt = null;
|
||||
_lastSwipeAt = null;
|
||||
_lastLongPressAt = null;
|
||||
_lastPinchAt = null;
|
||||
_lastScrollDirection = 0;
|
||||
|
||||
_scrollTotalCount = 0;
|
||||
_scrollTotalDistancePx = 0;
|
||||
_scrollUpCount = 0;
|
||||
_scrollDownCount = 0;
|
||||
_scrollDirectionChangeCount = 0;
|
||||
_firstScrollDelayMs = -1;
|
||||
_maxScrollDepthPercent = -1;
|
||||
_isReachBottom = -1;
|
||||
_contentHeight = -1;
|
||||
_touchTotalCount = 0;
|
||||
_swipeTotalCount = 0;
|
||||
_longPressTotalCount = 0;
|
||||
_pinchTotalCount = 0;
|
||||
_firstInteractionDelayMs = -1;
|
||||
}
|
||||
|
||||
void recordAcceptedEvent(Map<String, dynamic> event) {
|
||||
final eventName = event['event']?.toString() ?? '';
|
||||
if (eventName.isEmpty || eventName == 'app_session_summary') return;
|
||||
|
||||
final sid = event['sid']?.toString() ?? '';
|
||||
if (sid != _sid) {
|
||||
reset(sid: sid);
|
||||
}
|
||||
|
||||
if (eventName == 'app_page_view') {
|
||||
recordPageEnter(_pageEnterTime(event) ?? _nowProvider());
|
||||
}
|
||||
}
|
||||
|
||||
void recordPageEnter(DateTime enteredAt) {
|
||||
_pageStartedAt = enteredAt;
|
||||
_lastScrollTop = null;
|
||||
_lastScrollProcessedAt = null;
|
||||
_lastScrollDirection = 0;
|
||||
}
|
||||
|
||||
void recordScroll({
|
||||
required int scrollTop,
|
||||
required int viewportHeight,
|
||||
required int contentHeight,
|
||||
DateTime? at,
|
||||
}) {
|
||||
final observedAt = at ?? _nowProvider();
|
||||
final normalizedScrollTop = _nonNegative(scrollTop);
|
||||
final normalizedViewportHeight = _nonNegative(viewportHeight);
|
||||
final normalizedContentHeight = _nonNegative(contentHeight);
|
||||
|
||||
_updateScrollExtent(
|
||||
scrollTop: normalizedScrollTop,
|
||||
viewportHeight: normalizedViewportHeight,
|
||||
contentHeight: normalizedContentHeight,
|
||||
);
|
||||
|
||||
final previousScrollTop = _lastScrollTop;
|
||||
if (previousScrollTop == null) {
|
||||
_lastScrollTop = normalizedScrollTop;
|
||||
_lastScrollProcessedAt = observedAt;
|
||||
return;
|
||||
}
|
||||
|
||||
final previousProcessedAt = _lastScrollProcessedAt;
|
||||
if (previousProcessedAt != null &&
|
||||
observedAt.difference(previousProcessedAt) < _scrollMinInterval) {
|
||||
return;
|
||||
}
|
||||
|
||||
final delta = normalizedScrollTop - previousScrollTop;
|
||||
final distance = delta.abs();
|
||||
if (distance < _scrollThresholdPx) {
|
||||
_lastScrollTop = normalizedScrollTop;
|
||||
_lastScrollProcessedAt = observedAt;
|
||||
return;
|
||||
}
|
||||
|
||||
final direction = delta > 0 ? 1 : -1;
|
||||
_scrollTotalCount += 1;
|
||||
_scrollTotalDistancePx += distance;
|
||||
if (direction > 0) {
|
||||
_scrollDownCount += 1;
|
||||
} else {
|
||||
_scrollUpCount += 1;
|
||||
}
|
||||
if (_lastScrollDirection != 0 && _lastScrollDirection != direction) {
|
||||
_scrollDirectionChangeCount += 1;
|
||||
}
|
||||
_lastScrollDirection = direction;
|
||||
_lastScrollTop = normalizedScrollTop;
|
||||
_lastScrollProcessedAt = observedAt;
|
||||
_recordFirstScrollDelay(observedAt);
|
||||
}
|
||||
|
||||
void recordTouchStart({DateTime? at}) {
|
||||
final observedAt = at ?? _nowProvider();
|
||||
if (_isDuplicate(_lastTouchStartAt, observedAt)) return;
|
||||
_lastTouchStartAt = observedAt;
|
||||
_touchTotalCount += 1;
|
||||
_recordFirstInteractionDelay(observedAt);
|
||||
}
|
||||
|
||||
void recordSwipe({DateTime? at}) {
|
||||
final observedAt = at ?? _nowProvider();
|
||||
if (_isDuplicate(_lastSwipeAt, observedAt)) return;
|
||||
_lastSwipeAt = observedAt;
|
||||
_swipeTotalCount += 1;
|
||||
_recordFirstInteractionDelay(observedAt);
|
||||
}
|
||||
|
||||
void recordLongPress({DateTime? at}) {
|
||||
final observedAt = at ?? _nowProvider();
|
||||
if (_isDuplicate(_lastLongPressAt, observedAt)) return;
|
||||
_lastLongPressAt = observedAt;
|
||||
_longPressTotalCount += 1;
|
||||
_recordFirstInteractionDelay(observedAt);
|
||||
}
|
||||
|
||||
void recordPinch({DateTime? at}) {
|
||||
final observedAt = at ?? _nowProvider();
|
||||
if (_isDuplicate(_lastPinchAt, observedAt)) return;
|
||||
_lastPinchAt = observedAt;
|
||||
_pinchTotalCount += 1;
|
||||
_recordFirstInteractionDelay(observedAt);
|
||||
}
|
||||
|
||||
SessionBehaviorSnapshot snapshot() {
|
||||
return SessionBehaviorSnapshot(
|
||||
scrollTotalCount: _scrollTotalCount,
|
||||
scrollTotalDistancePx: _scrollTotalDistancePx,
|
||||
scrollUpCount: _scrollUpCount,
|
||||
scrollDownCount: _scrollDownCount,
|
||||
scrollDirectionChangeCount: _scrollDirectionChangeCount,
|
||||
firstScrollDelayMs: _firstScrollDelayMs,
|
||||
maxScrollDepthPercent: _maxScrollDepthPercent,
|
||||
isReachBottom: _isReachBottom,
|
||||
contentHeight: _contentHeight,
|
||||
touchTotalCount: _touchTotalCount,
|
||||
swipeTotalCount: _swipeTotalCount,
|
||||
longPressTotalCount: _longPressTotalCount,
|
||||
pinchTotalCount: _pinchTotalCount,
|
||||
firstInteractionDelayMs: _firstInteractionDelayMs,
|
||||
);
|
||||
}
|
||||
|
||||
void _updateScrollExtent({
|
||||
required int scrollTop,
|
||||
required int viewportHeight,
|
||||
required int contentHeight,
|
||||
}) {
|
||||
if (contentHeight <= 0 || viewportHeight <= 0) return;
|
||||
_contentHeight = contentHeight;
|
||||
final depth = ((scrollTop + viewportHeight) / contentHeight * 100)
|
||||
.round()
|
||||
.clamp(0, 100);
|
||||
if (_maxScrollDepthPercent < 0 || depth > _maxScrollDepthPercent) {
|
||||
_maxScrollDepthPercent = depth;
|
||||
}
|
||||
if (scrollTop + viewportHeight >= contentHeight - _bottomTolerancePx) {
|
||||
_isReachBottom = 1;
|
||||
} else if (_isReachBottom < 0) {
|
||||
_isReachBottom = 0;
|
||||
}
|
||||
}
|
||||
|
||||
void _recordFirstScrollDelay(DateTime at) {
|
||||
if (_firstScrollDelayMs >= 0) return;
|
||||
_firstScrollDelayMs = _delaySincePageStart(at);
|
||||
}
|
||||
|
||||
void _recordFirstInteractionDelay(DateTime at) {
|
||||
if (_firstInteractionDelayMs >= 0) return;
|
||||
_firstInteractionDelayMs = _delaySincePageStart(at);
|
||||
}
|
||||
|
||||
int _delaySincePageStart(DateTime at) {
|
||||
final pageStartedAt = _pageStartedAt;
|
||||
if (pageStartedAt == null) return -1;
|
||||
final delay = at.difference(pageStartedAt).inMilliseconds;
|
||||
return delay < 0 ? 0 : delay;
|
||||
}
|
||||
|
||||
DateTime? _pageEnterTime(Map<String, dynamic> event) {
|
||||
final payload = event['payload'];
|
||||
if (payload is! Map) return null;
|
||||
final value = payload['page_enter_ts'];
|
||||
if (value is int) {
|
||||
return DateTime.fromMillisecondsSinceEpoch(value * 1000);
|
||||
}
|
||||
if (value is num) {
|
||||
return DateTime.fromMillisecondsSinceEpoch(value.round() * 1000);
|
||||
}
|
||||
final parsed = int.tryParse(value?.toString() ?? '');
|
||||
if (parsed == null) return null;
|
||||
return DateTime.fromMillisecondsSinceEpoch(parsed * 1000);
|
||||
}
|
||||
|
||||
bool _isDuplicate(DateTime? previous, DateTime current) {
|
||||
if (previous == null) return false;
|
||||
final diffMs = current.difference(previous).inMilliseconds.abs();
|
||||
return diffMs < _touchDedupWindow.inMilliseconds;
|
||||
}
|
||||
|
||||
int _nonNegative(int value) => value < 0 ? 0 : value;
|
||||
}
|
||||
|
||||
class SessionBehaviorSnapshot {
|
||||
final int scrollTotalCount;
|
||||
final int scrollTotalDistancePx;
|
||||
final int scrollUpCount;
|
||||
final int scrollDownCount;
|
||||
final int scrollDirectionChangeCount;
|
||||
final int firstScrollDelayMs;
|
||||
final int maxScrollDepthPercent;
|
||||
final int isReachBottom;
|
||||
final int contentHeight;
|
||||
final int touchTotalCount;
|
||||
final int swipeTotalCount;
|
||||
final int longPressTotalCount;
|
||||
final int pinchTotalCount;
|
||||
final int firstInteractionDelayMs;
|
||||
|
||||
const SessionBehaviorSnapshot({
|
||||
required this.scrollTotalCount,
|
||||
required this.scrollTotalDistancePx,
|
||||
required this.scrollUpCount,
|
||||
required this.scrollDownCount,
|
||||
required this.scrollDirectionChangeCount,
|
||||
required this.firstScrollDelayMs,
|
||||
required this.maxScrollDepthPercent,
|
||||
required this.isReachBottom,
|
||||
required this.contentHeight,
|
||||
required this.touchTotalCount,
|
||||
required this.swipeTotalCount,
|
||||
required this.longPressTotalCount,
|
||||
required this.pinchTotalCount,
|
||||
required this.firstInteractionDelayMs,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export 'session_behavior_monitor_stub.dart'
|
||||
if (dart.library.html) 'session_behavior_monitor_web.dart';
|
||||
@@ -0,0 +1,10 @@
|
||||
class SessionBehaviorMonitor {
|
||||
static final SessionBehaviorMonitor instance =
|
||||
SessionBehaviorMonitor._internal();
|
||||
|
||||
SessionBehaviorMonitor._internal();
|
||||
|
||||
void start() {}
|
||||
|
||||
void dispose() {}
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
// ignore: avoid_web_libraries_in_flutter
|
||||
import 'dart:async';
|
||||
import 'dart:html' as html;
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:analytics_sdk/manager/session_behavior_manager.dart';
|
||||
|
||||
class SessionBehaviorMonitor {
|
||||
static final SessionBehaviorMonitor instance =
|
||||
SessionBehaviorMonitor._internal();
|
||||
|
||||
SessionBehaviorMonitor._internal();
|
||||
|
||||
static const int _swipeThresholdPx = 50;
|
||||
static const int _pinchThresholdPx = 20;
|
||||
static const int _longPressMoveTolerancePx = 10;
|
||||
static const _longPressDuration = Duration(milliseconds: 500);
|
||||
|
||||
final List<StreamSubscription<dynamic>> _subscriptions = [];
|
||||
final Map<int, _TouchStart> _touchStarts = <int, _TouchStart>{};
|
||||
final Map<int, math.Point<num>> _activeTouches = <int, math.Point<num>>{};
|
||||
|
||||
double? _pinchStartDistance;
|
||||
bool _pinchRecorded = false;
|
||||
bool _started = false;
|
||||
|
||||
void start() {
|
||||
if (_started) return;
|
||||
_started = true;
|
||||
_subscriptions
|
||||
..add(html.window.onScroll.listen(_handleScroll))
|
||||
..add(html.document.onTouchStart.listen(_handleTouchStart))
|
||||
..add(html.document.onTouchMove.listen(_handleTouchMove))
|
||||
..add(html.document.onTouchEnd.listen(_handleTouchEnd))
|
||||
..add(html.document.onTouchCancel.listen(_handleTouchCancel));
|
||||
_handleScroll(null);
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
for (final subscription in _subscriptions) {
|
||||
subscription.cancel();
|
||||
}
|
||||
_subscriptions.clear();
|
||||
_touchStarts.clear();
|
||||
_activeTouches.clear();
|
||||
_pinchStartDistance = null;
|
||||
_pinchRecorded = false;
|
||||
_started = false;
|
||||
}
|
||||
|
||||
void _handleScroll(Object? _) {
|
||||
try {
|
||||
final element = html.document.scrollingElement ??
|
||||
html.document.documentElement ??
|
||||
html.document.body;
|
||||
final viewportHeight = html.window.innerHeight ?? element?.clientHeight;
|
||||
final contentHeight = element?.scrollHeight;
|
||||
final scrollTop = element?.scrollTop ?? html.window.pageYOffset.round();
|
||||
if (viewportHeight == null || contentHeight == null) return;
|
||||
SessionBehaviorManager.instance.recordScroll(
|
||||
scrollTop: scrollTop,
|
||||
viewportHeight: viewportHeight,
|
||||
contentHeight: contentHeight,
|
||||
);
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
void _handleTouchStart(html.TouchEvent event) {
|
||||
try {
|
||||
final touches = event.touches;
|
||||
final changedTouches = event.changedTouches;
|
||||
if (touches == null || changedTouches == null) return;
|
||||
|
||||
final now = DateTime.now();
|
||||
if (touches.length == 1) {
|
||||
SessionBehaviorManager.instance.recordTouchStart(at: now);
|
||||
}
|
||||
|
||||
for (final touch in changedTouches) {
|
||||
final identifier = touch.identifier ?? 0;
|
||||
final point = touch.client;
|
||||
_touchStarts[identifier] = _TouchStart(point: point, startedAt: now);
|
||||
_activeTouches[identifier] = point;
|
||||
}
|
||||
_updatePinchBaseline();
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
void _handleTouchMove(html.TouchEvent event) {
|
||||
try {
|
||||
final touches = event.touches;
|
||||
if (touches == null) return;
|
||||
for (final touch in touches) {
|
||||
_activeTouches[touch.identifier ?? 0] = touch.client;
|
||||
}
|
||||
if (_activeTouches.length < 2) return;
|
||||
final distance = _firstTwoTouchDistance();
|
||||
if (distance == null) return;
|
||||
_pinchStartDistance ??= distance;
|
||||
if (!_pinchRecorded &&
|
||||
(distance - _pinchStartDistance!).abs() >= _pinchThresholdPx) {
|
||||
_pinchRecorded = true;
|
||||
SessionBehaviorManager.instance.recordPinch();
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
void _handleTouchEnd(html.TouchEvent event) {
|
||||
try {
|
||||
final changedTouches = event.changedTouches;
|
||||
if (changedTouches == null) return;
|
||||
final now = DateTime.now();
|
||||
final wasSingleTouch = _activeTouches.length == 1;
|
||||
for (final touch in changedTouches) {
|
||||
final identifier = touch.identifier ?? 0;
|
||||
final start = _touchStarts[identifier];
|
||||
if (start != null && wasSingleTouch) {
|
||||
_recordFinishedSingleTouch(
|
||||
start: start,
|
||||
endPoint: touch.client,
|
||||
endedAt: now,
|
||||
);
|
||||
}
|
||||
_touchStarts.remove(identifier);
|
||||
_activeTouches.remove(identifier);
|
||||
}
|
||||
_updatePinchBaseline();
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
void _handleTouchCancel(html.TouchEvent event) {
|
||||
try {
|
||||
final changedTouches = event.changedTouches;
|
||||
if (changedTouches == null) return;
|
||||
for (final touch in changedTouches) {
|
||||
final identifier = touch.identifier ?? 0;
|
||||
_touchStarts.remove(identifier);
|
||||
_activeTouches.remove(identifier);
|
||||
}
|
||||
_updatePinchBaseline();
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
void _recordFinishedSingleTouch({
|
||||
required _TouchStart start,
|
||||
required math.Point<num> endPoint,
|
||||
required DateTime endedAt,
|
||||
}) {
|
||||
final distance = _distance(start.point, endPoint);
|
||||
if (distance >= _swipeThresholdPx) {
|
||||
SessionBehaviorManager.instance.recordSwipe(at: endedAt);
|
||||
return;
|
||||
}
|
||||
if (endedAt.difference(start.startedAt) >= _longPressDuration &&
|
||||
distance <= _longPressMoveTolerancePx) {
|
||||
SessionBehaviorManager.instance.recordLongPress(at: endedAt);
|
||||
}
|
||||
}
|
||||
|
||||
void _updatePinchBaseline() {
|
||||
if (_activeTouches.length < 2) {
|
||||
_pinchStartDistance = null;
|
||||
_pinchRecorded = false;
|
||||
return;
|
||||
}
|
||||
_pinchStartDistance = _firstTwoTouchDistance();
|
||||
_pinchRecorded = false;
|
||||
}
|
||||
|
||||
double? _firstTwoTouchDistance() {
|
||||
if (_activeTouches.length < 2) return null;
|
||||
final iterator = _activeTouches.values.iterator;
|
||||
iterator.moveNext();
|
||||
final first = iterator.current;
|
||||
iterator.moveNext();
|
||||
final second = iterator.current;
|
||||
return _distance(first, second);
|
||||
}
|
||||
|
||||
double _distance(math.Point<num> a, math.Point<num> b) {
|
||||
final dx = a.x - b.x;
|
||||
final dy = a.y - b.y;
|
||||
return math.sqrt(dx * dx + dy * dy);
|
||||
}
|
||||
}
|
||||
|
||||
class _TouchStart {
|
||||
final math.Point<num> point;
|
||||
final DateTime startedAt;
|
||||
|
||||
const _TouchStart({
|
||||
required this.point,
|
||||
required this.startedAt,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:analytics_sdk/utils/logger.dart';
|
||||
import 'package:analytics_sdk/utils/uuid_util.dart';
|
||||
|
||||
/// 会话管理器:管理会话ID(sid)的生成和更新
|
||||
///
|
||||
/// 会话定义:用户在一次连续、自然的产品使用过程中产生的一组行为集合
|
||||
///
|
||||
/// **sid 说明**:sid 由客户端 SDK 自动生成,每个事件均会携带。
|
||||
/// 服务端上报事件时,需通过 [AnalyticsSdk.getParams] 获取当前 sid 并一同回传,
|
||||
/// 以便服务端事件与客户端事件归属同一会话。
|
||||
///
|
||||
/// 会话中断阈值:30 分钟无任何有效行为,生成新会话ID
|
||||
///
|
||||
/// APP产品定义方案,任意满足一条就重新生成会话ID:
|
||||
/// 1. App 冷启动(进程首次启动)
|
||||
/// 2. App 从后台切换前台,且后台停留 > 30 分钟
|
||||
/// 3. 上一次行为距离当前 > 30 分钟
|
||||
class SessionManager {
|
||||
static final SessionManager instance = SessionManager._internal();
|
||||
|
||||
factory SessionManager() => instance;
|
||||
|
||||
SessionManager._internal();
|
||||
|
||||
/// 当前会话ID
|
||||
String? _currentSessionId;
|
||||
|
||||
/// 会话创建时间
|
||||
DateTime? _sessionStartTime;
|
||||
|
||||
/// 最后一次行为时间
|
||||
DateTime? _lastActivityTime;
|
||||
|
||||
/// 应用进入后台的时间
|
||||
DateTime? _backgroundTime;
|
||||
|
||||
/// 当前正在通知结束的旧 session 逻辑结束时间。
|
||||
DateTime? _notifyingSessionEndTime;
|
||||
|
||||
/// 会话中断阈值(30分钟)
|
||||
static const Duration sessionTimeout = Duration(minutes: 30);
|
||||
|
||||
static const Set<String> _effectiveLifecycleKeys = {
|
||||
'cold_start',
|
||||
'web_load',
|
||||
'warm_start',
|
||||
'foreground',
|
||||
};
|
||||
|
||||
static const Set<String> _nonBusinessActivityEvents = {
|
||||
'sdk_init',
|
||||
'network_state_change',
|
||||
'app_session_summary',
|
||||
'navigation',
|
||||
'page_lifecycle',
|
||||
};
|
||||
|
||||
/// 是否已初始化(用于判断冷启动)
|
||||
bool _isInitialized = false;
|
||||
|
||||
/// 新 session 创建时的回调(由外部注入,避免直接依赖 AdImpressionManager)
|
||||
/// 通常由 AnalyticsSdk.init() 注入:`() => AdImpressionManager.instance.clear()`
|
||||
void Function()? onNewSession;
|
||||
|
||||
/// 旧 session 即将结束时的回调。
|
||||
///
|
||||
/// 在新 sid 生成前调用,使调用方仍能用旧 sid 补发 app_session_summary。
|
||||
void Function(String sessionEndReason)? onSessionEnding;
|
||||
|
||||
/// 获取当前会话ID
|
||||
/// 如果当前没有会话或会话已过期,会自动创建新会话
|
||||
String getSessionId() {
|
||||
final now = DateTime.now();
|
||||
|
||||
// 检查是否需要生成新会话
|
||||
// 注意:这里只检查和创建会话,不更新 _lastActivityTime
|
||||
// 调用方应通过 recordActivity() 显式记录行为时间,避免非行为调用(如 debug 面板)误延长会话
|
||||
if (_shouldCreateNewSession(now)) {
|
||||
_createNewSession(now);
|
||||
}
|
||||
|
||||
// 安全检查:确保会话ID不为null(理论上不会发生,但作为保护措施)
|
||||
if (_currentSessionId == null) {
|
||||
Logger.analyticsSdk('警告:会话ID为null,强制创建新会话', level: LogLevel.warn);
|
||||
_createNewSession(now);
|
||||
}
|
||||
|
||||
return _currentSessionId!;
|
||||
}
|
||||
|
||||
/// 检查是否需要创建新会话
|
||||
bool _shouldCreateNewSession(DateTime now) {
|
||||
// 1. 如果还没有会话ID,需要创建
|
||||
if (_currentSessionId == null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 2. 如果还未初始化,说明是冷启动,需要创建新会话
|
||||
if (!_isInitialized) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 3. 如果从后台恢复,且后台停留时间 > 30 分钟
|
||||
// 注意:这里只在超时时清空 _backgroundTime(并返回 true 触发新会话)。
|
||||
// 非超时分支不清空,保留给 onAppForeground() 统一处理,
|
||||
// 避免后台期间有代码调用 getSessionId()(如推送通知触发 track())时
|
||||
// 提前清空导致 onAppForeground() 无法正确判断后台时长。
|
||||
if (_backgroundTime != null) {
|
||||
final backgroundDuration = now.difference(_backgroundTime!);
|
||||
if (backgroundDuration > sessionTimeout) {
|
||||
Logger.analyticsSdk(
|
||||
'后台停留时间 ${backgroundDuration.inMinutes} 分钟,超过阈值,创建新会话');
|
||||
_backgroundTime = null;
|
||||
return true;
|
||||
}
|
||||
// 未超时:不清空 _backgroundTime,由 onAppForeground() 负责清空
|
||||
}
|
||||
|
||||
// 4. 如果上一次行为距离当前 > 30 分钟
|
||||
if (_lastActivityTime != null) {
|
||||
final timeSinceLastActivity = now.difference(_lastActivityTime!);
|
||||
if (timeSinceLastActivity > sessionTimeout) {
|
||||
Logger.analyticsSdk(
|
||||
'距离上次行为 ${timeSinceLastActivity.inMinutes} 分钟,超过阈值,创建新会话');
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// 创建新会话
|
||||
void _createNewSession(DateTime now) {
|
||||
try {
|
||||
_notifySessionEndingIfNeeded('idle');
|
||||
_currentSessionId = generateUuidV4().replaceAll("-", "");
|
||||
_sessionStartTime = now;
|
||||
_lastActivityTime = now;
|
||||
_isInitialized = true;
|
||||
// 新 session 开始时通知外部清理广告去重状态
|
||||
onNewSession?.call();
|
||||
Logger.analyticsSdk('创建新会话ID: $_currentSessionId');
|
||||
} catch (e) {
|
||||
// UUID 生成失败时使用时间戳作为备选方案
|
||||
final timestamp = now.millisecondsSinceEpoch;
|
||||
_currentSessionId = 'session_${timestamp}_${now.microsecondsSinceEpoch}';
|
||||
_sessionStartTime = now;
|
||||
_lastActivityTime = now;
|
||||
_isInitialized = true;
|
||||
onNewSession?.call();
|
||||
Logger.analyticsSdk('UUID 生成失败,使用时间戳创建会话ID: $_currentSessionId');
|
||||
}
|
||||
}
|
||||
|
||||
/// 初始化会话管理器
|
||||
/// 在 SDK 初始化时调用,标记为冷启动
|
||||
void initialize() {
|
||||
if (!_isInitialized) {
|
||||
final now = DateTime.now();
|
||||
_createNewSession(now);
|
||||
Logger.analyticsSdk('会话管理器初始化(冷启动),创建新会话');
|
||||
}
|
||||
}
|
||||
|
||||
/// 应用进入后台
|
||||
/// 在应用生命周期变为后台时调用
|
||||
void onAppBackground() {
|
||||
_backgroundTime = DateTime.now();
|
||||
Logger.analyticsSdk('应用进入后台,记录时间: $_backgroundTime');
|
||||
}
|
||||
|
||||
/// 应用从后台恢复
|
||||
/// 在应用生命周期变为前台时调用
|
||||
void onAppForeground() {
|
||||
final now = DateTime.now();
|
||||
|
||||
// 如果从后台恢复,检查是否需要创建新会话
|
||||
if (_backgroundTime != null) {
|
||||
final backgroundDuration = now.difference(_backgroundTime!);
|
||||
if (backgroundDuration > sessionTimeout) {
|
||||
Logger.analyticsSdk(
|
||||
'从后台恢复,后台停留 ${backgroundDuration.inMinutes} 分钟,超过阈值,创建新会话');
|
||||
_createNewSession(now);
|
||||
} else {
|
||||
Logger.analyticsSdk(
|
||||
'从后台恢复,后台停留 ${backgroundDuration.inMinutes} 分钟,继续使用当前会话');
|
||||
// 更新最后行为时间,表示用户重新活跃
|
||||
_lastActivityTime = now;
|
||||
}
|
||||
_backgroundTime = null; // 清空后台时间
|
||||
} else {
|
||||
// 如果没有后台时间记录,直接更新最后行为时间
|
||||
_lastActivityTime = now;
|
||||
}
|
||||
}
|
||||
|
||||
/// 记录用户行为
|
||||
/// 每次生成事件时调用,用于更新最后行为时间
|
||||
void recordActivity() {
|
||||
_lastActivityTime = DateTime.now();
|
||||
}
|
||||
|
||||
/// 仅在成功进入 SDK 队列且属于有效行为时更新最后行为时间。
|
||||
void recordActivityForEvent(Map<String, dynamic> event, {DateTime? at}) {
|
||||
if (!_isEffectiveActivityEvent(event)) return;
|
||||
_lastActivityTime = at ?? DateTime.now();
|
||||
}
|
||||
|
||||
/// 获取当前会话ID(不触发新会话创建)
|
||||
String? get currentSessionId => _currentSessionId;
|
||||
|
||||
/// 获取会话开始时间
|
||||
DateTime? get sessionStartTime => _sessionStartTime;
|
||||
|
||||
/// 获取最后行为时间
|
||||
DateTime? get lastActivityTime => _lastActivityTime;
|
||||
|
||||
/// 当前 session 结束回调内可读取的逻辑结束时间。
|
||||
DateTime? get sessionEndTimeForNotification => _notifyingSessionEndTime;
|
||||
|
||||
/// 仅供测试使用:获取后台时间(验证 _shouldCreateNewSession 不提前清空)
|
||||
@visibleForTesting
|
||||
DateTime? get backgroundTimeForTest => _backgroundTime;
|
||||
|
||||
@visibleForTesting
|
||||
void setBackgroundTimeForTest(DateTime? value) {
|
||||
_backgroundTime = value;
|
||||
}
|
||||
|
||||
@visibleForTesting
|
||||
DateTime? get sessionEndTimeForNotificationForTest =>
|
||||
_notifyingSessionEndTime;
|
||||
|
||||
@visibleForTesting
|
||||
void setLastActivityTimeForTest(DateTime? value) {
|
||||
_lastActivityTime = value;
|
||||
}
|
||||
|
||||
@visibleForTesting
|
||||
void setSessionStartTimeForTest(DateTime? value) {
|
||||
_sessionStartTime = value;
|
||||
}
|
||||
|
||||
/// QA 调试用:把后台停留时间虚拟为指定时长,便于手工触发 idle summary。
|
||||
///
|
||||
/// Release 包中不生效,避免接入方误用影响真实 session 规则。
|
||||
bool debugSetBackgroundElapsedForQa(Duration elapsed) {
|
||||
if (kReleaseMode) return false;
|
||||
if (elapsed <= Duration.zero) return false;
|
||||
final now = DateTime.now();
|
||||
_backgroundTime = now.subtract(elapsed);
|
||||
_lastActivityTime = now.subtract(elapsed);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// 手动重置会话(用于测试或特殊场景)
|
||||
void reset() {
|
||||
_currentSessionId = null;
|
||||
_sessionStartTime = null;
|
||||
_lastActivityTime = null;
|
||||
_backgroundTime = null;
|
||||
_notifyingSessionEndTime = null;
|
||||
_isInitialized = false;
|
||||
Logger.analyticsSdk('会话已重置');
|
||||
}
|
||||
|
||||
void _notifySessionEndingIfNeeded(String reason) {
|
||||
if (_currentSessionId == null || !_isInitialized) return;
|
||||
final previousEndTime = _notifyingSessionEndTime;
|
||||
_notifyingSessionEndTime = _logicalSessionEndTime(
|
||||
reason: reason,
|
||||
now: DateTime.now(),
|
||||
);
|
||||
try {
|
||||
onSessionEnding?.call(reason);
|
||||
} catch (e) {
|
||||
Logger.analyticsSdk('旧 session 结束回调执行失败: $e', level: LogLevel.warn);
|
||||
} finally {
|
||||
_notifyingSessionEndTime = previousEndTime;
|
||||
}
|
||||
}
|
||||
|
||||
DateTime _logicalSessionEndTime({
|
||||
required String reason,
|
||||
required DateTime now,
|
||||
}) {
|
||||
if (reason != 'idle') return now;
|
||||
final sessionStart = _sessionStartTime ?? now;
|
||||
final rawLastActivity = _lastActivityTime ?? sessionStart;
|
||||
final lastActivity =
|
||||
rawLastActivity.isBefore(sessionStart) ? sessionStart : rawLastActivity;
|
||||
final logicalEnd = lastActivity.add(sessionTimeout);
|
||||
return logicalEnd.isAfter(now) ? now : logicalEnd;
|
||||
}
|
||||
|
||||
bool _isEffectiveActivityEvent(Map<String, dynamic> event) {
|
||||
final eventName = event['event']?.toString() ?? '';
|
||||
if (eventName.isEmpty) return false;
|
||||
if (eventName == 'app_lifecycle') {
|
||||
final key = _lifecycleEventKey(event);
|
||||
return _effectiveLifecycleKeys.contains(key);
|
||||
}
|
||||
if (_nonBusinessActivityEvents.contains(eventName)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
String _lifecycleEventKey(Map<String, dynamic> event) {
|
||||
final payload = event['payload'];
|
||||
if (payload is Map) {
|
||||
final value = payload['lifecycle_event_key']?.toString().trim() ?? '';
|
||||
if (value.isNotEmpty) return value;
|
||||
}
|
||||
return event['lifecycle_event_key']?.toString().trim() ?? '';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:crypto/crypto.dart';
|
||||
|
||||
/// Session 内自动汇总指标。
|
||||
///
|
||||
/// 只记录成功进入 SDK 队列的事件。这里不采集滚动轨迹、键盘输入内容、
|
||||
/// 长按、双指缩放等高风险/不稳定指标,避免为了汇总事件扩大监听面。
|
||||
class SessionMetricsManager {
|
||||
static final SessionMetricsManager instance =
|
||||
SessionMetricsManager._internal();
|
||||
|
||||
factory SessionMetricsManager() => instance;
|
||||
|
||||
SessionMetricsManager._internal();
|
||||
|
||||
static const int _maxBehaviorSequence = 200;
|
||||
static const int _maxPageStayRecords = 100;
|
||||
|
||||
static const Set<String> _nonBusinessEvents = {
|
||||
'sdk_init',
|
||||
'app_lifecycle',
|
||||
'network_state_change',
|
||||
'app_session_summary',
|
||||
'app_page_view',
|
||||
'page_click',
|
||||
'navigation',
|
||||
'page_lifecycle',
|
||||
};
|
||||
|
||||
String _sid = '';
|
||||
int _eventCountTotal = 0;
|
||||
int _businessEventCount = 0;
|
||||
int _foregroundCount = 0;
|
||||
int _backgroundCountInSid = 0;
|
||||
int _pageViewCount = 0;
|
||||
int _pageClickCount = 0;
|
||||
int _networkChangeCount = 0;
|
||||
String _lastEventName = '';
|
||||
final Set<String> _pageKeys = <String>{};
|
||||
final Set<String> _pagesWithInteraction = <String>{};
|
||||
final List<String> _behaviorSequence = <String>[];
|
||||
final List<_PageStayRecord> _activePageStays = <_PageStayRecord>[];
|
||||
final List<_PageStayRecord> _completedPageStays = <_PageStayRecord>[];
|
||||
|
||||
void recordAcceptedEvent(Map<String, dynamic> event) {
|
||||
final eventName = event['event']?.toString() ?? '';
|
||||
if (eventName.isEmpty || eventName == 'app_session_summary') return;
|
||||
|
||||
final sid = event['sid']?.toString() ?? '';
|
||||
if (sid != _sid) {
|
||||
reset(sid: sid);
|
||||
}
|
||||
|
||||
_eventCountTotal += 1;
|
||||
_lastEventName = eventName;
|
||||
_recordBehaviorSequence(eventName, _payloadPageKey(event));
|
||||
|
||||
if (!_nonBusinessEvents.contains(eventName)) {
|
||||
_businessEventCount += 1;
|
||||
}
|
||||
|
||||
if (eventName == 'app_page_view') {
|
||||
_pageViewCount += 1;
|
||||
_recordPageKey(event);
|
||||
} else if (eventName == 'page_click') {
|
||||
_pageClickCount += 1;
|
||||
_recordPageKey(event);
|
||||
final pageKey = _payloadPageKey(event);
|
||||
if (pageKey.isNotEmpty) _pagesWithInteraction.add(pageKey);
|
||||
} else if (eventName == 'app_lifecycle') {
|
||||
final key = _payloadString(event, 'lifecycle_event_key',
|
||||
fallback: event['lifecycle_event_key']);
|
||||
if (key == 'foreground') {
|
||||
_foregroundCount += 1;
|
||||
} else if (key == 'background') {
|
||||
_backgroundCountInSid += 1;
|
||||
}
|
||||
} else if (eventName == 'network_state_change') {
|
||||
_networkChangeCount += 1;
|
||||
}
|
||||
}
|
||||
|
||||
void recordPageEnter({
|
||||
required String pageKey,
|
||||
required String pageName,
|
||||
required DateTime enterTime,
|
||||
}) {
|
||||
final normalizedPageKey = pageKey.trim();
|
||||
if (normalizedPageKey.isEmpty) return;
|
||||
_pageKeys.add(normalizedPageKey);
|
||||
_closeActivePageStays(exitTime: enterTime);
|
||||
_activePageStays.add(_PageStayRecord(
|
||||
pageKey: normalizedPageKey,
|
||||
pageName: pageName,
|
||||
enterTime: enterTime,
|
||||
));
|
||||
}
|
||||
|
||||
void recordPageExit({
|
||||
required String pageKey,
|
||||
required String pageName,
|
||||
required DateTime enterTime,
|
||||
required DateTime exitTime,
|
||||
}) {
|
||||
final normalizedPageKey = pageKey.trim();
|
||||
if (normalizedPageKey.isEmpty) return;
|
||||
_pageKeys.add(normalizedPageKey);
|
||||
|
||||
final index = _findActivePageStayIndex(normalizedPageKey, enterTime);
|
||||
if (index < 0) return;
|
||||
final record = _activePageStays.removeAt(index);
|
||||
_completedPageStays.add(record.close(
|
||||
exitTime: exitTime,
|
||||
hasInteraction: _pagesWithInteraction.contains(normalizedPageKey),
|
||||
));
|
||||
_trimCompletedPageStays();
|
||||
}
|
||||
|
||||
SessionMetricsSnapshot snapshot({DateTime? endedAt}) {
|
||||
final now = endedAt ?? DateTime.now();
|
||||
return SessionMetricsSnapshot(
|
||||
eventCountTotal: _eventCountTotal,
|
||||
businessEventCount: _businessEventCount,
|
||||
foregroundCount: _foregroundCount,
|
||||
backgroundCountInSid: _backgroundCountInSid,
|
||||
pageCount: _pageKeys.length,
|
||||
pageViewCount: _pageViewCount,
|
||||
pageClickCount: _pageClickCount,
|
||||
networkChangeCount: _networkChangeCount,
|
||||
sessionSid: _sid,
|
||||
lastEventName: _lastEventName,
|
||||
pageStayList: _pageStayList(now),
|
||||
behaviorSequenceHash: _behaviorSequenceHash(),
|
||||
);
|
||||
}
|
||||
|
||||
void reset({String sid = ''}) {
|
||||
_sid = sid;
|
||||
_eventCountTotal = 0;
|
||||
_businessEventCount = 0;
|
||||
_foregroundCount = 0;
|
||||
_backgroundCountInSid = 0;
|
||||
_pageViewCount = 0;
|
||||
_pageClickCount = 0;
|
||||
_networkChangeCount = 0;
|
||||
_lastEventName = '';
|
||||
_pageKeys.clear();
|
||||
_pagesWithInteraction.clear();
|
||||
_behaviorSequence.clear();
|
||||
_activePageStays.clear();
|
||||
_completedPageStays.clear();
|
||||
}
|
||||
|
||||
void _recordPageKey(Map<String, dynamic> event) {
|
||||
final pageKey = _payloadPageKey(event);
|
||||
if (pageKey.isNotEmpty) _pageKeys.add(pageKey);
|
||||
}
|
||||
|
||||
String _payloadPageKey(Map<String, dynamic> event) {
|
||||
return _payloadString(event, 'page_key');
|
||||
}
|
||||
|
||||
String _payloadString(
|
||||
Map<String, dynamic> event,
|
||||
String key, {
|
||||
Object? fallback,
|
||||
}) {
|
||||
final payload = event['payload'];
|
||||
if (payload is Map) {
|
||||
final value = payload[key]?.toString().trim() ?? '';
|
||||
if (value.isNotEmpty) return value;
|
||||
}
|
||||
return fallback?.toString().trim() ?? '';
|
||||
}
|
||||
|
||||
void _recordBehaviorSequence(String eventName, String pageKey) {
|
||||
final token = pageKey.isEmpty ? eventName : '$eventName:$pageKey';
|
||||
_behaviorSequence.add(token);
|
||||
if (_behaviorSequence.length > _maxBehaviorSequence) {
|
||||
_behaviorSequence.removeAt(0);
|
||||
}
|
||||
}
|
||||
|
||||
String _behaviorSequenceHash() {
|
||||
if (_behaviorSequence.isEmpty) return '';
|
||||
return sha256.convert(utf8.encode(_behaviorSequence.join('|'))).toString();
|
||||
}
|
||||
|
||||
List<Map<String, dynamic>> _pageStayList(DateTime endedAt) {
|
||||
final all = [
|
||||
..._completedPageStays,
|
||||
..._activePageStays.map((record) => record.close(
|
||||
exitTime: endedAt,
|
||||
hasInteraction: _pagesWithInteraction.contains(record.pageKey),
|
||||
)),
|
||||
]..sort((a, b) => a.enterTime.compareTo(b.enterTime));
|
||||
final capped = all.length > _maxPageStayRecords
|
||||
? all.sublist(all.length - _maxPageStayRecords)
|
||||
: all;
|
||||
return capped.map((record) => record.toJson()).toList();
|
||||
}
|
||||
|
||||
void _closeActivePageStays({required DateTime exitTime}) {
|
||||
if (_activePageStays.isEmpty) return;
|
||||
for (final record in _activePageStays) {
|
||||
_completedPageStays.add(record.close(
|
||||
exitTime: exitTime,
|
||||
hasInteraction: _pagesWithInteraction.contains(record.pageKey),
|
||||
));
|
||||
}
|
||||
_activePageStays.clear();
|
||||
_trimCompletedPageStays();
|
||||
}
|
||||
|
||||
int _findActivePageStayIndex(String pageKey, DateTime enterTime) {
|
||||
for (var i = _activePageStays.length - 1; i >= 0; i--) {
|
||||
final record = _activePageStays[i];
|
||||
if (record.pageKey == pageKey && record.enterTime == enterTime) return i;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
void _trimCompletedPageStays() {
|
||||
while (_completedPageStays.length > _maxPageStayRecords) {
|
||||
_completedPageStays.removeAt(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class SessionMetricsSnapshot {
|
||||
final int eventCountTotal;
|
||||
final int businessEventCount;
|
||||
final int foregroundCount;
|
||||
final int backgroundCountInSid;
|
||||
final int pageCount;
|
||||
final int pageViewCount;
|
||||
final int pageClickCount;
|
||||
final int networkChangeCount;
|
||||
final String sessionSid;
|
||||
final String lastEventName;
|
||||
final List<Map<String, dynamic>> pageStayList;
|
||||
final String behaviorSequenceHash;
|
||||
|
||||
const SessionMetricsSnapshot({
|
||||
required this.eventCountTotal,
|
||||
required this.businessEventCount,
|
||||
required this.foregroundCount,
|
||||
required this.backgroundCountInSid,
|
||||
required this.pageCount,
|
||||
required this.pageViewCount,
|
||||
required this.pageClickCount,
|
||||
required this.networkChangeCount,
|
||||
required this.sessionSid,
|
||||
required this.lastEventName,
|
||||
required this.pageStayList,
|
||||
required this.behaviorSequenceHash,
|
||||
});
|
||||
}
|
||||
|
||||
class _PageStayRecord {
|
||||
final String pageKey;
|
||||
final String pageName;
|
||||
final DateTime enterTime;
|
||||
final DateTime? exitTime;
|
||||
final bool hasInteraction;
|
||||
|
||||
const _PageStayRecord({
|
||||
required this.pageKey,
|
||||
required this.pageName,
|
||||
required this.enterTime,
|
||||
this.exitTime,
|
||||
this.hasInteraction = false,
|
||||
});
|
||||
|
||||
_PageStayRecord close({
|
||||
required DateTime exitTime,
|
||||
required bool hasInteraction,
|
||||
}) {
|
||||
return _PageStayRecord(
|
||||
pageKey: pageKey,
|
||||
pageName: pageName,
|
||||
enterTime: enterTime,
|
||||
exitTime: exitTime,
|
||||
hasInteraction: hasInteraction,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final endTime = exitTime ?? DateTime.now();
|
||||
final stayDurationMs = endTime.difference(enterTime).inMilliseconds;
|
||||
return {
|
||||
'page_key': pageKey,
|
||||
'page_name': pageName,
|
||||
'stay_duration_ms': stayDurationMs < 0 ? 0 : stayDurationMs,
|
||||
'enter_time': enterTime.millisecondsSinceEpoch ~/ 1000,
|
||||
'has_interaction': hasInteraction ? 1 : 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export 'session_sensor_proximity_stub.dart'
|
||||
if (dart.library.io) 'session_sensor_proximity_native.dart';
|
||||
@@ -0,0 +1,17 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
const EventChannel _proximityChannel = EventChannel('analytics_sdk/proximity');
|
||||
|
||||
Stream<int> proximityEvents() {
|
||||
if (!Platform.isAndroid && !Platform.isIOS) {
|
||||
return const Stream<int>.empty();
|
||||
}
|
||||
return _proximityChannel.receiveBroadcastStream().map((value) {
|
||||
if (value is bool) return value ? 1 : 0;
|
||||
if (value is num) return value == 0 ? 0 : 1;
|
||||
final text = value?.toString().trim().toLowerCase() ?? '';
|
||||
return text == 'true' || text == '1' ? 1 : 0;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
Stream<int> proximityEvents() => const Stream<int>.empty();
|
||||
@@ -0,0 +1,467 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:analytics_sdk/manager/session_sensor_proximity.dart';
|
||||
import 'package:analytics_sdk/utils/logger.dart';
|
||||
import 'package:crypto/crypto.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:sensors_plus/sensors_plus.dart';
|
||||
|
||||
enum SensorSampleType {
|
||||
accelerometer,
|
||||
gyroscope,
|
||||
magnetometer,
|
||||
proximity,
|
||||
}
|
||||
|
||||
class SensorMagnitudeSample {
|
||||
final SensorSampleType type;
|
||||
final double magnitude;
|
||||
final DateTime sampledAt;
|
||||
|
||||
const SensorMagnitudeSample({
|
||||
required this.type,
|
||||
required this.magnitude,
|
||||
required this.sampledAt,
|
||||
});
|
||||
}
|
||||
|
||||
class SessionSensorSnapshot {
|
||||
final String sensorSignature;
|
||||
final int sensorCountActive;
|
||||
|
||||
const SessionSensorSnapshot({
|
||||
required this.sensorSignature,
|
||||
required this.sensorCountActive,
|
||||
});
|
||||
|
||||
const SessionSensorSnapshot.unsupported()
|
||||
: sensorSignature = '',
|
||||
sensorCountActive = -1;
|
||||
}
|
||||
|
||||
typedef SensorStreamFactory = Stream<SensorMagnitudeSample> Function(
|
||||
SensorSampleType type,
|
||||
Duration samplingPeriod,
|
||||
);
|
||||
|
||||
typedef PlatformSupportResolver = bool Function();
|
||||
|
||||
/// Android/iOS session-level sensor sampler.
|
||||
///
|
||||
/// It only keeps rolling aggregate statistics for the current sid. Raw sensor
|
||||
/// sequences are never retained or exposed.
|
||||
class SessionSensorSampler {
|
||||
static final SessionSensorSampler instance = SessionSensorSampler._internal();
|
||||
|
||||
static const Duration _defaultWindowInterval = Duration(seconds: 60);
|
||||
static const Duration _defaultWindowDuration = Duration(seconds: 5);
|
||||
static const Duration _defaultSamplingPeriod = Duration(milliseconds: 200);
|
||||
static const int _defaultMaxWindowCount = 6;
|
||||
|
||||
final PlatformSupportResolver _isSupportedPlatform;
|
||||
final SensorStreamFactory _sensorStreams;
|
||||
final Duration _windowInterval;
|
||||
final Duration _windowDuration;
|
||||
final Duration _samplingPeriod;
|
||||
final int _maxWindowCount;
|
||||
|
||||
String _sid = '';
|
||||
bool _isForeground = true;
|
||||
bool _hasForegroundPageView = false;
|
||||
bool _samplingStarted = false;
|
||||
bool _samplingUnavailable = false;
|
||||
bool _windowActive = false;
|
||||
int _windowsStarted = 0;
|
||||
Timer? _nextWindowTimer;
|
||||
Timer? _windowStopTimer;
|
||||
final List<StreamSubscription<SensorMagnitudeSample>> _subscriptions =
|
||||
<StreamSubscription<SensorMagnitudeSample>>[];
|
||||
final Map<SensorSampleType, _SensorStats> _stats =
|
||||
<SensorSampleType, _SensorStats>{};
|
||||
|
||||
factory SessionSensorSampler() => instance;
|
||||
|
||||
SessionSensorSampler._internal()
|
||||
: _isSupportedPlatform = _defaultPlatformSupported,
|
||||
_sensorStreams = _defaultSensorStreams,
|
||||
_windowInterval = _defaultWindowInterval,
|
||||
_windowDuration = _defaultWindowDuration,
|
||||
_samplingPeriod = _defaultSamplingPeriod,
|
||||
_maxWindowCount = _defaultMaxWindowCount;
|
||||
|
||||
@visibleForTesting
|
||||
SessionSensorSampler.forTest({
|
||||
required PlatformSupportResolver isSupportedPlatform,
|
||||
required SensorStreamFactory sensorStreams,
|
||||
Duration windowInterval = _defaultWindowInterval,
|
||||
Duration windowDuration = _defaultWindowDuration,
|
||||
Duration samplingPeriod = _defaultSamplingPeriod,
|
||||
int maxWindowCount = _defaultMaxWindowCount,
|
||||
}) : _isSupportedPlatform = isSupportedPlatform,
|
||||
_sensorStreams = sensorStreams,
|
||||
_windowInterval = windowInterval,
|
||||
_windowDuration = windowDuration,
|
||||
_samplingPeriod = samplingPeriod,
|
||||
_maxWindowCount = maxWindowCount;
|
||||
|
||||
void recordAcceptedEvent(Map<String, dynamic> event) {
|
||||
final eventName = event['event']?.toString() ?? '';
|
||||
if (eventName.isEmpty || eventName == 'app_session_summary') return;
|
||||
|
||||
final sid = event['sid']?.toString() ?? '';
|
||||
if (sid != _sid) {
|
||||
reset(sid: sid);
|
||||
}
|
||||
|
||||
if (eventName == 'app_page_view') {
|
||||
_hasForegroundPageView = _isForeground;
|
||||
_ensureSamplingTask();
|
||||
return;
|
||||
}
|
||||
|
||||
if (eventName != 'app_lifecycle') return;
|
||||
final key = _lifecycleEventKey(event);
|
||||
if (key == 'background') {
|
||||
markBackground();
|
||||
} else if (key == 'foreground') {
|
||||
markForeground();
|
||||
}
|
||||
}
|
||||
|
||||
void markForeground() {
|
||||
_isForeground = true;
|
||||
_hasForegroundPageView = false;
|
||||
}
|
||||
|
||||
String _lifecycleEventKey(Map<String, dynamic> event) {
|
||||
final payload = event['payload'];
|
||||
if (payload is Map) {
|
||||
final value = payload['lifecycle_event_key']?.toString().trim() ?? '';
|
||||
if (value.isNotEmpty) return value;
|
||||
}
|
||||
return event['lifecycle_event_key']?.toString().trim() ?? '';
|
||||
}
|
||||
|
||||
void markBackground() {
|
||||
_isForeground = false;
|
||||
_hasForegroundPageView = false;
|
||||
_pauseSamplingTask();
|
||||
}
|
||||
|
||||
void reset({String sid = ''}) {
|
||||
_pauseSamplingTask();
|
||||
_sid = sid;
|
||||
_isForeground = true;
|
||||
_hasForegroundPageView = false;
|
||||
_samplingStarted = false;
|
||||
_samplingUnavailable = false;
|
||||
_windowsStarted = 0;
|
||||
_stats.clear();
|
||||
}
|
||||
|
||||
SessionSensorSnapshot snapshot({int screenBrightness = -1}) {
|
||||
if (_samplingUnavailable || !_samplingStarted) {
|
||||
return const SessionSensorSnapshot.unsupported();
|
||||
}
|
||||
final activeCount = _stats.entries
|
||||
.where((entry) => entry.value.isActive(_activeThreshold(entry.key)))
|
||||
.length;
|
||||
final totalSamples =
|
||||
_stats.values.fold<int>(0, (sum, stats) => sum + stats.count);
|
||||
if (totalSamples <= 0) {
|
||||
return const SessionSensorSnapshot(
|
||||
sensorSignature: '',
|
||||
sensorCountActive: 0,
|
||||
);
|
||||
}
|
||||
if (activeCount <= 0) {
|
||||
return const SessionSensorSnapshot(
|
||||
sensorSignature: '',
|
||||
sensorCountActive: 0,
|
||||
);
|
||||
}
|
||||
final signatureInput = _signatureInput(screenBrightness: screenBrightness);
|
||||
return SessionSensorSnapshot(
|
||||
sensorSignature: sha256.convert(utf8.encode(signatureInput)).toString(),
|
||||
sensorCountActive: activeCount,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> dispose() async {
|
||||
_pauseSamplingTask();
|
||||
_stats.clear();
|
||||
}
|
||||
|
||||
@visibleForTesting
|
||||
void debugMarkSamplingStartedForTest() {
|
||||
_samplingStarted = true;
|
||||
_samplingUnavailable = false;
|
||||
}
|
||||
|
||||
@visibleForTesting
|
||||
void debugRecordSampleForTest(
|
||||
SensorSampleType type, {
|
||||
required double magnitude,
|
||||
required DateTime sampledAt,
|
||||
}) {
|
||||
_recordSample(SensorMagnitudeSample(
|
||||
type: type,
|
||||
magnitude: magnitude,
|
||||
sampledAt: sampledAt,
|
||||
));
|
||||
}
|
||||
|
||||
void _ensureSamplingTask() {
|
||||
if (!_isForeground || !_hasForegroundPageView) return;
|
||||
if (_windowsStarted >= _maxWindowCount) return;
|
||||
if (_windowActive || _nextWindowTimer != null) return;
|
||||
if (!_isSupportedPlatform()) {
|
||||
_samplingUnavailable = true;
|
||||
return;
|
||||
}
|
||||
_startWindow();
|
||||
}
|
||||
|
||||
void _startWindow() {
|
||||
if (!_isForeground || !_hasForegroundPageView) return;
|
||||
if (_windowsStarted >= _maxWindowCount) return;
|
||||
if (!_isSupportedPlatform()) {
|
||||
_samplingUnavailable = true;
|
||||
return;
|
||||
}
|
||||
|
||||
final subscriptions = <StreamSubscription<SensorMagnitudeSample>>[];
|
||||
for (final type in SensorSampleType.values) {
|
||||
try {
|
||||
final subscription = _sensorStreams(type, _samplingPeriod).listen(
|
||||
_recordSample,
|
||||
onError: (Object error) {
|
||||
Logger.analyticsSdk('传感器采样异常($type): $error', level: LogLevel.warn);
|
||||
},
|
||||
cancelOnError: false,
|
||||
);
|
||||
subscriptions.add(subscription);
|
||||
} catch (e) {
|
||||
Logger.analyticsSdk('传感器监听启动失败($type): $e', level: LogLevel.warn);
|
||||
}
|
||||
}
|
||||
|
||||
if (subscriptions.isEmpty) {
|
||||
_samplingUnavailable = true;
|
||||
return;
|
||||
}
|
||||
|
||||
_subscriptions.addAll(subscriptions);
|
||||
_samplingStarted = true;
|
||||
_samplingUnavailable = false;
|
||||
_windowActive = true;
|
||||
_windowsStarted += 1;
|
||||
_windowStopTimer?.cancel();
|
||||
_windowStopTimer = Timer(_windowDuration, _finishWindow);
|
||||
}
|
||||
|
||||
void _finishWindow() {
|
||||
_stopCurrentWindow();
|
||||
if (!_isForeground || !_hasForegroundPageView) return;
|
||||
if (_windowsStarted >= _maxWindowCount) return;
|
||||
_nextWindowTimer?.cancel();
|
||||
_nextWindowTimer = Timer(_windowInterval, () {
|
||||
_nextWindowTimer = null;
|
||||
_startWindow();
|
||||
});
|
||||
}
|
||||
|
||||
void _pauseSamplingTask() {
|
||||
_nextWindowTimer?.cancel();
|
||||
_nextWindowTimer = null;
|
||||
_windowStopTimer?.cancel();
|
||||
_windowStopTimer = null;
|
||||
_stopCurrentWindow();
|
||||
}
|
||||
|
||||
void _stopCurrentWindow() {
|
||||
_windowActive = false;
|
||||
for (final subscription in _subscriptions) {
|
||||
unawaited(subscription.cancel());
|
||||
}
|
||||
_subscriptions.clear();
|
||||
}
|
||||
|
||||
void _recordSample(SensorMagnitudeSample sample) {
|
||||
if (!sample.magnitude.isFinite) return;
|
||||
final stats = _stats.putIfAbsent(sample.type, _SensorStats.new);
|
||||
if (!stats.canRecord(sample.sampledAt, _samplingPeriod)) return;
|
||||
stats.record(sample.magnitude, sample.sampledAt);
|
||||
}
|
||||
|
||||
String _signatureInput({required int screenBrightness}) {
|
||||
final tokens = <String>['sid=$_sid', 'windows=$_windowsStarted'];
|
||||
if (screenBrightness >= 0 && screenBrightness <= 100) {
|
||||
tokens.add('screen_brightness=$screenBrightness');
|
||||
}
|
||||
for (final type in SensorSampleType.values) {
|
||||
final stats = _stats[type];
|
||||
if (stats == null || stats.count == 0) continue;
|
||||
tokens.add('${_typeCode(type)}:${stats.signatureToken}');
|
||||
}
|
||||
return tokens.join('|');
|
||||
}
|
||||
|
||||
static bool _defaultPlatformSupported() {
|
||||
return isSupportedPlatformForTest(
|
||||
isWeb: kIsWeb,
|
||||
platform: defaultTargetPlatform,
|
||||
);
|
||||
}
|
||||
|
||||
@visibleForTesting
|
||||
static bool isSupportedPlatformForTest({
|
||||
required bool isWeb,
|
||||
required TargetPlatform platform,
|
||||
}) {
|
||||
return !isWeb &&
|
||||
(platform == TargetPlatform.android || platform == TargetPlatform.iOS);
|
||||
}
|
||||
|
||||
static Stream<SensorMagnitudeSample> _defaultSensorStreams(
|
||||
SensorSampleType type,
|
||||
Duration samplingPeriod,
|
||||
) {
|
||||
switch (type) {
|
||||
case SensorSampleType.accelerometer:
|
||||
return accelerometerEvents.map(
|
||||
(event) => SensorMagnitudeSample(
|
||||
type: type,
|
||||
magnitude: _magnitude(event.x, event.y, event.z),
|
||||
sampledAt: DateTime.now(),
|
||||
),
|
||||
);
|
||||
case SensorSampleType.gyroscope:
|
||||
return gyroscopeEvents.map(
|
||||
(event) => SensorMagnitudeSample(
|
||||
type: type,
|
||||
magnitude: _magnitude(event.x, event.y, event.z),
|
||||
sampledAt: DateTime.now(),
|
||||
),
|
||||
);
|
||||
case SensorSampleType.magnetometer:
|
||||
return magnetometerEvents.map(
|
||||
(event) => SensorMagnitudeSample(
|
||||
type: type,
|
||||
magnitude: _magnitude(event.x, event.y, event.z),
|
||||
sampledAt: DateTime.now(),
|
||||
),
|
||||
);
|
||||
case SensorSampleType.proximity:
|
||||
return proximityEvents().map(
|
||||
(value) => SensorMagnitudeSample(
|
||||
type: type,
|
||||
magnitude: value == 0 ? 0 : 1,
|
||||
sampledAt: DateTime.now(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
static double _magnitude(double x, double y, double z) {
|
||||
return sqrt(x * x + y * y + z * z);
|
||||
}
|
||||
|
||||
static String _typeCode(SensorSampleType type) {
|
||||
switch (type) {
|
||||
case SensorSampleType.accelerometer:
|
||||
return 'acc';
|
||||
case SensorSampleType.gyroscope:
|
||||
return 'gyro';
|
||||
case SensorSampleType.magnetometer:
|
||||
return 'mag';
|
||||
case SensorSampleType.proximity:
|
||||
return 'prox';
|
||||
}
|
||||
}
|
||||
|
||||
static double _activeThreshold(SensorSampleType type) {
|
||||
switch (type) {
|
||||
case SensorSampleType.accelerometer:
|
||||
return 0.8;
|
||||
case SensorSampleType.gyroscope:
|
||||
return 0.05;
|
||||
case SensorSampleType.magnetometer:
|
||||
return 3.0;
|
||||
case SensorSampleType.proximity:
|
||||
return 1.0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _SensorStats {
|
||||
int count = 0;
|
||||
double mean = 0;
|
||||
double _m2 = 0;
|
||||
double minValue = double.infinity;
|
||||
double maxValue = double.negativeInfinity;
|
||||
DateTime? _lastSampleAt;
|
||||
int _intervalCount = 0;
|
||||
int _totalIntervalMs = 0;
|
||||
|
||||
bool canRecord(DateTime sampledAt, Duration minInterval) {
|
||||
final last = _lastSampleAt;
|
||||
if (last == null) return true;
|
||||
return sampledAt.difference(last) >= minInterval;
|
||||
}
|
||||
|
||||
void record(double value, DateTime sampledAt) {
|
||||
final last = _lastSampleAt;
|
||||
if (last != null) {
|
||||
final intervalMs = sampledAt.difference(last).inMilliseconds;
|
||||
if (intervalMs > 0) {
|
||||
_intervalCount += 1;
|
||||
_totalIntervalMs += intervalMs;
|
||||
}
|
||||
}
|
||||
_lastSampleAt = sampledAt;
|
||||
|
||||
count += 1;
|
||||
final delta = value - mean;
|
||||
mean += delta / count;
|
||||
final delta2 = value - mean;
|
||||
_m2 += delta * delta2;
|
||||
if (value < minValue) minValue = value;
|
||||
if (value > maxValue) maxValue = value;
|
||||
}
|
||||
|
||||
double get variance => count > 1 ? _m2 / (count - 1) : 0;
|
||||
|
||||
double get range {
|
||||
if (count <= 0) return 0;
|
||||
return maxValue - minValue;
|
||||
}
|
||||
|
||||
int get averageIntervalMs {
|
||||
if (_intervalCount <= 0) return 0;
|
||||
return (_totalIntervalMs / _intervalCount).round();
|
||||
}
|
||||
|
||||
bool isActive(double threshold) {
|
||||
return count > 0 && range >= threshold;
|
||||
}
|
||||
|
||||
String get signatureToken {
|
||||
return [
|
||||
'n=$count',
|
||||
'mean=${_fixed(mean)}',
|
||||
'var=${_fixed(variance)}',
|
||||
'min=${_fixed(minValue)}',
|
||||
'max=${_fixed(maxValue)}',
|
||||
'range=${_fixed(range)}',
|
||||
'avg_i=$averageIntervalMs',
|
||||
].join(',');
|
||||
}
|
||||
|
||||
static String _fixed(double value) {
|
||||
if (!value.isFinite) return '0.000';
|
||||
return value.toStringAsFixed(3);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import 'package:analytics_sdk/enum/user_type_enum.dart';
|
||||
|
||||
/// 全局用户管理器
|
||||
/// 负责管理用户类型信息,支持用户类型更新和登出操作
|
||||
class UserManager {
|
||||
static final UserManager instance = UserManager._internal();
|
||||
|
||||
factory UserManager() => instance;
|
||||
|
||||
UserManager._internal();
|
||||
|
||||
/// 当前用户类型:normal、vip 等
|
||||
String _userType = UserTypeEnum.normal.label;
|
||||
|
||||
String get userType => _userType;
|
||||
|
||||
static String normalizeUserType(String? value) {
|
||||
return value?.trim().toLowerCase() == UserTypeEnum.vip.label
|
||||
? UserTypeEnum.vip.label
|
||||
: UserTypeEnum.normal.label;
|
||||
}
|
||||
|
||||
/// 更新用户类型(登录、升级会员、切换账号时调用)
|
||||
void updateUserType(String newType) {
|
||||
final normalized = normalizeUserType(newType);
|
||||
if (_userType != normalized) {
|
||||
_userType = normalized;
|
||||
}
|
||||
}
|
||||
|
||||
/// 登出
|
||||
void logout() {
|
||||
_userType = UserTypeEnum.normal.label; // 或 'guest',根据业务决定
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:analytics_sdk/entity/app_lifecycle_event.dart';
|
||||
import 'package:analytics_sdk/manager/session_manager.dart';
|
||||
import 'package:analytics_sdk/observer/page_lifecycle_observer.dart';
|
||||
import 'package:analytics_sdk/utils/environment_info_util.dart';
|
||||
import 'package:analytics_sdk/utils/logger.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
/// 应用生命周期观察者:监听应用前后台切换,用于会话管理与生命周期埋点
|
||||
class AppLifecycleObserver with WidgetsBindingObserver {
|
||||
static final AppLifecycleObserver instance = AppLifecycleObserver._internal();
|
||||
|
||||
factory AppLifecycleObserver() => instance;
|
||||
|
||||
AppLifecycleObserver._internal();
|
||||
|
||||
bool _isInitialized = false;
|
||||
void Function(dynamic event)? _track;
|
||||
DateTime Function() _now = DateTime.now;
|
||||
DateTime? _foregroundStartedAt;
|
||||
DateTime? _backgroundStartedAt;
|
||||
int _foregroundCountInSid = 0;
|
||||
String _foregroundCountSid = '';
|
||||
|
||||
/// 初始化观察者
|
||||
void initialize({
|
||||
void Function(dynamic event)? track,
|
||||
DateTime Function()? now,
|
||||
}) {
|
||||
if (track != null) _track = track;
|
||||
if (now != null) _now = now;
|
||||
_foregroundStartedAt ??= _now();
|
||||
if (!_isInitialized) {
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
_isInitialized = true;
|
||||
Logger.analyticsSdk('应用生命周期观察者已初始化');
|
||||
}
|
||||
}
|
||||
|
||||
/// 销毁观察者
|
||||
void dispose() {
|
||||
if (_isInitialized) {
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
_isInitialized = false;
|
||||
Logger.analyticsSdk('应用生命周期观察者已销毁');
|
||||
}
|
||||
_track = null;
|
||||
_foregroundStartedAt = null;
|
||||
_backgroundStartedAt = null;
|
||||
_foregroundCountInSid = 0;
|
||||
_foregroundCountSid = '';
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||
super.didChangeAppLifecycleState(state);
|
||||
|
||||
try {
|
||||
switch (state) {
|
||||
case AppLifecycleState.resumed:
|
||||
if (!kIsWeb) {
|
||||
final now = _now();
|
||||
final backgroundStartedAt = _backgroundStartedAt;
|
||||
final backgroundDurationMs = backgroundStartedAt == null
|
||||
? 0
|
||||
: now.difference(backgroundStartedAt).inMilliseconds;
|
||||
// 应用从后台恢复前台
|
||||
SessionManager.instance.onAppForeground();
|
||||
if (backgroundStartedAt != null) {
|
||||
_emit(AppLifecycleEvent.warmStart(
|
||||
backgroundDurationMs: backgroundDurationMs,
|
||||
resumeReason: 'unknown',
|
||||
));
|
||||
_emit(AppLifecycleEvent.foreground(
|
||||
foregroundCountInSid: _nextForegroundCountInSid(),
|
||||
));
|
||||
}
|
||||
_backgroundStartedAt = null;
|
||||
_foregroundStartedAt = now;
|
||||
}
|
||||
unawaited(
|
||||
EnvironmentInfoUtil.refreshBatteryLevelIfNeeded(force: true),
|
||||
);
|
||||
unawaited(EnvironmentInfoUtil.refreshRuntimeObservationFields());
|
||||
Logger.analyticsSdk('应用恢复前台');
|
||||
break;
|
||||
case AppLifecycleState.paused:
|
||||
if (!kIsWeb) {
|
||||
final now = _now();
|
||||
final foregroundDurationMs = _foregroundStartedAt == null
|
||||
? 0
|
||||
: now.difference(_foregroundStartedAt!).inMilliseconds;
|
||||
_emit(AppLifecycleEvent.background(
|
||||
foregroundDurationMs: foregroundDurationMs,
|
||||
lastActivePageKey: PageLifecycleObserver.currentPageKey,
|
||||
));
|
||||
// Android 和 iOS 进入后台均走 paused(iOS 先经过 hidden 再到 paused)
|
||||
// 统一在此记录后台开始时间,避免在 hidden 重复记录
|
||||
SessionManager.instance.onAppBackground();
|
||||
_backgroundStartedAt = now;
|
||||
}
|
||||
Logger.analyticsSdk('应用进入后台');
|
||||
break;
|
||||
case AppLifecycleState.inactive:
|
||||
// inactive 是过渡状态(进后台和回前台都会经过),不记录后台时间
|
||||
Logger.analyticsSdk('应用进入 inactive 状态');
|
||||
break;
|
||||
case AppLifecycleState.detached:
|
||||
if (!kIsWeb) {
|
||||
final now = _now();
|
||||
_emit(AppLifecycleEvent.terminate(
|
||||
lastActivePageKey: PageLifecycleObserver.currentPageKey,
|
||||
terminateType: 'unknown',
|
||||
sessionDurationMs: _sessionDurationMs(now),
|
||||
terminateTs: now.millisecondsSinceEpoch ~/ 1000,
|
||||
));
|
||||
}
|
||||
Logger.analyticsSdk('应用即将终止');
|
||||
break;
|
||||
default:
|
||||
// Flutter 3.0 没有 hidden;较新版本的 hidden 是过渡状态,
|
||||
// 不应影响会话开始或后台时长。
|
||||
Logger.analyticsSdk('应用生命周期过渡状态: $state');
|
||||
break;
|
||||
}
|
||||
} catch (e) {
|
||||
Logger.analyticsSdk('应用生命周期状态变化处理异常: $e');
|
||||
_emit(AppLifecycleEvent.unknown(unknownReason: 'collect_error'));
|
||||
}
|
||||
}
|
||||
|
||||
void _emit(AppLifecycleEvent event) {
|
||||
try {
|
||||
final track = _track;
|
||||
if (track == null) return;
|
||||
track(event);
|
||||
} catch (e) {
|
||||
Logger.analyticsSdk('app_lifecycle 上报异常,已安全处理: $e');
|
||||
}
|
||||
}
|
||||
|
||||
int _nextForegroundCountInSid() {
|
||||
final sid = SessionManager.instance.currentSessionId ?? '';
|
||||
if (sid != _foregroundCountSid) {
|
||||
_foregroundCountSid = sid;
|
||||
_foregroundCountInSid = 0;
|
||||
}
|
||||
_foregroundCountInSid += 1;
|
||||
return _foregroundCountInSid;
|
||||
}
|
||||
|
||||
int _sessionDurationMs(DateTime now) {
|
||||
final start = SessionManager.instance.sessionStartTime;
|
||||
if (start == null) return 0;
|
||||
final duration = now.difference(start).inMilliseconds;
|
||||
return duration < 0 ? 0 : duration;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
typedef AppLifecycleWebUnloadDisposer = void Function();
|
||||
|
||||
AppLifecycleWebUnloadDisposer registerAppLifecycleWebUnloadListener({
|
||||
required void Function(dynamic event) track,
|
||||
required DateTime pageEnterTime,
|
||||
}) {
|
||||
return () {};
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
// ignore: avoid_web_libraries_in_flutter
|
||||
import 'dart:async';
|
||||
import 'dart:html' as html;
|
||||
|
||||
import 'package:analytics_sdk/entity/app_lifecycle_event.dart';
|
||||
import 'package:analytics_sdk/observer/page_lifecycle_observer.dart';
|
||||
|
||||
typedef AppLifecycleWebUnloadDisposer = void Function();
|
||||
|
||||
AppLifecycleWebUnloadDisposer registerAppLifecycleWebUnloadListener({
|
||||
required void Function(dynamic event) track,
|
||||
required DateTime pageEnterTime,
|
||||
}) {
|
||||
StreamSubscription<html.Event>? pageHideSubscription;
|
||||
pageHideSubscription = html.window.onPageHide.listen((event) {
|
||||
if (_isPersistedPageHide(event)) return;
|
||||
final now = DateTime.now();
|
||||
track(AppLifecycleEvent.webUnload(
|
||||
pageUrl: _currentPageUrl(),
|
||||
lastActivePageKey: PageLifecycleObserver.currentPageKey,
|
||||
pageStayDurationMs: now.difference(pageEnterTime).inMilliseconds,
|
||||
unloadReason: 'unknown',
|
||||
scrollDepthPercentAtUnload: _scrollDepthPercent(),
|
||||
webVisibilityState: _visibilityState(),
|
||||
));
|
||||
});
|
||||
|
||||
return () {
|
||||
pageHideSubscription?.cancel();
|
||||
pageHideSubscription = null;
|
||||
};
|
||||
}
|
||||
|
||||
bool _isPersistedPageHide(html.Event event) {
|
||||
try {
|
||||
if (event is html.PageTransitionEvent) {
|
||||
return event.persisted == true;
|
||||
}
|
||||
} catch (_) {}
|
||||
return false;
|
||||
}
|
||||
|
||||
String _currentPageUrl() {
|
||||
final uri = Uri.tryParse(html.window.location.href);
|
||||
if (uri == null) return '';
|
||||
final buffer = StringBuffer();
|
||||
if (uri.scheme.isNotEmpty) {
|
||||
buffer
|
||||
..write(uri.scheme)
|
||||
..write('://');
|
||||
}
|
||||
if (uri.host.isNotEmpty) {
|
||||
buffer.write(uri.host);
|
||||
}
|
||||
if (uri.hasPort) {
|
||||
buffer.write(':${uri.port}');
|
||||
}
|
||||
if (uri.path.isNotEmpty) {
|
||||
buffer.write(uri.path);
|
||||
}
|
||||
return buffer.toString();
|
||||
}
|
||||
|
||||
int _scrollDepthPercent() {
|
||||
try {
|
||||
final documentElement = html.document.documentElement;
|
||||
final body = html.document.body;
|
||||
final scrollTop = html.window.pageYOffset.round();
|
||||
final viewportHeight = html.window.innerHeight ?? 0;
|
||||
final documentHeight = [
|
||||
documentElement?.scrollHeight ?? 0,
|
||||
body?.scrollHeight ?? 0,
|
||||
documentElement?.clientHeight ?? 0,
|
||||
body?.clientHeight ?? 0,
|
||||
].reduce((value, element) => value > element ? value : element);
|
||||
if (documentHeight <= 0 || viewportHeight <= 0) return 0;
|
||||
final percent = ((scrollTop + viewportHeight) / documentHeight) * 100;
|
||||
return percent.round().clamp(0, 100);
|
||||
} catch (_) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
String _visibilityState() {
|
||||
final state = html.document.visibilityState.trim();
|
||||
if (state == 'visible' || state == 'hidden') return state;
|
||||
return 'unknown';
|
||||
}
|
||||
@@ -0,0 +1,458 @@
|
||||
// lib/observer/page_lifecycle_observer.dart
|
||||
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:analytics_sdk/config/sdk_config.dart';
|
||||
import 'package:analytics_sdk/entity/app_page_view_event.dart';
|
||||
import 'package:analytics_sdk/manager/session_metrics_manager.dart';
|
||||
import 'package:analytics_sdk/utils/logger.dart';
|
||||
import 'package:analytics_sdk/manager/page_name_manager.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/scheduler.dart';
|
||||
|
||||
/// 路由页面生命周期观察者,维护页面状态并按配置自动追踪页面展示事件。
|
||||
///
|
||||
/// **职责**
|
||||
/// - 监听 [Navigator] 的 push / pop / remove / replace,记录 Session 页面停留
|
||||
/// - [autoTrackPageViews] 开启时,在渲染完成后自动上报 [AppPageViewEvent]
|
||||
/// - 维护 [currentPageKey],供 [GlobalClickWrapper] 进行 page_click 点击归属
|
||||
/// - 通过 [_PageInfo.effectivePageKey] 支持子页面精准归属:
|
||||
/// Tab / PageView 切换时更新栈顶的 effectivePageKey,pop 返回时自动恢复正确 key
|
||||
///
|
||||
/// **接入**:在 [MaterialApp.navigatorObservers] 注册 SDK 提供的 `pageObserver`
|
||||
/// 或 `sessionObserver`,无需手动实例化。
|
||||
///
|
||||
/// **限制**:仅追踪带名称的 [PageRoute]([RouteSettings.name] 非空);
|
||||
/// Dialog、BottomSheet 等非 PageRoute 不会触发上报。
|
||||
class PageLifecycleObserver extends NavigatorObserver {
|
||||
/// 全局路由堆栈(静态,确保 [recordNavigation] 等静态方法可直接访问)。
|
||||
///
|
||||
/// 每个 [_PageInfo] 元素记录路由 key、来路、进入时间以及当前有效子页面 key。
|
||||
static final List<_PageInfo> _pageStack = [];
|
||||
|
||||
/// 事件上报回调(由 AnalyticsSdk 在组合根注入,避免循环依赖)
|
||||
final void Function(dynamic event) _track;
|
||||
|
||||
/// 用户类型提供者(由 AnalyticsSdk 在组合根注入)
|
||||
final String Function() _getUserType;
|
||||
|
||||
/// 页面退出回调,传出退出页面的 pageKey(用于清除页面级广告去重状态)
|
||||
final void Function(String pageKey)? _onPageExit;
|
||||
|
||||
/// 真实路由解析回调(由外部注入,例如 GetX 反向匹配模板路由)
|
||||
String Function(String? actualRoute)? _resolveRealRoute;
|
||||
|
||||
/// 是否自动上报 [AppPageViewEvent]。
|
||||
///
|
||||
/// `false` 时仍维护页面状态并写入 Session 停留明细,供手动曝光模式使用。
|
||||
final bool autoTrackPageViews;
|
||||
|
||||
/// 页面堆栈最大容量限制,防止内存无限增长
|
||||
static int get _maxPageStackSize => SdkConfig.maxPageStackSize;
|
||||
|
||||
/// 当前有效页面 key,供 [GlobalClickWrapper] 及业务方读取。
|
||||
///
|
||||
/// 更新时机:
|
||||
/// - 路由 push → 更新为新路由 key
|
||||
/// - 路由 pop → 恢复为目标路由的 [_PageInfo.effectivePageKey](含子页面状态)
|
||||
/// - [recordNavigation] → Tab 切换或 PageView 翻页时手动更新
|
||||
static final ValueNotifier<String> _currentPageKeyNotifier =
|
||||
ValueNotifier('main');
|
||||
|
||||
/// 当前有效页面 key,供 [GlobalClickWrapper] 及业务方读取。
|
||||
///
|
||||
/// 更新时机:
|
||||
/// - 路由 push → 更新为新路由 key
|
||||
/// - 路由 pop → 恢复为目标路由的 [_PageInfo.effectivePageKey](含子页面状态)
|
||||
/// - [recordNavigation] → Tab 切换或 PageView 翻页时手动更新
|
||||
static String get currentPageKey => _currentPageKeyNotifier.value;
|
||||
|
||||
/// 可监听的 currentPageKey,供业务方响应式监听路由变化。
|
||||
///
|
||||
/// 类型为 [ValueListenable](只读),防止外部直接写入破坏 SDK 内部状态。
|
||||
/// 使用示例:
|
||||
/// ```dart
|
||||
/// PageLifecycleObserver.currentPageKeyListenable.addListener(() {
|
||||
/// print(PageLifecycleObserver.currentPageKey);
|
||||
/// });
|
||||
/// ```
|
||||
static ValueListenable<String> get currentPageKeyListenable =>
|
||||
_currentPageKeyNotifier;
|
||||
|
||||
/// [_PageInfo.effectivePageKey],使 pop 返回时能恢复到正确的子页面 key。
|
||||
///
|
||||
/// **调用场景**
|
||||
/// - 由 [AnalyticsSdk.trackNavigation] 和 [AnalyticsSdk.updateCurrentPage] 统一调用;
|
||||
/// [BottomNavigationBar.withAnalytics] / [TabBar.withAnalytics] 内部已处理,
|
||||
/// 接入方通常不需要直接调用此方法。
|
||||
///
|
||||
/// 若堆栈为空(纯 Tab 导航、无路由层级),则只更新 [currentPageKey]。
|
||||
static void recordNavigation(String pageKey) {
|
||||
_currentPageKeyNotifier.value = pageKey;
|
||||
if (_pageStack.isNotEmpty) {
|
||||
_pageStack.last.effectivePageKey = pageKey;
|
||||
}
|
||||
}
|
||||
|
||||
static String _stripLeadingSlash(String name) =>
|
||||
PageNameMapper.normalizeKey(name);
|
||||
|
||||
/// 动态更新真实路由解析回调(支持 SDK re-init 时刷新)
|
||||
void setRealRouteResolver(String Function(String? actualRoute)? resolver) {
|
||||
_resolveRealRoute = resolver;
|
||||
}
|
||||
|
||||
/// 解析路由名:优先使用注入的真实路由回调,失败时降级为 RouteSettings.name
|
||||
String? _resolveRouteName(Route? route) {
|
||||
if (route == null) return null;
|
||||
final actualRoute = route.settings.name;
|
||||
if (_resolveRealRoute == null) return actualRoute;
|
||||
try {
|
||||
final resolved = _resolveRealRoute?.call(actualRoute);
|
||||
if (resolved != null && resolved.isNotEmpty) return resolved;
|
||||
} catch (e) {
|
||||
Logger.pageLifecycleObserver(' 真实路由回调执行失败,降级使用 settings.name: $e');
|
||||
}
|
||||
return actualRoute;
|
||||
}
|
||||
|
||||
// 跟踪活动的 Timer,防止内存泄漏
|
||||
final Map<Route, Timer> _activeTimers = <Route, Timer>{};
|
||||
|
||||
/// 本 observer 管辖的路由集合,用于 dispose 时精准清理,
|
||||
/// 避免多 Navigator 场景下影响其他 observer 的堆栈条目。
|
||||
final Set<Route> _ownedRoutes = {};
|
||||
|
||||
PageLifecycleObserver({
|
||||
required void Function(dynamic event) track,
|
||||
required String Function() getUserType,
|
||||
void Function(String pageKey)? onPageExit,
|
||||
String Function(String? actualRoute)? resolveRealRoute,
|
||||
this.autoTrackPageViews = true,
|
||||
}) : _track = track,
|
||||
_getUserType = getUserType,
|
||||
_onPageExit = onPageExit,
|
||||
_resolveRealRoute = resolveRealRoute;
|
||||
|
||||
@override
|
||||
void didPush(Route route, Route? previousRoute) {
|
||||
super.didPush(route, previousRoute);
|
||||
try {
|
||||
if (route is PageRoute && route.settings.name != null) {
|
||||
final page = _ensurePageEntered(route);
|
||||
if (autoTrackPageViews) _schedulePageView(page);
|
||||
}
|
||||
} catch (e) {
|
||||
Logger.pageLifecycleObserver(' didPush() 异常,已安全处理: $e');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void didPop(Route route, Route? previousRoute) {
|
||||
super.didPop(route, previousRoute);
|
||||
try {
|
||||
_cancelPageViewTimer(route);
|
||||
if (_pageStack.isNotEmpty && _pageStack.last.route == route) {
|
||||
// 在出栈前先读取 prevEffectiveKey(push 时捕获的来路有效 key),
|
||||
// 避免依赖 _pageStack.last.effectivePageKey 在 pop 时仍正确——
|
||||
// 后者可能因 recordNavigation 在 main 未入栈时调用而未被更新。
|
||||
final restoreKey = _pageStack.last.prevEffectiveKey;
|
||||
_handlePageExit(route);
|
||||
|
||||
_currentPageKeyNotifier.value = restoreKey.isNotEmpty
|
||||
? restoreKey
|
||||
: _stripLeadingSlash(previousRoute?.settings.name ?? 'main');
|
||||
}
|
||||
} catch (e) {
|
||||
Logger.pageLifecycleObserver(' didPop() 异常,已安全处理: $e');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void didRemove(Route route, Route? previousRoute) {
|
||||
super.didRemove(route, previousRoute);
|
||||
try {
|
||||
// pushAndRemoveUntil / removeRoute 走这里,需主动清理被移除路由
|
||||
_cancelPageViewTimer(route);
|
||||
final removed = _pageStack.where((info) => info.route == route).toList();
|
||||
_pageStack.removeWhere((info) => info.route == route);
|
||||
_ownedRoutes.remove(route);
|
||||
for (final info in removed) {
|
||||
SessionMetricsManager.instance.recordPageExit(
|
||||
pageKey: info.pageKey,
|
||||
pageName: info.pageName,
|
||||
enterTime: info.sessionEnterTime,
|
||||
exitTime: DateTime.now(),
|
||||
);
|
||||
try {
|
||||
_onPageExit?.call(info.pageKey);
|
||||
} catch (e) {
|
||||
Logger.pageLifecycleObserver('onPageExit 回调异常 (didRemove): $e');
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
Logger.pageLifecycleObserver(' didRemove() 异常,已安全处理: $e');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void didReplace({Route? newRoute, Route? oldRoute}) {
|
||||
super.didReplace(newRoute: newRoute, oldRoute: oldRoute);
|
||||
try {
|
||||
// 旧页面 hide
|
||||
if (oldRoute != null) _cancelPageViewTimer(oldRoute);
|
||||
if (oldRoute is PageRoute && _pageStack.isNotEmpty) {
|
||||
if (_pageStack.last.route == oldRoute) {
|
||||
_handlePageExit(oldRoute);
|
||||
// 旧路由出栈后,将 currentPageKey 同步为新栈顶的 effectivePageKey。
|
||||
// 不更新的话,_ensurePageEntered 捕获的 prevEffectiveKey 会是旧路由的
|
||||
// key,导致新路由被 pop 时恢复到已不存在的旧路由,而非下层真实页面。
|
||||
_currentPageKeyNotifier.value =
|
||||
_pageStack.isNotEmpty ? _pageStack.last.effectivePageKey : 'main';
|
||||
}
|
||||
}
|
||||
// 新页面 show + 曝光
|
||||
if (newRoute is PageRoute && newRoute.settings.name != null) {
|
||||
final page = _ensurePageEntered(newRoute);
|
||||
if (autoTrackPageViews) _schedulePageView(page);
|
||||
}
|
||||
} catch (e) {
|
||||
Logger.pageLifecycleObserver(' didReplace() 异常,已安全处理: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// 新 Session 开始且当前路由未变化时,从 Session 起点续开页面停留。
|
||||
///
|
||||
/// 该操作只写入 [SessionMetricsManager],不会调度或上报 [AppPageViewEvent]。
|
||||
void startCurrentPageStayForNewSession(DateTime sessionStartedAt) {
|
||||
if (_pageStack.isEmpty) return;
|
||||
final current = _pageStack.last;
|
||||
current.sessionEnterTime = sessionStartedAt;
|
||||
SessionMetricsManager.instance.recordPageEnter(
|
||||
pageKey: current.pageKey,
|
||||
pageName: current.pageName,
|
||||
enterTime: sessionStartedAt,
|
||||
);
|
||||
}
|
||||
|
||||
/// 确保同一个 Flutter [Route] 只写入一次页面状态和 Session 停留明细。
|
||||
_PageInfo _ensurePageEntered(PageRoute route) {
|
||||
for (final existing in _pageStack) {
|
||||
if (existing.route == route) return existing;
|
||||
}
|
||||
|
||||
final rawKey =
|
||||
_stripLeadingSlash(_resolveRouteName(route) ?? route.settings.name!);
|
||||
final pageInfo = PageNameMapper.resolvePageInfo(rawKey);
|
||||
final pageKey = pageInfo.key;
|
||||
final pageName = pageInfo.name;
|
||||
// 在 currentPageKey 被覆盖前捕获:这是 pop 返回时应恢复的目标 key。
|
||||
// 必须在 currentPageKey = pageKey 之前读取,否则读到的是新页面自身的 key。
|
||||
final prevEffectiveKey = currentPageKey;
|
||||
_currentPageKeyNotifier.value = pageKey;
|
||||
|
||||
final referrerKey =
|
||||
_pageStack.isEmpty ? '' : _pageStack.last.effectivePageKey;
|
||||
final referrerName = _pageStack.isEmpty
|
||||
? ''
|
||||
: PageNameMapper.getPageName(_pageStack.last.effectivePageKey);
|
||||
final enterTime = DateTime.now();
|
||||
|
||||
// 页面堆栈容量检查
|
||||
if (_pageStack.length >= _maxPageStackSize) {
|
||||
final evicted = _pageStack.removeAt(0);
|
||||
final evictedTimer = _activeTimers.remove(evicted.route);
|
||||
evictedTimer?.cancel();
|
||||
SessionMetricsManager.instance.recordPageExit(
|
||||
pageKey: evicted.pageKey,
|
||||
pageName: evicted.pageName,
|
||||
enterTime: evicted.sessionEnterTime,
|
||||
exitTime: DateTime.now(),
|
||||
);
|
||||
}
|
||||
|
||||
final page = _PageInfo(
|
||||
pageKey: pageKey,
|
||||
pageName: pageName,
|
||||
routeEnterTime: enterTime,
|
||||
route: route,
|
||||
prevEffectiveKey: prevEffectiveKey,
|
||||
referrerPageKey: referrerKey,
|
||||
referrerPageName: referrerName,
|
||||
);
|
||||
_pageStack.add(page);
|
||||
SessionMetricsManager.instance.recordPageEnter(
|
||||
pageKey: pageKey,
|
||||
pageName: pageName,
|
||||
enterTime: enterTime,
|
||||
);
|
||||
_ownedRoutes.add(route);
|
||||
return page;
|
||||
}
|
||||
|
||||
/// 在页面完成首帧渲染后自动上报一次 [AppPageViewEvent]。
|
||||
void _schedulePageView(_PageInfo page) {
|
||||
if (page.pageViewScheduled) return;
|
||||
page.pageViewScheduled = true;
|
||||
|
||||
final route = page.route;
|
||||
bool callbackExecuted = false;
|
||||
Timer? loadTimer;
|
||||
|
||||
void frameCallback(Duration timestamp) {
|
||||
if (callbackExecuted) return;
|
||||
// 页面可能在帧回调等待期间被 pop,检查是否仍在栈中。
|
||||
final stillOnStack = _pageStack.any((info) => info.route == route);
|
||||
if (!stillOnStack) {
|
||||
callbackExecuted = true;
|
||||
loadTimer?.cancel();
|
||||
_activeTimers.remove(route);
|
||||
return;
|
||||
}
|
||||
callbackExecuted = true;
|
||||
|
||||
final pageLoadTime =
|
||||
DateTime.now().difference(page.routeEnterTime).inMilliseconds;
|
||||
loadTimer?.cancel();
|
||||
_activeTimers.remove(route);
|
||||
|
||||
String userType;
|
||||
try {
|
||||
userType = _getUserType();
|
||||
} catch (e) {
|
||||
Logger.pageLifecycleObserver(' 获取用户类型失败,使用默认值: $e');
|
||||
userType = 'normal';
|
||||
}
|
||||
|
||||
try {
|
||||
_track(AppPageViewEvent(
|
||||
userType: userType,
|
||||
pageKey: page.pageKey,
|
||||
pageName: page.pageName,
|
||||
referrerPageKey: page.referrerPageKey,
|
||||
referrerPageName: page.referrerPageName,
|
||||
currentPageKey: page.pageKey,
|
||||
currentPageName: page.pageName,
|
||||
pageLoadTime: pageLoadTime,
|
||||
pageEnterTs: page.routeEnterTime.millisecondsSinceEpoch ~/ 1000,
|
||||
));
|
||||
} catch (e) {
|
||||
Logger.pageLifecycleObserver(' AppPageViewEvent 上报失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// 超时保护:如果第一帧回调未在规定时间内执行,重新注册。
|
||||
loadTimer = Timer(SdkConfig.pageLoadTimeout, () {
|
||||
if (!callbackExecuted &&
|
||||
_pageStack.isNotEmpty &&
|
||||
_pageStack.last.route == route) {
|
||||
SchedulerBinding.instance.addPostFrameCallback(frameCallback);
|
||||
}
|
||||
});
|
||||
_activeTimers[route] = loadTimer;
|
||||
SchedulerBinding.instance.addPostFrameCallback(frameCallback);
|
||||
}
|
||||
|
||||
/// 释放所有待触发的 Timer,防止 dispose 后继续触发幽灵上报。
|
||||
/// 只移除本 observer 管辖的路由条目,避免多 Navigator 场景下清空其他 observer 的堆栈。
|
||||
void dispose() {
|
||||
for (final timer in _activeTimers.values) {
|
||||
timer.cancel();
|
||||
}
|
||||
_pageStack.removeWhere((info) => _ownedRoutes.contains(info.route));
|
||||
_activeTimers.clear();
|
||||
_ownedRoutes.clear();
|
||||
}
|
||||
|
||||
void _cancelPageViewTimer(Route route) {
|
||||
final timer = _activeTimers.remove(route);
|
||||
timer?.cancel();
|
||||
}
|
||||
|
||||
/// 处理页面退出:出栈、取消对应 Timer、触发 [_onPageExit] 回调。
|
||||
void _handlePageExit(Route route) {
|
||||
if (_pageStack.isEmpty || _pageStack.last.route != route) return;
|
||||
|
||||
final exitingPage = _pageStack.last;
|
||||
final exitingPageKey = exitingPage.pageKey;
|
||||
_pageStack.removeLast();
|
||||
_ownedRoutes.remove(route);
|
||||
SessionMetricsManager.instance.recordPageExit(
|
||||
pageKey: exitingPage.pageKey,
|
||||
pageName: exitingPage.pageName,
|
||||
enterTime: exitingPage.sessionEnterTime,
|
||||
exitTime: DateTime.now(),
|
||||
);
|
||||
|
||||
// 清理该路由的 Timer,防止内存泄漏
|
||||
_cancelPageViewTimer(route);
|
||||
|
||||
try {
|
||||
_onPageExit?.call(exitingPageKey);
|
||||
} catch (e) {
|
||||
Logger.pageLifecycleObserver('onPageExit 回调异常: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 路由页面快照,记录页面进入时的状态。
|
||||
///
|
||||
/// [effectivePageKey] 是唯一可变字段:初始值与 [pageKey] 相同,
|
||||
/// 当该路由位于栈顶时,内部 Tab / PageView 切换会通过
|
||||
/// [PageLifecycleObserver.recordNavigation] 将其更新为子页面 key;
|
||||
/// 新路由 push 的 referrer 均能精准反映子页面位置。
|
||||
///
|
||||
/// [prevEffectiveKey] 在 push 时捕获彼时的 [PageLifecycleObserver.currentPageKey],
|
||||
/// pop 返回时用于恢复 [PageLifecycleObserver.currentPageKey]。
|
||||
/// 与 [effectivePageKey] 的区别:前者存储在被 push 的新页面里,
|
||||
/// 后者存储在上一层页面里——当上层页面是"容器路由"时,
|
||||
/// effectivePageKey 可能因时序问题未能同步 tab 状态,
|
||||
/// 而 prevEffectiveKey 在 push 时即时捕获,始终准确。
|
||||
class _PageInfo {
|
||||
/// 路由自身的页面 key(由路由名称规范化而来,不可变)。
|
||||
final String pageKey;
|
||||
|
||||
/// 路由自身的展示名称(由 [PageNameMapper] 解析,不可变)。
|
||||
final String pageName;
|
||||
|
||||
/// 该路由当前的有效页面 key。
|
||||
///
|
||||
/// 初始等于 [pageKey];路由处于栈顶时,内部子页面切换会将其更新为子页面 key,
|
||||
/// 从而使新路由 push 的 referrer 能精准反映子页面位置。
|
||||
String effectivePageKey;
|
||||
|
||||
/// push 此路由时捕获的前一页有效 key,pop 时用于恢复 [PageLifecycleObserver.currentPageKey]。
|
||||
///
|
||||
/// 在 [PageLifecycleObserver._ensurePageEntered] 的第一行读取
|
||||
/// [PageLifecycleObserver.currentPageKey](早于 `currentPageKey = pageKey`),
|
||||
/// 保证容器路由 + tab 场景下返回时能恢复到正确的子页面 key 而非容器路由 key。
|
||||
final String prevEffectiveKey;
|
||||
|
||||
/// 进入该路由时的来路页面信息,供自动页面曝光使用。
|
||||
final String referrerPageKey;
|
||||
final String referrerPageName;
|
||||
|
||||
/// 防止 `pageObserver` 与 `sessionObserver` 误同时注册时重复自动曝光。
|
||||
bool pageViewScheduled = false;
|
||||
|
||||
/// 路由首次进入时刻,只用于页面曝光时间和 pageLoadTime。
|
||||
final DateTime routeEnterTime;
|
||||
|
||||
/// 当前 Session 内的页面进入时刻,用于页面停留统计。
|
||||
DateTime sessionEnterTime;
|
||||
|
||||
/// 对应的 Flutter 路由对象,用于 Timer / 堆栈查找时的精确匹配。
|
||||
final Route route;
|
||||
|
||||
_PageInfo({
|
||||
required this.pageKey,
|
||||
required this.pageName,
|
||||
required this.routeEnterTime,
|
||||
required this.route,
|
||||
required this.prevEffectiveKey,
|
||||
required this.referrerPageKey,
|
||||
required this.referrerPageName,
|
||||
}) : effectivePageKey = pageKey,
|
||||
sessionEnterTime = routeEnterTime;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user