Initial commit: 91dj android client dev_1.0.0

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

Some files were not shown because too many files have changed in this diff Show More