diff --git a/analytics_sdk/.gitignore b/analytics_sdk/.gitignore deleted file mode 100644 index ac5aa98..0000000 --- a/analytics_sdk/.gitignore +++ /dev/null @@ -1,29 +0,0 @@ -# Miscellaneous -*.class -*.log -*.pyc -*.swp -.DS_Store -.atom/ -.buildlog/ -.history -.svn/ -migrate_working_dir/ - -# IntelliJ related -*.iml -*.ipr -*.iws -.idea/ - -# The .vscode folder contains launch configuration and tasks you configure in -# VS Code which you may wish to be included in version control, so this line -# is commented out by default. -#.vscode/ - -# Flutter/Dart/Pub related -# Libraries should not include pubspec.lock, per https://dart.dev/guides/libraries/private-files#pubspeclock. -/pubspec.lock -**/doc/api/ -.dart_tool/ -build/ diff --git a/analytics_sdk/LICENSE b/analytics_sdk/LICENSE deleted file mode 100644 index ba75c69..0000000 --- a/analytics_sdk/LICENSE +++ /dev/null @@ -1 +0,0 @@ -TODO: Add your license here. diff --git a/analytics_sdk/README.md b/analytics_sdk/README.md deleted file mode 100644 index 1fc9d6c..0000000 --- a/analytics_sdk/README.md +++ /dev/null @@ -1,1158 +0,0 @@ -> **版本说明:** 本分支(`dev`)适用于 **Flutter 3.0.5 及以上**(Dart >=2.17)。 -> 如使用 Flutter 1.22.5,请切换至 `compat/flutter-1.22` 分支。 - -# Analytics SDK - -一个 Flutter 埋点 SDK,用于采集 App 内页面、点击、广告、视频、小说、漫画、搜索、推荐等行为,并提供批量上报、本地持久化、域名测速和事件类型动态配置能力。 - -## 目录 - -- [功能特性](#功能特性) -- [安装](#安装) -- [快速开始](#快速开始) -- [核心 API](#核心-api) -- [域名与事件类型管理](#域名与事件类型管理) -- [SDK 配置(可选)](#sdk-配置可选) -- [更多文档](#更多文档) -- [手动上报事件](#手动上报事件) -- [用户类型管理](#用户类型管理) -- [页面名称映射](#页面名称映射) -- [事件数据结构](#事件数据结构) -- [上报机制](#上报机制) -- [事件去重](#事件去重) -- [完整示例](#完整示例) -- [枚举类型参考](#枚举类型参考) -- [注意事项](#注意事项) -- [调试](#调试) -- [常见问题 FAQ](#常见问题-faq) -- [依赖项](#依赖项) -- [环境要求](#环境要求) - -## 功能特性 - -- **多事件类型**:页面浏览、页面点击、导航、广告展示/点击、视频/小说/漫画行为、关键词搜索、推荐等事件 -- **自动埋点**:页面生命周期(曝光/停留)、全局点击(默认开启) -- **批量上报**:队列聚合,默认每 5 秒或队列达 10 条时批量上报 -- **本地持久化**:事件写入本地 JSONL 文件,重启后自动恢复(默认最多 2000 行) -- **智能重试**:指数退避重试机制,初始 2s,最大 60s,最多 5 级 -- **域名测速**:从 `encryptedConfig` 解析域名列表,并发测速选最快可用节点 -- **事件类型动态开关**:通过 `encryptedConfig` 下发白名单,SDK 侧统一过滤 -- **会话管理**:按"冷启动 / 前后台 30 分钟 / 行为 30 分钟"规则生成 `sid` -- **用户管理**:支持用户 ID 与用户类型动态更新 -- **广告展示去重**:session 级别按 adId 自动去重,防止重复上报 -- **设备信息自动采集**:通过 `device_info_plus` 自动获取品牌、型号、系统名称与版本,无需外部传入 -- **设备指纹**:基于设备因子生成 SHA-256 指纹;因子全空时降级为 UUID v4 持久化兜底 -- **公共参数暴露**:通过 `getParams()` 获取全部或指定公共埋点参数,供服务端复用 - -## 安装 - -在宿主工程的 `pubspec.yaml` 中添加依赖(示例): - -```yaml -dependencies: - analytics_sdk: - path: ../analytics_sdk # 本地导入:根据实际目录调整相对路径 -``` - -然后执行: - -```bash -flutter pub get -``` - -## 快速开始 - -### 1. 初始化 SDK - -```dart -import 'package:flutter/widgets.dart'; -import 'package:analytics_sdk/analytics_sdk.dart'; - -void main() async { - WidgetsFlutterBinding.ensureInitialized(); - - // 从你的服务端获取加密配置(由业务方自行实现网络请求) - final encryptedConfig = await yourApiClient.fetchEncryptedConfig(); - - await AnalyticsSdk.instance.init( - appId: 'your_app_id', - encryptedConfig: encryptedConfig, // 加密配置,解密后包含域名列表和事件白名单 - channel: 'official', - uid: 'user_123', // 未登录可传 null 或空字符串 - deviceId: 'device_unique_id', // 设备唯一标识 - ); - - runApp(const MyApp()); -} -``` - -> 设备品牌、型号、系统名称和版本由 SDK 内部通过 `device_info_plus` 自动采集,无需外部传入。 - -### 2. 支持「先 init,后补 encryptedConfig」的场景 - -如果你需要先初始化 SDK,再从自己配置接口拿到加密串,可以这样用: - -```dart -// 1)先初始化(此时还没有 encryptedConfig,事件会正常入队和落盘,但无法实际上报) -await AnalyticsSdk.instance.init( - appId: 'your_app_id', - encryptedConfig: null, // 先传 null,SDK 会尝试读取本地缓存域名 - deviceId: 'device_unique_id', -); - -// 2)等拿到配置后再传入 encryptedConfig -final encryptedConfig = await yourApiClient.fetchEncryptedConfig(); -await AnalyticsSdk.instance.refreshDomainConfig(encryptedConfig: encryptedConfig); -``` - -`refreshDomainConfig()` 会: -- 解密 `encryptedConfig`,解析最新域名列表并并发测速,选出最快可用域名; -- 更新内部上报 URL,后续定时上报和 `flush()` 会把之前队列里的事件一并发出; -- 更新事件类型白名单配置。 - -### 3. 页面生命周期 & 全局点击埋点 - -```dart -import 'package:analytics_sdk/analytics_sdk.dart'; -import 'package:analytics_sdk/widget/global_click_wrapper.dart'; - -class MyApp extends StatelessWidget { - const MyApp({super.key}); - - @override - Widget build(BuildContext context) { - return MaterialApp( - navigatorObservers: [ - AnalyticsSdk.instance.pageObserver, // 页面自动埋点 - ], - builder: (context, child) { - return GlobalClickWrapper( // 全局点击自动抓取 - child: child!, - ); - }, - home: const HomePage(), - ); - } -} -``` - -## 核心 API - -### AnalyticsSdk.init - -```dart -Future init({ - required String appId, - required String? encryptedConfig, - String? channel, - String? uid, - required String deviceId, - bool enableDebugBanner = false, -}) -``` - -建议在 `main()` 中、`runApp()` 之前调用。内部流程: -- 通过 `device_info_plus` 自动采集设备品牌、型号、系统名称、版本等信息; -- 配置公共字段(appId / channel / uid / device 等); -- 初始化 SessionManager、AppLifecycleObserver、设备指纹; -- 解密 `encryptedConfig`,解析域名列表并发测速选最快节点; -- 解析事件类型白名单; -- 如 `encryptedConfig` 为 null / 解密失败,尝试读取本地缓存域名和事件类型配置; -- 初始化事件队列、本地缓存和定时上报定时器。 - -**`enableDebugBanner`**:仅在非 release 包下生效,配合 `AnalyticsDebugBanner` 显示调试悬浮条。 - -### AnalyticsSdk.getParams - -```dart -static Map getParams([List? keys]) -``` - -获取 SDK 当前全部公共参数(或按 key 过滤子集),可用于服务端事件拼装或调试: - -```dart -// 获取全部公共参数 -final all = AnalyticsSdk.getParams(); -// { 'app_id': '...', 'channel': '...', 'uid': '...', 'user_type': '...', 'sid': '...', 'device': '...', ... } - -// 只取指定参数 -final partial = AnalyticsSdk.getParams(['app_id', 'uid', 'device_id']); -``` - -返回字段包括:`app_id`、`channel`、`uid`、`user_type`、`sid`、`device`、`device_id`、`device_brand`、`device_model`、`user_agent`、`system_name`、`system_version`、`device_fingerprint`、`fp_version`。 - -### AnalyticsSdk.refreshDomainConfig - -```dart -Future refreshDomainConfig({String? encryptedConfig}) -``` - -使用场景: -- 先调用 `init()`,再从服务端获取 `encryptedConfig`; -- 运行中需要更新域名列表或事件类型白名单。 - -```dart -// 获取到最新加密配置后刷新 -final newConfig = await yourApiClient.fetchEncryptedConfig(); -await AnalyticsSdk.instance.refreshDomainConfig(encryptedConfig: newConfig); -``` - -> 传入 null 或空字符串时忽略本次刷新。本方法永远不会抛出异常。 - -### 事件上报相关 - -```dart -// 上报内置事件 -void track(dynamic event) - -// 立即触发上报 -Future flush() - -// 页面生命周期观察者 -NavigatorObserver get pageObserver - -// 资源清理(可选) -Future dispose() -``` - -`track()` 支持传入 SDK 内置的事件类(如 `VideoEvent`、`AdImpressionEvent` 等)或直接传 `Map`。 - -SDK 会自动: -- 做广告展示事件去重与过滤; -- 检查事件类型是否在白名单中; -- 校验必填字段; -- 检查单条事件大小(超过 300KB 丢弃); -- 将事件写入队列和本地缓存; -- 在队列长度达到阈值或定时器触发时批量上报。 - -### 用户与会话 - -```dart -// 设置用户 ID 和类型(登录 / 切换账号) -static void setUserIdAndType({String userId = "", UserTypeEnum? userTypeEnum}) - -// 只更新用户 ID(不改变用户类型) -static void setUid(String uid) - -// 更新用户类型 -void updateUserType(String newType) - -// 登出(清空 uid 和 userType) -static void logoutUser() - -// 更新渠道标识 -static void setChannel(String channel) -``` - -`sid`(会话 ID)由 SessionManager 自动管理,无需手动干预。 - -### 获取设备公共字段(服务端复用) - -```dart -static Map getDeviceCommonFields() -``` - -返回字段:`device`、`device_id`、`device_brand`、`device_model`、`user_agent`、`system_name`、`system_version`、`device_fingerprint`。可将这些字段传递给服务端,由服务端上报事件时附加。 - -## 域名与事件类型管理 - -### encryptedConfig 格式 - -`encryptedConfig` 是宿主 App 从自己服务端获取的 **Base64 编码 AES-GCM 密文**,SDK 内部解密后格式为: - -```json -{ - "domainList": [ - "https://api1.example.com", - "https://api2.example.com" - ], - "eventList": [ - "app_page_view", - "page_click", - "video_event", - "ad_impression" - ] -} -``` - -- **`domainList`**:上报域名列表,SDK 启动时会并发测速(GET 请求),选择响应最快的域名上报。 -- **`eventList`**:启用的事件类型白名单。**不在列表中的事件会被静默丢弃**。传空列表时,所有事件类型均允许上报(向后兼容)。 - -### 测速与降级策略 - -1. 解密 `encryptedConfig` 成功 → 用 `domainList` 并发测速,选最快域名; -2. `encryptedConfig` 为 null 或解密失败 → 尝试从磁盘缓存(`data_plus_domains_cache.json`)读取上次保存的域名列表; -3. 缓存也为空 → 上报 URL 保持未配置,事件缓存在本地,等 `refreshDomainConfig()` 成功后自动上报。 - -### 验证加密配置(调试包) - -```dart -final result = AnalyticsSdk.instance.validateEncryptedConfig(encryptedString); -if (result['success'] == true) { - print('条目数量: ${result['count']}'); - print('第一条预览: ${result['preview']}'); -} else { - print('验证失败: ${result['error']}'); -} -``` - -> 此方法仅在 debug 包可用,release 包直接返回 `{'success': false}`。 - -## SDK 配置(可选) - -通过 `SdkConfig.configure()` 调整 SDK 参数,需在 `init()` **之前**调用: - -```dart -import 'package:analytics_sdk/config/sdk_config.dart'; - -SdkConfig.configure( - maxQueueSize: 5000, - uploadInterval: const Duration(seconds: 10), - maxCacheLines: 2000, - connectionTimeout: const Duration(seconds: 15), -); -``` - -常用配置: - -**队列与批量** -- `maxQueueSize`:事件队列最大容量(默认 `5000`) -- `autoUploadThreshold`:达到阈值时自动触发上报(默认 `10`) -- `maxBatchSize`:单次上报最大事件数(默认 `500`) -- `maxJsonSize`:单次请求 JSON 最大字节数(默认 `5MB`) -- `maxSingleEventSize`:单条事件最大字节数,超过则丢弃(默认 `300KB`) - -**定时器** -- `uploadInterval`:定时上报间隔(默认 `5秒`) -- `clickThrottleDuration`:全局点击节流时间(默认 `300毫秒`) -- `speedTestTimeout`:域名测速超时(默认 `5秒`) - -**网络超时** -- `connectionTimeout`:HTTP 连接超时(默认 `10秒`) -- `readTimeout`:HTTP 读取超时(默认 `30秒`) - -**缓存相关** -- `cacheFileName`:事件缓存文件名(默认 `data_plus_events_cache.jsonl`) -- `maxCacheLines`:缓存文件最大行数(默认 `2000`) -- `maxCacheBytes`:缓存文件最大字节数(默认 `2MB`) -- `tombstoneCompactionThreshold`:tombstone 文件达到此条目数时触发主缓存压缩(默认 `200`) -- `tombstoneFileName`:tombstone 文件名(默认 `data_plus_events_tombstone.txt`) - -**重试** -- `baseRetryDelay`:基础重试延迟(默认 `2秒`) -- `maxRetryDelay`:最大重试延迟(默认 `60秒`) -- `maxBackoffLevel`:最大退避级别(默认 `5`) - -**广告去重** -- `maxAdImpressionCapacity`:广告去重集合最大容量,FIFO 淘汰(默认 `10000`) - -恢复所有默认值: - -```dart -SdkConfig.reset(); -``` - -## 更多文档 - -本仓库根目录提供了更详细的开发文档: - -- **SDK_USAGE_GUIDE.md**:完整使用指南,包含所有内置事件的数据结构与字段说明、会话规则、自动埋点细节、性能与容错机制说明等。 - -建议接入前先通读 `SDK_USAGE_GUIDE.md`,本 README 主要用于快速了解和集成。 - ---- - -## 手动上报事件 - -```dart -void track(dynamic event) -``` - -上报任意事件,传入 SDK 内置事件类即可。 - -### 1. App 安装事件(客户端) - -> 首次安装启动时,读取剪贴板获取到 `traceId` 后上报。 - -```dart -AnalyticsSdk.instance.track(AppInstallEvent( - traceId: 'landing_page_trace_id_xxx', // 落地页点击生成的唯一 ID -)); -``` - -### 2. 视频行为事件(客户端) - -```dart -import 'package:analytics_sdk/enum/video_event_enum.dart'; - -AnalyticsSdk.instance.track(VideoEvent( - videoId: 'video_123', - videoTitle: '示例视频', - videoTypeId: 'type_1', - videoTypeName: '娱乐', - videoTagKey: 'tag_1,tag_2', - videoTagName: '热门,最新', - videoDuration: 300, - playDuration: 60, - playProgress: 20, - videoBehavior: VideoEventEnum.VIDEO_PLAY, - mediaId: '', -)); -``` - -### 3. 小说阅读事件(客户端) - -```dart -import 'package:analytics_sdk/enum/read_behavior_enum.dart'; - -AnalyticsSdk.instance.track(NovelEvent( - novelId: 'novel_123', - novelTitle: '示例小说', - novelTypeId: 'type_1', - novelTypeName: '玄幻', - novelTagKey: 'tag_1,tag_2', - novelTagName: '玄幻,穿越', - readProgress: 50, - pageNo: 10, - novelBehavior: ReadBehaviorEnum.PAGE_NEXT, -)); -``` - -### 4. 漫画阅读事件(客户端) - -```dart -AnalyticsSdk.instance.track(ComicEvent( - comicId: 'comic_123', - comicTitle: '示例漫画', - comicTypeId: 'type_1', - comicTypeName: '热血', - comicTagKey: 'tag_1,tag_2', - comicTagName: '热血,冒险', - readProgress: 30, - pageNo: 5, - comicBehavior: ReadBehaviorEnum.VIEW, -)); -``` - -### 5. 广告展示事件(客户端,带自动去重) - -> SDK 会对 `adId` 进行 session 级别去重:同一个 `adId` 在当前 session 内只会上报一次。 - -```dart -import 'package:analytics_sdk/enum/ad_type_enum.dart'; - -AnalyticsSdk.instance.track(AdImpressionEvent( - pageKey: PageLifecycleObserver.currentPageKey, - pageName: PageNameMapper.getPageName(PageLifecycleObserver.currentPageKey), - adSlotKey: 'home_banner_1', - adSlotName: '首页顶部Banner', - adId: 'ad_123,ad_456,ad_789', // 多个 adId 英文逗号分隔 - creativeId: 'cr_001,cr_002,cr_003', // 与 adId 一一对应,可传 null - adType: AdTypeEnum.banner, -)); -``` - -### 6. 广告点击事件(客户端) - -```dart -AnalyticsSdk.instance.track(AdClickEvent( - pageKey: PageLifecycleObserver.currentPageKey, - pageName: PageNameMapper.getPageName(PageLifecycleObserver.currentPageKey), - adSlotKey: 'home_banner_1', - adSlotName: '首页顶部Banner', - adId: 'ad_123', - adType: AdTypeEnum.banner, -)); -``` - -### 7. APP 广告行为事件(客户端) - -> 用于上报 APP 内置活动弹窗、运营 Banner 等的展示、点击、关闭行为。 - -```dart -import 'package:analytics_sdk/enum/banner_event_enum.dart'; - -AnalyticsSdk.instance.track(AdvertisingEvent( - eventType: BannerEventEnum.CLICK.label, // 'click' - advertisingKey: 'home_banner', - advertisingName: '首页Banner', - advertisingId: 'ad_001', -)); -``` - -### 8. 关键词搜索事件(客户端) - -```dart -AnalyticsSdk.instance.track(KeywordSearchEvent( - keyword: '示例关键词', - searchResultCount: 100, -)); -``` - -### 9. 关键词搜索结果点击事件(客户端) - -```dart -import 'package:analytics_sdk/enum/click_item_type_enum.dart'; - -AnalyticsSdk.instance.track(KeywordClickEvent( - keyword: '示例关键词', - clickItemId: 'video_123', - clickItemType: ClickItemTypeEnum.VIDEO, - clickPosition: 1, -)); -``` - -### 10. 推荐列表展示事件(客户端) - -```dart -AnalyticsSdk.instance.track(RecommendListViewEvent( - pageKey: 'home', - pageName: '首页', - recommendContentType: RecommendContentTypeEnum.video, - recommendTraceId: 'rec_trace_abc123', - recommendId: 'vid_001,vid_002,vid_003', -)); -``` - -### 11. 推荐列表点击事件(客户端) - -```dart -AnalyticsSdk.instance.track(RecommendListClickEvent( - pageKey: 'home', - pageName: '首页', - recommendTraceId: 'rec_trace_abc123', - recommendId: 'vid_001', -)); -``` - -### 强制立即上报 - -```dart -await AnalyticsSdk.instance.flush(); -``` - -### 资源清理 - -建议在应用退出前调用,SDK 会先尝试上报剩余事件,再释放所有资源: - -```dart -await AnalyticsSdk.instance.dispose(); -``` - ---- - -## 用户类型管理 - -```dart -import 'package:analytics_sdk/enum/user_type_enum.dart'; - -// 用户登录时设置用户 ID 和类型 -AnalyticsSdk.setUserIdAndType( - userId: 'user_123', - userTypeEnum: UserTypeEnum.vip, -); - -// 只更新用户类型(升级会员等) -AnalyticsSdk.instance.updateUserType(UserTypeEnum.vip.label); - -// 用户登出 -AnalyticsSdk.logoutUser(); -``` - ---- - -## 页面名称映射 - -SDK 使用路由 key(`RouteSettings.name`)作为 `pageKey`,建议在应用启动时统一注册映射: - -```dart -import 'package:analytics_sdk/manager/page_name_manager.dart'; - -// 批量添加 -PageNameMapper.addMappings({ - '/': '首页', - '/video_detail': '视频详情页', - '/user_center': '个人中心', - '/search': '搜索页', -}); - -// 单条添加 -PageNameMapper.addMapping('/settings', '设置页'); - -// 获取 -String pageName = PageNameMapper.getPageName('/video_detail'); // '视频详情页' -``` - -### 动态获取当前页面信息 - -```dart -import 'package:analytics_sdk/observer/page_lifecycle_observer.dart'; -import 'package:analytics_sdk/manager/page_name_manager.dart'; - -AnalyticsSdk.instance.track(AdImpressionEvent( - pageKey: PageLifecycleObserver.currentPageKey, - pageName: PageNameMapper.getPageName(PageLifecycleObserver.currentPageKey), - adSlotKey: 'home_banner_1', - adSlotName: '首页顶部Banner', - adId: 'ad_123', - adType: AdTypeEnum.banner, -)); -``` - ---- - -## 事件数据结构 - -所有事件上报时都包含以下公共字段(顶层): - -| 字段名 | 类型 | 说明 | -|--------|------|------| -| `event` | String | 事件类型,如 `video_event` | -| `channel` | String | 渠道标识 | -| `app_id` | String | 应用 ID | -| `uid` | String | 用户 ID | -| `sid` | String | 会话 ID(冷启动或后台超 30 分钟时重新生成) | -| `client_ts` | int | 事件对象**创建时**的时间戳(10位秒级) | -| `device` | String | 设备类型(SDK 自动检测) | -| `device_id` | String | 设备唯一标识(由 `init(deviceId)` 传入) | -| `device_brand` | String | 设备品牌(SDK 自动检测) | -| `device_model` | String | 设备型号(SDK 自动检测) | -| `user_agent` | String | 客户端 UA(SDK 自动检测) | -| `system_name` | String | 操作系统名称(SDK 自动检测) | -| `system_version` | String | 操作系统版本(SDK 自动检测) | -| `device_fingerprint` | String | 设备指纹(SHA-256;因子全空时降级为 UUID v4) | -| `fp_version` | String | 指纹生成规则版本号(当前为 `1`) | -| `event_id` | String | 事件去重 ID(双重 MD5) | -| `payload` | Object | 事件业务字段 | - -> `device_fingerprint` 和 `fp_version` 均不参与 `event_id` 的 MD5 计算。 - ---- - -## 上报机制 - -### 批量上报地址 - -``` -POST {fastestDomain}/api/eventTracking/batchReport.json?appId={appId} -Content-Type: application/json -Body: [ {...事件1}, {...事件2}, ... ] -``` - -### 触发条件 - -- 队列达到 10 条(`SdkConfig.autoUploadThreshold`) -- 定时器每 5 秒触发(`SdkConfig.uploadInterval`) -- 手动调用 `flush()` - -### 本地持久化 - -- 事件自动保存到 `data_plus_events_cache.jsonl`(应用文档目录) -- 应用重启后自动从缓存恢复并上报;恢复时会过滤掉已通过 tombstone 标记为已上报的事件,以及当前事件类型白名单中未启用的事件类型 -- 上报成功后,已上报的 `event_id` 追加写入 tombstone 文件(`data_plus_events_tombstone.txt`),避免每次上报都重写整个缓存文件;tombstone 条目达到 200 条(`SdkConfig.tombstoneCompactionThreshold`)时触发一次主缓存压缩合并 - -### 重试机制 - -失败后指数退避重试:`2s → 4s → 8s → 16s → 32s`,超过 60s 上限后不再翻倍,最多重试 5 级。 - ---- - -## 事件去重 - -### 通用事件去重 - -每个事件都有唯一的 `event_id`(基于所有字段的双重 MD5 计算),服务端可以使用 `event_id` 去重。 - -### 广告展示事件去重 - -`AdImpressionEvent` 在客户端进行额外去重: - -- 基于 `adId` 去重,同一 `adId` 在当前 session 只上报一次 -- 支持多个 `adId` 逗号分隔,只上报未曾上报的 ID -- 去重记录在内存中,应用重启后清空 -- 容量上限默认 10000 条,超出时 FIFO 淘汰最旧记录 - -去重管理 API: - -```dart -import 'package:analytics_sdk/manager/ad_impression_manager.dart'; - -// 检查广告 ID 是否已上报 -bool isReported = AdImpressionManager.instance.isReported('ad_123'); - -// 获取多个 ID 中哪些未上报 -List unreported = AdImpressionManager.instance.getUnreportedAdIds('ad_123,ad_456'); - -// 清除已上报记录(测试用) -AdImpressionManager.instance.clear(); -``` - ---- - -## 完整示例 - -```dart -import 'package:analytics_sdk/analytics_sdk.dart'; -import 'package:analytics_sdk/config/sdk_config.dart'; -import 'package:analytics_sdk/entity/video_event.dart'; -import 'package:analytics_sdk/entity/ad_impression_event.dart'; -import 'package:analytics_sdk/entity/advertising_event.dart'; -import 'package:analytics_sdk/enum/user_type_enum.dart'; -import 'package:analytics_sdk/enum/video_event_enum.dart'; -import 'package:analytics_sdk/enum/ad_type_enum.dart'; -import 'package:analytics_sdk/enum/banner_event_enum.dart'; -import 'package:analytics_sdk/manager/page_name_manager.dart'; -import 'package:analytics_sdk/observer/page_lifecycle_observer.dart'; -import 'package:analytics_sdk/widget/global_click_wrapper.dart'; -import 'package:flutter/material.dart'; - -void main() async { - WidgetsFlutterBinding.ensureInitialized(); - - // 可选:调整 SDK 默认参数 - SdkConfig.configure( - uploadInterval: const Duration(seconds: 10), - autoUploadThreshold: 20, - ); - - // 配置页面名称映射 - PageNameMapper.addMappings({ - '/': '首页', - '/video_detail': '视频详情页', - '/user_center': '个人中心', - }); - - // 从自己的服务端获取加密配置 - final encryptedConfig = await yourApiClient.fetchEncryptedConfig(); - - // 初始化 SDK(设备信息自动采集,无需外部传入) - await AnalyticsSdk.instance.init( - appId: 'your_app_id', - encryptedConfig: encryptedConfig, - channel: 'official', - uid: '', // 未登录时传空 - deviceId: 'device_unique_id', - enableDebugBanner: true, // 仅 debug 包生效 - ); - - // 设置登录用户信息(已登录时) - AnalyticsSdk.setUserIdAndType( - userId: 'user_123', - userTypeEnum: UserTypeEnum.vip, - ); - - runApp(const MyApp()); -} - -class MyApp extends StatelessWidget { - const MyApp({super.key}); - - @override - Widget build(BuildContext context) { - return AnalyticsDebugBanner( // 仅 debug 包显示调试悬浮条 - child: MaterialApp( - navigatorObservers: [ - AnalyticsSdk.instance.pageObserver, // 页面生命周期自动埋点 - ], - builder: (context, child) { - return GlobalClickWrapper( // 全局点击自动抓取 - child: child!, - ); - }, - home: const HomePage(), - ), - ); - } -} - -class HomePage extends StatelessWidget { - const HomePage({super.key}); - - @override - Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar(title: const Text('首页')), - body: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - ElevatedButton( - onPressed: () { - AnalyticsSdk.instance.track(VideoEvent( - videoId: 'video_123', - videoTitle: '示例视频', - videoTypeId: 'type_1', - videoTypeName: '娱乐', - videoTagKey: 'hot', - videoTagName: '热门', - videoDuration: 300, - playDuration: 60, - playProgress: 20, - videoBehavior: VideoEventEnum.VIDEO_PLAY, - )); - }, - child: const Text('上报视频播放'), - ), - ElevatedButton( - onPressed: () { - AnalyticsSdk.instance.track(AdImpressionEvent( - pageKey: PageLifecycleObserver.currentPageKey, - pageName: PageNameMapper.getPageName(PageLifecycleObserver.currentPageKey), - adSlotKey: 'home_banner_1', - adSlotName: '首页顶部Banner', - adId: 'ad_123,ad_456', - adType: AdTypeEnum.banner, - )); - }, - child: const Text('上报广告展示'), - ), - ElevatedButton( - onPressed: () { - AnalyticsSdk.instance.track(AdvertisingEvent( - eventType: BannerEventEnum.CLICK.label, - advertisingKey: 'home_popup', - advertisingName: '首页弹窗', - advertisingId: 'adv_001', - )); - }, - child: const Text('上报弹窗点击'), - ), - ElevatedButton( - onPressed: () => AnalyticsSdk.instance.flush(), - child: const Text('立即上报'), - ), - ], - ), - ), - ); - } -} - -// 应用退出时清理资源 -class _MyAppState extends State with WidgetsBindingObserver { - @override - void initState() { - super.initState(); - WidgetsBinding.instance.addObserver(this); - } - - @override - void dispose() { - WidgetsBinding.instance.removeObserver(this); - AnalyticsSdk.instance.dispose(); - super.dispose(); - } - - @override - void didChangeAppLifecycleState(AppLifecycleState state) { - if (state == AppLifecycleState.detached) { - AnalyticsSdk.instance.dispose(); - } - } -} -``` - ---- - -## 枚举类型参考 - -### UserTypeEnum - -| 枚举值 | label | 说明 | -|--------|-------|------| -| `UserTypeEnum.normal` | `normal` | 普通用户 | -| `UserTypeEnum.vip` | `vip` | VIP 用户 | - -### DeviceEnum - -| 枚举值 | label | 说明 | -|--------|-------|------| -| `DeviceEnum.android` | `Android` | Android 设备 | -| `DeviceEnum.iOS` | `iOS` | iOS 设备 | -| `DeviceEnum.pc` | `PC` | PC 设备 | - -### VideoEventEnum - -| 枚举值 | key | 说明 | -|--------|-----|------| -| `VideoEventEnum.VIDEO_PLAY` | `video_play` | 播放 | -| `VideoEventEnum.VIDEO_PAUSE` | `video_pause` | 暂停 | -| `VideoEventEnum.VIDEO_SHARE` | `video_share` | 分享 | -| `VideoEventEnum.VIDEO_COMPLETE` | `video_complete` | 播放完成 | -| `VideoEventEnum.VIDEO_FORWARD` | `video_forward` | 快进 | -| `VideoEventEnum.VIDEO_REWIND` | `video_rewind` | 快退 | - -### VideoContentTypeEnum - -| 枚举值 | label | 说明 | -|--------|-------|------| -| `VideoContentTypeEnum.video` | `video` | 长视频 | -| `VideoContentTypeEnum.shortVideo` | `short_video` | 短视频 | - -### ReadBehaviorEnum - -| 枚举值 | key | 说明 | -|--------|-----|------| -| `ReadBehaviorEnum.VIEW` | `view` | 展示 | -| `ReadBehaviorEnum.PAGE_NEXT` | `page_next` | 下一页 | -| `ReadBehaviorEnum.PAGE_PREV` | `page_prev` | 上一页 | -| `ReadBehaviorEnum.COMPLETE` | `complete` | 读完 | - -### AdTypeEnum - -| 枚举值 | label | 说明 | -|--------|-------|------| -| `AdTypeEnum.icon` | `icon` | 图标广告 | -| `AdTypeEnum.banner` | `banner` | Banner | -| `AdTypeEnum.feed` | `feed` | 信息流 | -| `AdTypeEnum.player` | `player` | 播放器广告 | -| `AdTypeEnum.splash` | `splash` | 开屏广告 | -| `AdTypeEnum.text` | `text` | 文字广告 | - -### BannerEventEnum - -| 枚举值 | label | 说明 | -|--------|-------|------| -| `BannerEventEnum.CLICK` | `click` | 点击 | -| `BannerEventEnum.CLOSE` | `close` | 关闭 | -| `BannerEventEnum.SHOW` | `show` | 展示 | - -### AuthTypeEnum - -| 枚举值 | label | 说明 | -|--------|-------|------| -| `AuthTypeEnum.phone` | `phone` | 手机号 | -| `AuthTypeEnum.deviceid` | `deviceid` | 设备 ID | -| `AuthTypeEnum.email` | `email` | 邮箱 | -| `AuthTypeEnum.username` | `username` | 账号 | - -### ClickItemTypeEnum - -| 枚举值 | key | 说明 | -|--------|-----|------| -| `ClickItemTypeEnum.VIDEO` | `video` | 视频 | -| `ClickItemTypeEnum.NOVEL` | `novel` | 小说 | -| `ClickItemTypeEnum.COMIC` | `comic` | 漫画 | - -### RecommendContentTypeEnum - -| 枚举值 | label | 说明 | -|--------|-------|------| -| `RecommendContentTypeEnum.video` | `video` | 视频 | -| `RecommendContentTypeEnum.novel` | `novel` | 小说 | -| `RecommendContentTypeEnum.comic` | `comic` | 漫画 | - -### OrderTypeEnum - -| 枚举值 | label | 说明 | -|--------|-------|------| -| `OrderTypeEnum.coinPurchase` | `coin_purchase` | 金币购买 | -| `OrderTypeEnum.vipSubscription` | `vip_subscription` | VIP 订阅 | - -### CurrencyEnum - -| 枚举值 | label | 说明 | -|--------|-------|------| -| `CurrencyEnum.cny` | `CNY` | 人民币 | -| `CurrencyEnum.usd` | `USD` | 美元 | - -### PayTypeEnum - -| 枚举值 | label | 说明 | -|--------|-------|------| -| `PayTypeEnum.wechat` | `wechat` | 微信支付 | -| `PayTypeEnum.alipay` | `alipay` | 支付宝 | -| `PayTypeEnum.bankCard` | `bank_card` | 银行卡 | -| `PayTypeEnum.applePay` | `apple_pay` | Apple Pay | - -### ConsumeReasonEnum - -| 枚举值 | label | 说明 | -|--------|-------|------| -| `ConsumeReasonEnum.videoUnlock` | `video_unlock` | 视频解锁 | -| `ConsumeReasonEnum.giftSend` | `gift_send` | 礼物赠送 | -| `ConsumeReasonEnum.contentPurchase` | `content_purchase` | 内容购买 | - ---- - -## 注意事项 - -1. **初始化时机**:建议在 `main()` 函数中、`runApp()` 之前初始化 SDK。 - -2. **encryptedConfig 由宿主 App 负责获取**:SDK 不自主发起配置接口请求,宿主 App 需自行从服务端获取加密配置串,再传入 `init()` 或 `refreshDomainConfig()`。 - -3. **设备信息自动采集**:品牌、型号、系统名称和版本由 SDK 通过 `device_info_plus` 自动读取,无需外部传入。`deviceId` 仍由宿主 App 提供(Android IMEI/OAID、iOS IDFV 等)。 - -4. **页面路由命名**:确保所有页面路由都有名称,SDK 才能正确追踪页面生命周期: - ```dart - MaterialApp( - routes: { - '/': (context) => HomePage(), // ✅ 有路由名称 - '/detail': (context) => DetailPage(), - }, - ) - ``` - -5. **用户信息更新**:用户登录、登出、升级会员等操作时,记得调用相应的用户管理方法。 - -6. **网络权限**: - - Android:在 `AndroidManifest.xml` 中添加 `INTERNET` 权限 - - iOS:确保 `Info.plist` 中 `NSAppTransportSecurity` 配置正确 - -7. **资源清理**:应用退出前建议调用 `dispose()` 清理资源。 - -8. **事件过滤**:未在 `encryptedConfig.eventList` 中启用的事件类型会被静默丢弃,可通过日志确认。`eventList` 为空时默认允许所有事件上报。 - -9. **AdImpressionEvent 的 adType 需传枚举**:不能传字符串,需使用 `AdTypeEnum.banner` 等枚举值。 - ---- - -## 调试 - -### AnalyticsDebugBanner - -在 `init()` 时设置 `enableDebugBanner: true`,并在 Widget 树根部加入 `AnalyticsDebugBanner`(仅 debug 包显示): - -```dart -import 'package:analytics_sdk/analytics_sdk.dart'; - -AnalyticsDebugBanner( - child: MaterialApp(...), -) -``` - -悬浮条显示 7 个埋点流程步骤:①域名配置 → ②SDK初始化 → ③域名列表 → ④域名测速 → ⑤上报地址 → ⑥待上报队列 → ⑦最近上报结果。 - -### 通过代码获取调试信息 - -```dart -// 获取摘要 -final info = AnalyticsSdk.instance.getDebugInfo(); -print(info['inited']); // SDK 是否初始化 -print(info['queueLength']); // 当前队列长度 -print(info['reportUrl']); // 上报 URL - -// 获取详细步骤 -final steps = AnalyticsSdk.instance.getDebugSteps(); -for (final step in steps) { - print('${step['name']}: ${step['status']} - ${step['detail']}'); -} -``` - -### 控制日志输出 - -```dart -import 'package:analytics_sdk/utils/logger.dart'; - -Logger.enabled = false; // 关闭所有日志 -Logger.enabled = true; // 开启日志(debug 包默认已开启) -``` - ---- - -## 常见问题 FAQ - -**Q: 事件没有上报成功怎么办?** - -A: -1. 检查网络连接和权限 -2. 确认 `encryptedConfig` 是否传入且格式正确(可用 `validateEncryptedConfig()` 验证) -3. 查看控制台日志了解具体错误 -4. 检查是否调用了 `flush()` 或在应用退出前调用了 `dispose()` - -**Q: 如何验证 encryptedConfig 是否正确?** - -A: 在 debug 包中调用: - -```dart -final result = AnalyticsSdk.instance.validateEncryptedConfig(encryptedString); -print(result); -``` - -**Q: 事件上报延迟多久?** - -A: 正常情况下: -- 队列达到 10 条时立即上报 -- 每 5 秒定时上报一次 -- 调用 `flush()` 立即上报 - -**Q: 为什么某些事件没有上报?** - -A: 可能原因: -1. 事件类型不在 `encryptedConfig.eventList` 白名单中 -2. 广告展示事件被 session 级别去重过滤 -3. 事件单条超过 300KB 被丢弃 -4. 队列满(默认上限 5000 条) -5. 上报 URL 未就绪(域名测速未完成或 encryptedConfig 未传入) - -**Q: 如何动态开启/关闭某些事件的上报?** - -A: 通过服务端更新 `encryptedConfig` 中的 `eventList`,然后调用: - -```dart -await AnalyticsSdk.instance.refreshDomainConfig(encryptedConfig: newEncryptedConfig); -``` - -**Q: 如何自定义 SDK 配置参数?** - -A: - -```dart -import 'package:analytics_sdk/config/sdk_config.dart'; - -SdkConfig.configure( - maxQueueSize: 10000, - uploadInterval: const Duration(seconds: 30), - connectionTimeout: const Duration(seconds: 15), -); - -await AnalyticsSdk.instance.init(...); -``` - -**Q: 如何控制日志输出?** - -A: - -```dart -import 'package:analytics_sdk/utils/logger.dart'; -Logger.enabled = false; -``` - ---- - -## 支持的事件类型 - -| 事件类型字符串 | 说明 | 上报方式 | -|--------------|------|---------| -| `app_install` | App 安装 | 客户端 | -| `navigation` | 导航事件 | **自动** | -| `app_page_view` | 应用页面展示 | **自动** | -| `page_click` | 应用页面点击 | **自动** | -| `page_lifecycle` | 页面生命周期 | **自动** | -| `advertising` | APP 广告行为(弹窗/运营 Banner) | 客户端 | -| `ad_click` | 广告点击 | 客户端 | -| `ad_impression` | 广告展示 | 客户端 | -| `video_event` | 视频行为 | 客户端 | -| `novel_event` | 小说阅读 | 客户端 | -| `comic_event` | 漫画阅读 | 客户端 | -| `keyword_search` | 关键词搜索 | 客户端 | -| `keyword_click` | 关键词搜索结果点击 | 客户端 | -| `recommend_list_view` | 推荐列表展示 | 客户端 | -| `recommend_list_click` | 推荐列表点击 | 客户端 | - ---- - -## 依赖项 - -- `flutter`: Flutter SDK (>=3.3.0) -- `uuid`: UUID 生成(设备指纹因子全空时的降级兜底) -- `path_provider: ^2.1.4`: 本地文件路径获取 -- `synchronized: ^3.1.0`: 并发控制锁 -- `device_info_plus: ^10.0.0`: 自动采集设备品牌、型号、系统名称与版本 -- `pointycastle: 3.7.3`: AES-GCM 解密 -- `crypto: ^3.0.2`: SHA-256 / MD5 哈希计算 - -## 环境要求 - -- Dart SDK: `>=3.4.4 <4.0.0` -- Flutter: `>=3.3.0` diff --git a/analytics_sdk/SDK_USAGE_GUIDE.md b/analytics_sdk/SDK_USAGE_GUIDE.md deleted file mode 100644 index 6224497..0000000 --- a/analytics_sdk/SDK_USAGE_GUIDE.md +++ /dev/null @@ -1,1027 +0,0 @@ -# Analytics SDK 使用文档 - -## 目录 - -1. [概述](#1-概述) -2. [安装](#2-安装) -3. [SDK 初始化](#3-sdk-初始化) -4. [用户管理](#4-用户管理) -5. [自动埋点](#5-自动埋点) -6. [手动事件上报](#6-手动事件上报) -7. [枚举参考](#7-枚举参考) -8. [SdkConfig 全局参数](#8-sdkconfig-全局参数) -9. [上报数据格式](#9-上报数据格式) -10. [调试工具](#10-调试工具) -11. [高级用法](#11-高级用法) -12. [完整集成示例](#12-完整集成示例) - ---- - -## 1. 概述 - -Analytics SDK 是一个 Flutter 插件,提供: - -- 自动埋点:页面浏览、页面生命周期、全局点击、导航事件(均默认开启) -- 手动埋点:视频/小说/漫画行为、广告、推荐等客户端事件 -- 本地持久化缓存(JSONL 格式),断网重传 -- 域名并发测速,自动选择最快上报节点 -- AES-GCM 加密配置,防止配置明文泄露 -- 事件类型白名单控制(服务端下发) -- 广告展示去重(session 级别) -- 设备信息自动采集(通过 `device_info_plus`,无需外部传入) -- 设备指纹自动生成 -- `getParams()` 方法暴露全部或指定公共参数,供服务端复用 - -所有公开方法均**不会向外抛出异常**,SDK 内部失败不影响宿主应用运行。 - ---- - -## 2. 安装 - -在 `pubspec.yaml` 中添加依赖: - -```yaml -dependencies: - analytics_sdk: - path: ../analytics_sdk # 或 git/pub 地址 -``` - -运行: - -```bash -flutter pub get -``` - ---- - -## 3. SDK 初始化 - -### 3.1 最简初始化 - -```dart -import 'package:analytics_sdk/analytics_sdk.dart'; - -await AnalyticsSdk.instance.init( - appId: 'your_app_id', - encryptedConfig: 'Base64加密字符串', // 服务端下发 - deviceId: 'your_device_id', // Android IMEI/OAID、iOS IDFV 等 -); -``` - -设备品牌、型号、系统名称、版本等信息由 SDK 内部通过 `device_info_plus` 自动采集。 - -### 3.2 完整参数说明 - -| 参数 | 类型 | 必填 | 默认值 | 说明 | -|------|------|------|--------|------| -| `appId` | `String` | 是 | — | 应用 ID | -| `encryptedConfig` | `String?` | 是(可传 null) | null | 加密配置字符串,见下方格式说明 | -| `channel` | `String?` | 否 | '' | 渠道标识,可后续通过 `setChannel` 更新 | -| `uid` | `String?` | 否 | '' | 用户 ID,未登录可传空,登录后通过 `setUserIdAndType` 更新 | -| `deviceId` | `String` | 是 | — | 设备唯一标识(Android IMEI/OAID、iOS IDFV 等) | -| `enableDebugBanner` | `bool` | 否 | `false` | 是否显示调试悬浮条(仅在非 release 包生效) | - -> **自动采集字段**:`device`(类型)、`device_brand`、`device_model`、`system_name`、`system_version`、`user_agent` 均由 SDK 通过 `device_info_plus` 自动读取,无需传入。 - -### 3.3 encryptedConfig 格式说明 - -`encryptedConfig` 是一个 Base64 编码的 AES-GCM 密文,解密后内容为: - -```json -{ - "domainList": [ - "https://api1.example.com", - "https://api2.example.com" - ], - "eventList": [ - "app_page_view", - "page_click", - "video_event", - "ad_impression" - ] -} -``` - -- `domainList`:上报域名列表,SDK 启动时会并发测速,选择最快的域名上报。 -- `eventList`:启用的事件类型白名单。**不在列表中的事件会被静默丢弃**。传空列表或省略此字段时,所有事件类型均允许上报。 - -如需在调试包中验证加密串,使用 [`validateEncryptedConfig`](#validateencryptedconfig)。 - -### 3.4 初始化时机 - -建议在 `main()` 中 `runApp()` 之前调用: - -```dart -void main() async { - WidgetsFlutterBinding.ensureInitialized(); - - // 可选:在 init() 前覆盖默认参数 - SdkConfig.configure( - uploadInterval: const Duration(seconds: 5), - autoUploadThreshold: 10, - ); - - await AnalyticsSdk.instance.init( - appId: 'your_app_id', - encryptedConfig: await fetchEncryptedConfigFromServer(), - deviceId: await getDeviceId(), - channel: 'google_play', - uid: currentUser?.id ?? '', - ); - - runApp(const MyApp()); -} -``` - ---- - -## 4. 用户管理 - -### 4.1 登录 / 切换账号 - -```dart -AnalyticsSdk.setUserIdAndType( - userId: '12345', - userTypeEnum: UserTypeEnum.vip, -); -``` - -| 参数 | 类型 | 说明 | -|------|------|------| -| `userId` | `String` | 用户 ID,默认空字符串(未登录状态) | -| `userTypeEnum` | `UserTypeEnum?` | 用户类型,null 时默认为 `normal` | - -### 4.2 仅更新 uid(不改变用户类型) - -```dart -AnalyticsSdk.setUid('12345'); -``` - -### 4.3 更新渠道 - -```dart -AnalyticsSdk.setChannel('huawei_store'); -``` - -### 4.4 登出 - -```dart -AnalyticsSdk.logoutUser(); -``` - ---- - -## 5. 自动埋点 - -### 5.1 页面生命周期自动追踪(PageLifecycleObserver) - -```dart -MaterialApp( - navigatorObservers: [ - AnalyticsSdk.instance.pageObserver, - ], -) -``` - -注册后 SDK 会自动追踪页面进入、离开和导航事件,无需手动上报。 - -获取当前页面 key: - -```dart -final currentPage = PageLifecycleObserver.currentPageKey; -``` - -### 5.2 页面名称映射(PageNameMapper) - -```dart -import 'package:analytics_sdk/manager/page_name_manager.dart'; - -PageNameMapper.addMappings({ - 'home': '首页', - 'video_detail': '视频详情页', - 'user_center': '个人中心', - 'search': '搜索页', -}); - -final name = PageNameMapper.getPageName('video_detail'); // '视频详情页' -``` - -> **注意**:`pageKey` 和映射 key 均会自动去除前导 `/`,传 `'/video_detail'` 与 `'video_detail'` 效果相同。 - -### 5.3 全局点击自动追踪(GlobalClickWrapper) - -```dart -import 'package:analytics_sdk/widget/global_click_wrapper.dart'; - -MaterialApp( - builder: (context, child) => GlobalClickWrapper(child: child!), -) -``` - -自动上报 `PageClickEvent`,含归一化坐标(300ms 节流)。 - ---- - -## 6. 手动事件上报 - -所有手动事件通过 `AnalyticsSdk.instance.track(event)` 上报。 - -### 6.1 App 安装事件【客户端】 - -> **触发时机**:首次安装启动后,读取剪贴板获取到 traceId 时上报。 - -```dart -AnalyticsSdk.instance.track(AppInstallEvent( - traceId: 'clipboard_trace_id', -)); -``` - -| 字段 | 类型 | 说明 | -|------|------|------| -| `traceId` | `String` | 落地页生成并写入剪贴板的唯一追踪 ID | - ---- - -### 6.2 视频行为事件【客户端】 - -> **触发时机**:用户播放、暂停、快进、完播等操作时上报。 - -```dart -AnalyticsSdk.instance.track(VideoEvent( - videoId: 'vid_001', - videoTitle: '精彩大片', - videoTypeId: 'action', - videoTypeName: '动作片', - videoTagKey: 'hot,new', - videoTagName: '热门,最新', - videoDuration: 7200, - playDuration: 120, - playProgress: 30, - videoBehavior: VideoEventEnum.VIDEO_PLAY, - videoContentType: VideoContentTypeEnum.video, - recommendTraceId: 'trace_abc123', // 未接推荐引擎传 '' - mediaId: '', -)); -``` - -| 字段 | 类型 | 必填 | 说明 | -|------|------|------|------| -| `videoId` | `String` | 是 | 视频 ID | -| `videoTitle` | `String` | 是 | 视频标题 | -| `videoTypeId` | `String` | 是 | 视频分类 ID | -| `videoTypeName` | `String` | 是 | 视频分类名称 | -| `videoTagKey` | `String` | 是 | 标签 KEY,多个用英文逗号分隔 | -| `videoTagName` | `String` | 是 | 标签名称,多个用英文逗号分隔 | -| `videoDuration` | `int` | 是 | 视频总时长(秒) | -| `playDuration` | `int` | 是 | 本次播放时长(秒) | -| `playProgress` | `int` | 是 | 播放进度百分比(0-100) | -| `videoBehavior` | `VideoEventEnum` | 是 | 视频行为,见 [VideoEventEnum](#videoeventenum) | -| `videoContentType` | `VideoContentTypeEnum?` | 否 | 内容类型:`video`(长视频) / `short_video`(短视频),未传时上报空字符串 | -| `recommendTraceId` | `String` | 否 | 推荐引擎 trace ID,未接推荐引擎传 `''`,默认 `''` | -| `mediaId` | `String` | 否 | 媒体资源 ID,默认 `''` | - ---- - -### 6.3 小说阅读事件【客户端】 - -```dart -AnalyticsSdk.instance.track(NovelEvent( - novelId: 'novel_001', - novelTitle: '斗破苍穹', - novelTypeId: 'fantasy', - novelTypeName: '玄幻', - novelTagKey: 'hot,classic', - novelTagName: '热门,经典', - readProgress: 45, - pageNo: 10, - novelBehavior: ReadBehaviorEnum.PAGE_NEXT, - mediaId: '', -)); -``` - -| 字段 | 类型 | 必填 | 说明 | -|------|------|------|------| -| `novelId` | `String` | 是 | 小说 ID | -| `novelTitle` | `String` | 是 | 小说标题 | -| `novelTypeId` | `String` | 是 | 分类 ID | -| `novelTypeName` | `String` | 是 | 分类名称 | -| `novelTagKey` | `String` | 是 | 标签 KEY,多个用英文逗号分隔 | -| `novelTagName` | `String` | 是 | 标签名称,多个用英文逗号分隔 | -| `readProgress` | `int` | 是 | 阅读进度百分比(0-100) | -| `pageNo` | `int` | 是 | 当前阅读第几页(从 1 开始) | -| `novelBehavior` | `ReadBehaviorEnum` | 是 | 阅读行为,见 [ReadBehaviorEnum](#readbehaviorenum) | -| `recommendTraceId` | `String` | 否 | 推荐引擎 trace ID,未接推荐引擎传 `''`,默认 `''` | -| `mediaId` | `String` | 否 | 媒体资源 ID,未使用老司机库资源时传 `''` | - ---- - -### 6.4 漫画阅读事件【客户端】 - -```dart -AnalyticsSdk.instance.track(ComicEvent( - comicId: 'comic_001', - comicTitle: '火影忍者', - comicTypeId: 'battle', - comicTypeName: '热血', - comicTagKey: 'hot,classic', - comicTagName: '热门,经典', - readProgress: 60, - pageNo: 5, - comicBehavior: ReadBehaviorEnum.PAGE_NEXT, -)); -``` - -字段与 `NovelEvent` 对称,将 `novel*` 前缀替换为 `comic*`,`novelBehavior` 替换为 `comicBehavior`。`recommendTraceId` 字段同样支持,默认 `''`。 - ---- - -### 6.5 广告展示事件【客户端】(带自动去重) - -> SDK 会对 `adId` 进行 session 级别去重:同一个 `adId` 在当前 session 内只会上报一次。 - -```dart -AnalyticsSdk.instance.track(AdImpressionEvent( - pageKey: 'home', - pageName: '首页', - adSlotKey: 'home_banner_1', - adSlotName: '首页顶部Banner', - adId: 'ad_001,ad_002,ad_003', - creativeId: 'cr_001,cr_002,cr_003', - adType: 'banner', // 传广告类型字符串,不传默认空字符串 -)); -``` - -| 字段 | 类型 | 必填 | 说明 | -|------|------|------|------| -| `pageKey` | `String` | 是 | 页面标识 | -| `pageName` | `String` | 是 | 页面名称 | -| `adSlotKey` | `String` | 是 | 广告位标识 | -| `adSlotName` | `String` | 是 | 广告位名称 | -| `adId` | `String` | 是 | 广告 ID,多个用英文逗号分隔 | -| `creativeId` | `String` | 否 | 素材 ID,多个用英文逗号分隔,与 `adId` 一一对应,默认 `''` | -| `adType` | `String` | 否 | 广告类型字符串,如 `'banner'`、`'feed'`、`'splash'`,默认 `''` | - -**去重逻辑**: -- 进入 TabA 展示 20 条广告 → 上报 20 条 -- 切换到 TabB 展示 20 条广告 → 上报 20 条 -- 返回 TabA 上滑新加载 10 条广告 → 上报新的 10 条 -- 再次下滑看到之前已展示的广告 → 全部被过滤 - ---- - -### 6.6 广告点击事件【客户端】 - -```dart -AnalyticsSdk.instance.track(AdClickEvent( - pageKey: 'home', - pageName: '首页', - adSlotKey: 'home_banner_1', - adSlotName: '首页顶部Banner', - adId: 'ad_001', - creativeId: 'cr_001', - adType: 'banner', -)); -``` - -字段与 `AdImpressionEvent` 相同,`adId` 为单个广告 ID。 - ---- - -### 6.7 APP 广告行为事件【客户端】(弹窗/Banner 行为) - -> 用于上报 APP 内置广告(弹窗、Banner 等)的展示、点击、关闭行为。 - -```dart -AnalyticsSdk.instance.track(AdvertisingEvent( - eventType: BannerEventEnum.CLICK.label, - advertisingKey: 'home_popup', - advertisingName: '首页弹窗', - advertisingId: 'adv_001', -)); -``` - -| 字段 | 类型 | 说明 | -|------|------|------| -| `eventType` | `String` | 行为类型:`click`/`close`/`show` | -| `advertisingKey` | `String` | 广告标识 | -| `advertisingName` | `String` | 广告名称 | -| `advertisingId` | `String` | 广告 ID | - ---- - -### 6.8 关键词搜索事件【客户端】 - -```dart -AnalyticsSdk.instance.track(KeywordSearchEvent( - keyword: '火影忍者', - searchResultCount: 42, - searchTraceId: '', // 未接搜索引擎时传空字符串 - searchId: '', // 未接搜索引擎时传空字符串 -)); -``` - -| 字段 | 类型 | 必填 | 说明 | -|------|------|------|------| -| `keyword` | `String` | 是 | 搜索关键词 | -| `searchResultCount` | `int` | 是 | 搜索结果数量 | -| `searchTraceId` | `String` | 否 | 搜索引擎 trace ID,未接搜索引擎时传 `''` | -| `searchId` | `String` | 否 | 搜索引擎 search ID,未接搜索引擎时传 `''` | - ---- - -### 6.9 关键词点击事件【客户端】 - -```dart -AnalyticsSdk.instance.track(KeywordClickEvent( - keyword: '火影忍者', - clickItemId: 'vid_001', - clickItemType: ClickItemTypeEnum.VIDEO, - clickPosition: 1, - searchTraceId: '', // 未接搜索引擎时传空字符串 -)); -``` - -| 字段 | 类型 | 必填 | 说明 | -|------|------|------|------| -| `keyword` | `String` | 是 | 搜索关键词 | -| `clickItemId` | `String` | 是 | 点击项目 ID(视频ID / 小说ID 等) | -| `clickItemType` | `ClickItemTypeEnum` | 是 | 点击项目类型:`VIDEO` / `NOVEL` / `COMIC` | -| `clickPosition` | `int` | 是 | 点击位置(搜索结果中的排序位置,从 1 开始) | -| `searchTraceId` | `String` | 否 | 搜索引擎 trace ID,未接搜索引擎时传 `''` | - ---- - -### 6.10 推荐列表展示事件【客户端】 - -```dart -AnalyticsSdk.instance.track(RecommendListViewEvent( - pageKey: 'home', - pageName: '首页', - recommendContentType: RecommendContentTypeEnum.video, - recommendTraceId: 'rec_trace_abc123', - recommendId: 'vid_001,vid_002,vid_003', - recommendTraceInfo: '{"source":"server"}', // 未接推荐引擎传 '' - clientVersion: '1.2.3', // 未接推荐引擎传 '' -)); -``` - -| 字段 | 类型 | 必填 | 说明 | -|------|------|------|------| -| `pageKey` | `String` | 是 | 页面标识 | -| `pageName` | `String` | 是 | 页面名称 | -| `recommendContentType` | `RecommendContentTypeEnum` | 是 | 推荐内容类型,见 [RecommendContentTypeEnum](#recommendcontenttypeenum) | -| `recommendTraceId` | `String` | 是 | 推荐引擎 trace ID | -| `recommendId` | `String` | 是 | 可视窗口内的内容 ID,多个用英文逗号分隔 | -| `recommendTraceInfo` | `String` | 否 | 推荐引擎 trace info,未接推荐引擎传 `''`,默认 `''` | -| `clientVersion` | `String` | 否 | 客户端版本号,如 `'1.2.3'`,默认 `''` | - ---- - -### 6.11 推荐列表点击事件【客户端】 - -```dart -AnalyticsSdk.instance.track(RecommendListClickEvent( - pageKey: 'home', - pageName: '首页', - recommendContentType: RecommendContentTypeEnum.video, - recommendTraceId: 'rec_trace_abc123', - recommendId: 'vid_001', - recommendTraceInfo: '{"source":"server"}', // 未接推荐引擎传 '' - clientVersion: '1.2.3', // 未接推荐引擎传 '' -)); -``` - -| 字段 | 类型 | 必填 | 说明 | -|------|------|------|------| -| `pageKey` | `String` | 是 | 页面标识 | -| `pageName` | `String` | 是 | 页面名称 | -| `recommendContentType` | `RecommendContentTypeEnum` | 是 | 推荐内容类型,见 [RecommendContentTypeEnum](#recommendcontenttypeenum) | -| `recommendTraceId` | `String` | 是 | 推荐引擎 trace ID | -| `recommendId` | `String` | 是 | 被点击内容的 ID | -| `recommendTraceInfo` | `String` | 否 | 推荐引擎 trace info,未接推荐引擎传 `''`,默认 `''` | -| `clientVersion` | `String` | 否 | 客户端版本号,如 `'1.2.3'`,默认 `''` | - ---- - -### 6.12 强制立即上报 - -```dart -await AnalyticsSdk.instance.flush(); -``` - -### 6.13 释放资源 - -```dart -await AnalyticsSdk.instance.dispose(); -``` - ---- - -## 7. 枚举参考 - -### DeviceEnum - -| 枚举值 | label | 说明 | -|--------|-------|------| -| `DeviceEnum.android` | `Android` | Android 设备 | -| `DeviceEnum.iOS` | `iOS` | iOS 设备 | -| `DeviceEnum.pc` | `PC` | PC 设备 | - -### UserTypeEnum - -| 枚举值 | label | 说明 | -|--------|-------|------| -| `UserTypeEnum.normal` | `normal` | 普通用户 | -| `UserTypeEnum.vip` | `vip` | VIP 用户 | - -### AuthTypeEnum - -| 枚举值 | label | 说明 | -|--------|-------|------| -| `AuthTypeEnum.phone` | `phone` | 手机号 | -| `AuthTypeEnum.deviceid` | `deviceid` | 设备 ID | -| `AuthTypeEnum.email` | `email` | 邮箱 | -| `AuthTypeEnum.username` | `username` | 账号 | - -### AdTypeEnum - -> **已废弃**:`adType` 字段已改为普通 `String`,直接传字符串即可,如 `'banner'`、`'feed'`、`'splash'`。无需 import 枚举类。 - -### OrderTypeEnum - -| 枚举值 | label | 说明 | -|--------|-------|------| -| `OrderTypeEnum.coinPurchase` | `coin_purchase` | 金币购买 | -| `OrderTypeEnum.vipSubscription` | `vip_subscription` | VIP 订阅 | - -### CurrencyEnum - -| 枚举值 | label | 说明 | -|--------|-------|------| -| `CurrencyEnum.cny` | `CNY` | 人民币 | -| `CurrencyEnum.usd` | `USD` | 美元 | - -### PayTypeEnum - -| 枚举值 | label | 说明 | -|--------|-------|------| -| `PayTypeEnum.wechat` | `wechat` | 微信支付 | -| `PayTypeEnum.alipay` | `alipay` | 支付宝 | -| `PayTypeEnum.bankCard` | `bank_card` | 银行卡 | -| `PayTypeEnum.applePay` | `apple_pay` | Apple Pay | - -### ConsumeReasonEnum - -| 枚举值 | label | 说明 | -|--------|-------|------| -| `ConsumeReasonEnum.videoUnlock` | `video_unlock` | 视频解锁 | -| `ConsumeReasonEnum.giftSend` | `gift_send` | 礼物赠送 | -| `ConsumeReasonEnum.contentPurchase` | `content_purchase` | 内容购买 | - -### VideoEventEnum - -| 枚举值 | key | 说明 | -|--------|-----|------| -| `VideoEventEnum.VIDEO_VIEW` | `video_view` | 展示 | -| `VideoEventEnum.VIDEO_PLAY` | `video_play` | 播放 | -| `VideoEventEnum.VIDEO_PAUSE` | `video_pause` | 暂停 | -| `VideoEventEnum.VIDEO_SHARE` | `video_share` | 分享 | -| `VideoEventEnum.VIDEO_COMPLETE` | `video_complete` | 播放完成 | -| `VideoEventEnum.VIDEO_FORWARD` | `video_forward` | 快进 | -| `VideoEventEnum.VIDEO_REWIND` | `video_rewind` | 快退 | - -### VideoContentTypeEnum - -| 枚举值 | label | 说明 | -|--------|-------|------| -| `VideoContentTypeEnum.video` | `video` | 长视频 | -| `VideoContentTypeEnum.shortVideo` | `short_video` | 短视频 | - -### ReadBehaviorEnum - -| 枚举值 | key | 说明 | -|--------|-----|------| -| `ReadBehaviorEnum.VIEW` | `view` | 展示 | -| `ReadBehaviorEnum.PAGE_NEXT` | `page_next` | 下一页 | -| `ReadBehaviorEnum.PAGE_PREV` | `page_prev` | 上一页 | -| `ReadBehaviorEnum.COMPLETE` | `complete` | 读完 | - -### ClickItemTypeEnum - -| 枚举值 | label | 说明 | -|--------|-------|------| -| `ClickItemTypeEnum.VIDEO` | `video` | 视频 | -| `ClickItemTypeEnum.NOVEL` | `novel` | 小说 | -| `ClickItemTypeEnum.COMIC` | `comic` | 漫画 | - -### RecommendContentTypeEnum - -| 枚举值 | label | 说明 | -|--------|-------|------| -| `RecommendContentTypeEnum.video` | `video` | 视频 | -| `RecommendContentTypeEnum.novel` | `novel` | 小说 | -| `RecommendContentTypeEnum.comic` | `comic` | 漫画 | - -### BannerEventEnum - -| 枚举值 | label | 说明 | -|--------|-------|------| -| `BannerEventEnum.CLICK` | `click` | 点击 | -| `BannerEventEnum.CLOSE` | `close` | 关闭 | -| `BannerEventEnum.SHOW` | `show` | 展示 | - ---- - -## 8. SdkConfig 全局参数 - -在 `AnalyticsSdk.instance.init()` **之前**调用 `SdkConfig.configure(...)` 覆盖默认值: - -```dart -SdkConfig.configure( - uploadInterval: const Duration(seconds: 5), - autoUploadThreshold: 10, - maxBatchSize: 200, - connectionTimeout: const Duration(seconds: 15), -); -``` - -| 参数 | 默认值 | 说明 | -|------|--------|------| -| `maxQueueSize` | `5000` | 内存队列最大容量,超过后新事件丢弃 | -| `autoUploadThreshold` | `10` | 队列达到此数量时立即触发上报 | -| `maxBatchSize` | `500` | 单次上报最大事件数 | -| `uploadInterval` | `Duration(seconds: 5)` | 定时上报间隔 | -| `clickThrottleDuration` | `Duration(milliseconds: 300)` | 点击事件节流时间 | -| `connectionTimeout` | `Duration(seconds: 10)` | HTTP 连接超时 | -| `readTimeout` | `Duration(seconds: 30)` | HTTP 读取超时 | -| `speedTestTimeout` | `Duration(seconds: 5)` | 域名测速超时 | -| `maxCacheLines` | `2000` | 缓存文件最大行数 | -| `maxCacheBytes` | `2 * 1024 * 1024` (2MB) | 缓存文件最大字节数 | -| `maxSingleEventSize` | `300 * 1024` (300KB) | 单条事件最大字节数,超过丢弃 | -| `maxAdImpressionCapacity` | `10000` | 广告去重集合最大容量(FIFO 淘汰) | - -恢复所有默认值: - -```dart -SdkConfig.reset(); -``` - ---- - -## 9. 上报数据格式 - -### 9.1 公共字段 - -每个事件上报时,顶层 JSON 自动携带以下公共字段: - -| 字段名 | 类型 | 说明 | -|--------|------|------| -| `event` | `String` | 事件类型,如 `video_event`、`ad_impression` | -| `channel` | `String` | 渠道标识 | -| `app_id` | `String` | 应用 ID | -| `uid` | `String` | 用户 ID | -| `sid` | `String` | 会话 ID,自动管理 | -| `client_ts` | `int` | 事件创建时间戳(10位,秒) | -| `device` | `String` | 设备类型(自动采集) | -| `device_id` | `String` | 设备唯一标识 | -| `device_brand` | `String` | 设备品牌(自动采集) | -| `device_model` | `String` | 设备型号(自动采集) | -| `user_agent` | `String` | 客户端标识(自动采集) | -| `system_name` | `String` | 操作系统名称(自动采集) | -| `system_version` | `String` | 操作系统版本(自动采集) | -| `device_fingerprint` | `String` | 设备指纹(自动生成) | -| `fp_version` | `String` | 指纹版本号 | -| `event_id` | `String` | 事件去重 ID | -| `payload` | `Object` | 事件业务字段 | - -### 9.2 上报数据格式 - -```json -{ - "event": "video_event", - "channel": "google_play", - "app_id": "your_app_id", - "uid": "12345", - "sid": "sess_abc123", - "client_ts": 1704067200, - "device": "Android", - "device_id": "xxxx", - "device_brand": "Samsung", - "device_model": "SM-G991B", - "user_agent": "...", - "system_name": "Android", - "system_version": "14.0", - "device_fingerprint": "sha256-hex-xxxx", - "fp_version": "1", - "event_id": "md5-hash-for-dedup", - "payload": { - "event": "video_event", - "video_id": "vid_001", - ... - } -} -``` - -### 9.3 字段校验规则 - -SDK 在事件入队前会自动校验并修正字段。**关键字段**不合规时整个事件被静默丢弃;**可纠正字段**超限时自动截断或补全,不影响上报。 - -关键字段包括:`event`、`event_id`、`app_id`、`sid`、`client_ts`、`device`(提供了无法识别的值时)、`device_id`、`payload`(类型错误时),以及 payload 中的核心业务 ID 字段(`video_id` / `novel_id` / `comic_id` / `click_item_id`)。 - -> **通用类型处理规则** -> - 所有字符串字段在校验前均会自动 `trim()`(去除首尾空格)。 -> - 非字符串类型(如 `int`、`bool`、`null` 等)传入字符串字段时视为类型错误,等同于未传该字段(关键字段 → 丢弃事件;可选字段 → 移除该字段)。 -> - 数字字段(百分比 / 位置 / 数量 / 时间戳类)支持 `int`、`double` 以及纯数字字符串三种类型,均可正常解析。 - -#### 公共字段 - -| 字段 | 规则 | 不合规处理 | -|------|------|-----------| -| `event` | 小写字母/数字/下划线,最大 64 字符 | 丢弃事件 | -| `event_id` | 字母/数字,1~32 字符(SDK 自动生成) | 丢弃事件 | -| `app_id` | 非空,最大 64 字符 | 空时丢弃事件;超长截断 | -| `channel` | 可选,最大 128 字符 | 超长截断 | -| `uid` | 可选,最大 128 字符 | 超长截断 | -| `sid` | 自动生成;为空时 SDK 自动填充会话 ID | 生成失败则丢弃事件 | -| `client_ts` | 整数,范围 946684800 ~ 4102444800(2000 ~ 2100 年) | 丢弃事件 | -| `device` | 仅 `Android` / `iOS` / `PC`,大小写自动纠正 | 丢弃事件 | -| `device_id` | 非空,最大 128 字符 | 空时丢弃事件;超长截断 | -| `device_fingerprint` | 可选,最大 128 字符 | 超长截断 | -| `fp_version` | 若指纹非空而版本为空,自动填充默认版本 | — | - -#### payload 业务字段 - -| 类别 | 代表字段 | 规则 | 不合规处理 | -|------|---------|------|-----------| -| 字段名 | 所有 key | `^[A-Za-z0-9_]+$` | 移除该字段 | -| 关键 ID | `video_id` / `novel_id` / `comic_id` / `click_item_id` | 非空 | 丢弃事件 | -| 普通 ID | `ad_id` / `recommend_id` 等 | 非空 | 移除该字段 | -| 逗号分隔 | `ad_id` / `novel_tag_key` / `video_tag_name` 等 | 自动清理各段首尾空格、过滤空段;清洗后仍为空则移除该字段 | 移除该字段 | -| 可选字符串 | `creative_id` / `recommend_trace_id` 等 | 可为空 | 空字符串原样保留 | -| 名称字段 | `page_name` / `ad_name` / `video_name` 等 | 最大 128 字符 | 截断 | -| 标题字段 | `title` / `content_title` / `search_keyword` 等 | 最大 256 字符 | 截断 | -| 关键词字段 | `keywords` / `tags` / `description` | 最大 500 字符 | 截断 | -| 百分比字段 | `progress` / `completion_rate` / `view_ratio` | 整数,0 ~ 100 | 移除该字段 | -| 位置字段 | `position` / `rank` / `slot` / `index` | 整数,≥ 1 | 移除该字段 | -| 数字字段 | `duration` / `count` / `amount` 等 | 整数,≥ 0 | 移除该字段 | -| 时间戳字段 | `start_ts` / `end_ts` / `publish_ts` | 同 `client_ts` 范围 | 移除该字段 | -| 其他字段 | — | 字符串最大 500 字符;非字符串原样保留 | 截断 | - -### 9.4 设备指纹(device_fingerprint / fp_version) - -设备指纹由 SDK 在 `init()` 时自动生成,业务方**无需**手动传入。 - -#### 采集因子 - -| 平台 | 参与计算的因子 | -|------|--------------| -| Native(Android / iOS / 桌面) | `device_id`、`device`(类型)、`device_brand`、`device_model`、`system_name`、`system_version` | -| Web | `device`、`device_brand`、`device_model`、`system_name`、`system_version`、`user_agent`、屏幕分辨率(`WxH`)、浏览器语言、时区名称 | - -#### 生成规则 - -1. 过滤所有空因子,将剩余因子用 `|` 拼接后做 **SHA-256**,取 64 位十六进制字符串作为指纹。 -2. 若**所有因子均为空**(降级场景),改为生成随机 **UUID v4** 并持久化至本地(见缓存规则),后续从本地读取保证稳定性。 - -> `device_fingerprint` **不参与** `event_id` 的计算。 - -#### 缓存规则 - -- **内存缓存**:首次生成后缓存在内存中,同一应用生命周期内直接返回,不重复计算。 -- **持久化缓存**(仅降级 UUID 路径): - - Native:写入 `applicationDocumentsDirectory/analytics_fingerprint.txt`; - - Web:写入 `localStorage['analytics_fp']`。 -- 重新 `init()` 后若设备因子发生变化,可通过内部 `clearCache()` 重新计算(一般无需手动调用)。 - -#### 失败兜底 - -任何环节发生异常,均静默返回空字符串,事件照常上报;`device_fingerprint` 为空时,`fp_version` 字段亦不写入(无指纹则无需版本号)。 - -#### fp_version 说明 - -`fp_version` 标识当前指纹生成规则的版本(当前为 `"1"`),供服务端区分不同规则生成的指纹,支持跨版本数据对比分析。生成规则(因子组合或哈希算法)变更时版本号递增。 - ---- - -## 10. 调试工具 - -### 10.1 AnalyticsDebugBanner(悬浮调试条) - -在 `init()` 时传 `enableDebugBanner: true`,然后将 `AnalyticsDebugBanner()` 放入 widget 树顶部(通常是页面 Column 的第一个子节点或 Stack 的顶层): - -```dart -await AnalyticsSdk.instance.init( - // ... - enableDebugBanner: true, -); - -class MyApp extends StatelessWidget { - @override - Widget build(BuildContext context) { - return MaterialApp( - home: Scaffold( - body: Column( - children: [ - const AnalyticsDebugBanner(), // 无需任何参数 - Expanded(child: YourContent()), - ], - ), - ), - ); - } -} -``` - -调试条显示 7 个步骤:①域名配置 ②SDK初始化 ③域名列表 ④域名测速 ⑤上报地址 ⑥待上报队列 ⑦最近上报。release 包自动隐藏,无性能影响。 - -### 10.2 getDebugSteps / getDebugInfo - -```dart -final steps = AnalyticsSdk.instance.getDebugSteps(); -for (final step in steps) { - print('${step['name']}: ${step['status']} - ${step['detail']}'); -} - -final info = AnalyticsSdk.instance.getDebugInfo(); -print(info['inited']); -print(info['queueLength']); -print(info['reportUrl']); -``` - -### 10.3 validateEncryptedConfig - -```dart -final result = AnalyticsSdk.instance.validateEncryptedConfig(encryptedString); -if (result['success'] == true) { - print('域名数量: ${result['domainCount']}'); - print('事件类型数量: ${result['eventCount']}'); -} else { - print('验证失败: ${result['error']}'); -} -``` - -仅 debug 包可用,release 包返回错误。 - -### 10.4 Logger - -```dart -import 'package:analytics_sdk/utils/logger.dart'; - -Logger.enabled = false; // 关闭所有日志 -``` - ---- - -## 11. 高级用法 - -### 11.1 延迟传入加密配置 - -```dart -void main() async { - WidgetsFlutterBinding.ensureInitialized(); - - await AnalyticsSdk.instance.init( - appId: 'your_app_id', - encryptedConfig: null, // SDK 尝试读取磁盘缓存域名 - deviceId: await getDeviceId(), - ); - - runApp(const MyApp()); - - // 异步获取,不阻塞 UI 启动 - _fetchAndApplyConfig(); -} - -Future _fetchAndApplyConfig() async { - try { - final config = await yourApiClient.fetchEncryptedConfig(); - await AnalyticsSdk.instance.refreshDomainConfig(encryptedConfig: config); - } catch (_) {} -} -``` - -### 11.2 获取公共参数(getParams) - -`getParams()` 可获取 SDK 当前全部公共参数,适合服务端拼装事件或埋点调试: - -```dart -// 获取全部参数 -final all = AnalyticsSdk.getParams(); - -// 只取指定字段 -final partial = AnalyticsSdk.getParams(['app_id', 'uid', 'device_id', 'device_fingerprint']); -``` - -返回 `Map`,包含:`app_id`、`channel`、`uid`、`user_type`、`sid`、`device`、`device_id`、`device_brand`、`device_model`、`user_agent`、`system_name`、`system_version`、`device_fingerprint`、`fp_version`。 - -如果 key 不存在,该 key 不会出现在返回 Map 中(静默忽略)。 - -### 11.3 服务端复用设备公共字段 - -```dart -final deviceFields = AnalyticsSdk.getDeviceCommonFields(); -// 包含:device, device_id, device_brand, device_model, -// user_agent, system_name, system_version, device_fingerprint -``` - ---- - -## 12. 完整集成示例 - -```dart -import 'package:flutter/material.dart'; -import 'package:analytics_sdk/analytics_sdk.dart'; -import 'package:analytics_sdk/config/sdk_config.dart'; -import 'package:analytics_sdk/enum/user_type_enum.dart'; -import 'package:analytics_sdk/manager/page_name_manager.dart'; -import 'package:analytics_sdk/widget/global_click_wrapper.dart'; - -void main() async { - WidgetsFlutterBinding.ensureInitialized(); - - SdkConfig.configure( - uploadInterval: const Duration(seconds: 5), - autoUploadThreshold: 10, - ); - - PageNameMapper.addMappings({ - 'home': '首页', - 'video_detail': '视频详情页', - 'user_center': '个人中心', - 'search': '搜索页', - }); - - await AnalyticsSdk.instance.init( - appId: 'your_app_id', - encryptedConfig: 'YOUR_BASE64_ENCRYPTED_CONFIG', - deviceId: 'device_unique_id', - channel: 'google_play', - uid: '', - enableDebugBanner: true, - ); - - runApp(const MyApp()); -} - -class MyApp extends StatelessWidget { - const MyApp({super.key}); - - @override - Widget build(BuildContext context) { - return GlobalClickWrapper( - child: MaterialApp( - navigatorObservers: [ - AnalyticsSdk.instance.pageObserver, - ], - initialRoute: '/', - routes: { - '/': (context) => const HomePage(), - '/video_detail': (context) => const VideoDetailPage(), - }, - ), - ); - } -} - -// ——— 登录场景 ——— -void onLoginSuccess(String userId, bool isVip) { - AnalyticsSdk.setUserIdAndType( - userId: userId, - userTypeEnum: isVip ? UserTypeEnum.vip : UserTypeEnum.normal, - ); -} - -// ——— 视频播放场景 ——— -void onVideoPlay(String videoId, String title) { - AnalyticsSdk.instance.track(VideoEvent( - videoId: videoId, - videoTitle: title, - videoTypeId: 'action', - videoTypeName: '动作片', - videoTagKey: 'hot', - videoTagName: '热门', - videoDuration: 7200, - playDuration: 0, - playProgress: 0, - videoBehavior: VideoEventEnum.VIDEO_PLAY, - )); -} - -// ——— 广告展示场景 ——— -void onAdsRendered(List adIds) { - AnalyticsSdk.instance.track(AdImpressionEvent( - pageKey: 'home', - pageName: '首页', - adSlotKey: 'home_feed_ad', - adSlotName: '首页信息流广告', - adId: adIds.join(','), - adType: 'feed', - )); -} - -// ——— 应用退出场景 ——— -Future onAppExit() async { - await AnalyticsSdk.instance.flush(); - await AnalyticsSdk.instance.dispose(); -} -``` diff --git a/analytics_sdk/analysis_options.yaml b/analytics_sdk/analysis_options.yaml deleted file mode 100644 index a5744c1..0000000 --- a/analytics_sdk/analysis_options.yaml +++ /dev/null @@ -1,4 +0,0 @@ -include: package:flutter_lints/flutter.yaml - -# Additional information about this file can be found at -# https://dart.dev/guides/language/analysis-options diff --git a/analytics_sdk/android/.gitignore b/analytics_sdk/android/.gitignore deleted file mode 100644 index 161bdcd..0000000 --- a/analytics_sdk/android/.gitignore +++ /dev/null @@ -1,9 +0,0 @@ -*.iml -.gradle -/local.properties -/.idea/workspace.xml -/.idea/libraries -.DS_Store -/build -/captures -.cxx diff --git a/analytics_sdk/android/build.gradle b/analytics_sdk/android/build.gradle deleted file mode 100644 index 8c594be..0000000 --- a/analytics_sdk/android/build.gradle +++ /dev/null @@ -1,64 +0,0 @@ -group = "com.example.analytics_sdk" -version = "1.0-SNAPSHOT" - -// Flutter plugins are built within the host application's Gradle process. -// Do not pin AGP or Kotlin versions here: Flutter 3.0 hosts still use -// Gradle 6 / AGP 4, while newer hosts use AGP 8. -rootProject.allprojects { - repositories { - google() - mavenCentral() - maven { url 'https://jitpack.io' } - maven { url 'https://developer.huawei.com/repo/' } - maven { url 'https://developer.honor.com/repo/' } - } -} - -apply plugin: "com.android.library" -apply plugin: "kotlin-android" - -android { - if (project.android.hasProperty("namespace")) { - namespace = "com.example.analytics_sdk" - } - - // AGP 4.x understands compileSdkVersion; AGP 7/8 retains it as a - // compatible (deprecated) alias. - compileSdkVersion 34 - - compileOptions { - sourceCompatibility = JavaVersion.VERSION_1_8 - targetCompatibility = JavaVersion.VERSION_1_8 - } - - kotlinOptions { - jvmTarget = "1.8" - } - - sourceSets { - main.java.srcDirs += "src/main/kotlin" - test.java.srcDirs += "src/test/kotlin" - } - - defaultConfig { - minSdkVersion 21 - } - - testOptions { - unitTests.all { - useJUnitPlatform() - - testLogging { - events "passed", "skipped", "failed", "standardOut", "standardError" - outputs.upToDateWhen {false} - showStandardStreams = true - } - } - } -} - -dependencies { - testImplementation("org.jetbrains.kotlin:kotlin-test") - testImplementation("org.mockito:mockito-core:5.0.0") - implementation 'com.github.gzu-liyujiang:Android_CN_OAID:4.2.16' -} diff --git a/analytics_sdk/android/gradle/wrapper/gradle-wrapper.properties b/analytics_sdk/android/gradle/wrapper/gradle-wrapper.properties deleted file mode 100644 index 9355b41..0000000 --- a/analytics_sdk/android/gradle/wrapper/gradle-wrapper.properties +++ /dev/null @@ -1,7 +0,0 @@ -distributionBase=GRADLE_USER_HOME -distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.10-bin.zip -networkTimeout=10000 -validateDistributionUrl=true -zipStoreBase=GRADLE_USER_HOME -zipStorePath=wrapper/dists diff --git a/analytics_sdk/android/settings.gradle b/analytics_sdk/android/settings.gradle deleted file mode 100644 index aedd5d4..0000000 --- a/analytics_sdk/android/settings.gradle +++ /dev/null @@ -1 +0,0 @@ -rootProject.name = 'analytics_sdk' diff --git a/analytics_sdk/android/src/main/AndroidManifest.xml b/analytics_sdk/android/src/main/AndroidManifest.xml deleted file mode 100644 index 3bee847..0000000 --- a/analytics_sdk/android/src/main/AndroidManifest.xml +++ /dev/null @@ -1,3 +0,0 @@ - - diff --git a/analytics_sdk/android/src/main/kotlin/com/example/analytics_sdk/AnalyticsSdkPlugin.kt b/analytics_sdk/android/src/main/kotlin/com/example/analytics_sdk/AnalyticsSdkPlugin.kt deleted file mode 100644 index a72d0ff..0000000 --- a/analytics_sdk/android/src/main/kotlin/com/example/analytics_sdk/AnalyticsSdkPlugin.kt +++ /dev/null @@ -1,491 +0,0 @@ -package com.example.analytics_sdk - -import android.app.ActivityManager -import android.app.Application -import android.content.Context -import android.content.pm.ApplicationInfo -import android.content.pm.PackageManager -import android.hardware.Sensor -import android.hardware.SensorEvent -import android.hardware.SensorEventListener -import android.hardware.SensorManager -import android.net.ConnectivityManager -import android.net.NetworkCapabilities -import android.os.Build -import android.os.Handler -import android.os.Looper -import android.os.StatFs -import android.provider.Settings -import android.telephony.TelephonyManager -import android.util.Log -import com.github.gzuliyujiang.oaid.DeviceID -import com.github.gzuliyujiang.oaid.DeviceIdentifier -import com.github.gzuliyujiang.oaid.IGetter -import io.flutter.embedding.engine.plugins.FlutterPlugin -import io.flutter.plugin.common.MethodCall -import io.flutter.plugin.common.MethodChannel -import io.flutter.plugin.common.MethodChannel.MethodCallHandler -import io.flutter.plugin.common.MethodChannel.Result -import io.flutter.plugin.common.EventChannel -import java.io.File -import java.util.TimeZone - -class AnalyticsSdkPlugin: FlutterPlugin, MethodCallHandler, EventChannel.StreamHandler { - private lateinit var channel : MethodChannel - private var proximityChannel: EventChannel? = null - private lateinit var context: Context - private val mainHandler = Handler(Looper.getMainLooper()) - private var proximityEventSink: EventChannel.EventSink? = null - private var proximityListener: SensorEventListener? = null - - private companion object { - private const val LOG_TAG = "AnalyticsSdk" - private const val LOG_CHUNK_SIZE = 1000 - } - - override fun onAttachedToEngine(flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) { - context = flutterPluginBinding.applicationContext - registerDeviceIdentifier() - channel = MethodChannel(flutterPluginBinding.binaryMessenger, "analytics_sdk") - channel.setMethodCallHandler(this) - proximityChannel = EventChannel( - flutterPluginBinding.binaryMessenger, - "analytics_sdk/proximity" - ) - proximityChannel?.setStreamHandler(this) - } - - override fun onMethodCall(call: MethodCall, result: Result) { - when (call.method) { - "getPlatformVersion" -> result.success("Android ${Build.VERSION.RELEASE}") - "getDeviceId" -> result.success(deviceId()) - "getSdkInitPlatformFields" -> collectSdkInitPlatformFields(result) - else -> result.notImplemented() - } - } - - override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) { - stopProximityEvents() - proximityChannel?.setStreamHandler(null) - proximityChannel = null - channel.setMethodCallHandler(null) - } - - override fun onListen(arguments: Any?, events: EventChannel.EventSink?) { - stopProximityEvents() - proximityEventSink = events - startProximityEvents(events) - } - - override fun onCancel(arguments: Any?) { - stopProximityEvents() - } - - private fun collectSdkInitPlatformFields(result: Result) { - collectAdvertisingIdFields { advertisingIdFields -> - result.success(getSdkInitPlatformFields(advertisingIdFields)) - } - } - - private fun getSdkInitPlatformFields( - advertisingIdFields: Map - ): Map { - val fields = mutableMapOf() - - fields["package_name"] = context.packageName - fields["timezone"] = TimeZone.getDefault().id - fields["brand"] = unknownIfBlank(Build.BRAND) - fields["manufacturer"] = unknownIfBlank(Build.MANUFACTURER) - fields["model"] = unknownIfBlank(Build.MODEL) - fields["abi"] = unknownIfBlank(Build.SUPPORTED_ABIS.firstOrNull()) - fields["build_hardware"] = unknownIfBlank(Build.HARDWARE) - fields["build_product"] = unknownIfBlank(Build.PRODUCT) - fields["build_fingerprint"] = unknownIfBlank(Build.FINGERPRINT) - - fields.putAll(telephonyFields()) - fields.putAll(hardwareCapabilityFields()) - fields.putAll(storageFields()) - fields.putAll(networkStateFields()) - fields.putAll(androidIdFields()) - fields.putAll(advertisingIdFields) - fields.putAll(debugStateFields()) - fields.putAll(sensorFields()) - - fields["install_source"] = installSource() - fields["is_rooted"] = rootedState() - fields["has_baseband"] = hasBaseband() - - return fields - } - - private fun hardwareCapabilityFields(): Map { - val fields = mutableMapOf( - "cpu_cores" to -1, - "device_memory_gb" to -1, - "touch_support" to -1, - "max_touch_points" to -1 - ) - - try { - fields["cpu_cores"] = Runtime.getRuntime().availableProcessors().coerceAtLeast(1) - } catch (_: Throwable) { - } - - try { - val manager = context.getSystemService(Context.ACTIVITY_SERVICE) as? ActivityManager - if (manager != null) { - val memoryInfo = ActivityManager.MemoryInfo() - manager.getMemoryInfo(memoryInfo) - fields["device_memory_gb"] = bytesToGb(memoryInfo.totalMem) - } - } catch (_: Throwable) { - } - - try { - val packageManager = context.packageManager - val hasTouch = packageManager.hasSystemFeature(PackageManager.FEATURE_TOUCHSCREEN) - fields["touch_support"] = if (hasTouch) 1 else 0 - fields["max_touch_points"] = when { - packageManager.hasSystemFeature( - PackageManager.FEATURE_TOUCHSCREEN_MULTITOUCH_JAZZHAND - ) -> 5 - packageManager.hasSystemFeature( - PackageManager.FEATURE_TOUCHSCREEN_MULTITOUCH_DISTINCT - ) -> 2 - packageManager.hasSystemFeature(PackageManager.FEATURE_TOUCHSCREEN_MULTITOUCH) -> 2 - hasTouch -> 1 - else -> 0 - } - } catch (_: Throwable) { - } - - return fields - } - - private fun telephonyFields(): Map { - val fields = mutableMapOf( - "carrier" to "", - "mcc" to "unknown", - "mnc" to "unknown", - "has_sim" to -1 - ) - try { - val manager = context.getSystemService(Context.TELEPHONY_SERVICE) as? TelephonyManager - ?: return fields - fields["carrier"] = manager.simOperatorName ?: "" - val operator = manager.simOperator ?: "" - if (operator.length >= 3) fields["mcc"] = operator.substring(0, 3) - if (operator.length > 3) fields["mnc"] = operator.substring(3) - fields["has_sim"] = when (manager.simState) { - TelephonyManager.SIM_STATE_READY -> 1 - TelephonyManager.SIM_STATE_ABSENT -> 0 - else -> -1 - } - } catch (_: Throwable) { - } - return fields - } - - private fun storageFields(): Map { - return try { - val stat = StatFs(context.applicationInfo.dataDir) - mapOf( - "storage" to bytesToGb(stat.totalBytes), - "available_storage" to bytesToGb(stat.availableBytes) - ) - } catch (_: Throwable) { - mapOf("storage" to -1, "available_storage" to -1) - } - } - - private fun networkStateFields(): Map { - val fields = mutableMapOf( - "vpn_enabled" to -1, - "proxy_enabled" to -1 - ) - try { - val manager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager - val capabilities = manager?.getNetworkCapabilities(manager.activeNetwork) - if (capabilities == null) return fields - fields["vpn_enabled"] = - if (capabilities.hasTransport(NetworkCapabilities.TRANSPORT_VPN)) 1 else 0 - } catch (_: Throwable) { - } - try { - val proxyHost = System.getProperty("http.proxyHost")?.trim().orEmpty() - fields["proxy_enabled"] = if (proxyHost.isNotEmpty()) 1 else 0 - } catch (_: Throwable) { - } - return fields - } - - private fun androidIdFields(): Map { - return try { - val androidId = Settings.Secure.getString( - context.contentResolver, - Settings.Secure.ANDROID_ID - )?.trim().orEmpty() - if (androidId.isNotEmpty()) { - mapOf( - "android_id" to hashIdentifier(androidId), - "android_id_o" to androidId, - "android_id_status" to "available" - ) - } else { - mapOf( - "android_id" to "", - "android_id_o" to "", - "android_id_status" to "unavailable" - ) - } - } catch (_: Throwable) { - mapOf( - "android_id" to "", - "android_id_o" to "", - "android_id_status" to "unknown" - ) - } - } - - private fun collectAdvertisingIdFields(onReady: (Map) -> Unit) { - val clientIdValue = resolveClientIdRaw() - val rawGuid = guid() - val fields = mutableMapOf( - "oaid" to hashIdentifier(clientIdValue), - "oaid_o" to clientIdValue, - "aaid" to "", - "aaid_o" to "", - "guid" to hashIdentifier(rawGuid), - "guid_o" to rawGuid - ) - val lock = Any() - var completed = false - - fun finishAaid(rawValue: String?) { - val aaidValue = rawValue?.trim().orEmpty() - val snapshot = synchronized(lock) { - if (completed) { - null - } else { - completed = true - fields["aaid"] = hashIdentifier(aaidValue) - fields["aaid_o"] = aaidValue - fields.toMap() - } - } - snapshot?.let { mainHandler.post { onReady(it) } } - } - - mainHandler.postDelayed({ - finishAaid("") - }, 1500L) - - try { - DeviceID.getByGms(context, object : IGetter { - override fun onOAIDGetComplete(result: String?) { - finishAaid(result) - } - - override fun onOAIDGetError(error: Exception?) { - finishAaid("") - } - }) - } catch (_: Throwable) { - finishAaid("") - } - } - - private fun guid(): String { - return try { - DeviceIdentifier.getGUID(context)?.trim().orEmpty() - } catch (_: Throwable) { - "" - } - } - - @Suppress("DEPRECATION") - private fun resolveClientIdRaw(): String { - return ClientIdentifierResolver.resolve( - { DeviceIdentifier.getIMEI(context) }, - { DeviceIdentifier.getOAID(context) }, - { DeviceIdentifier.getAndroidID(context) }, - { DeviceIdentifier.getGUID(context) } - ) - } - - private fun deviceId(): String { - return hashIdentifier(resolveClientIdRaw()) - } - - private fun hashIdentifier(value: String?): String { - val text = value?.trim().orEmpty() - if (text.isEmpty()) return "" - return DeviceID.calculateHash(text, "MD5") - } - - private fun registerDeviceIdentifier() { - val application = context.applicationContext as? Application - ?: return - try { - DeviceIdentifier.register(application) - } catch (_: Throwable) {} - } - - private fun debugStateFields(): Map { - val fields = mutableMapOf( - "developer_options_enabled" to -1, - "adb_enabled" to -1, - "usb_debug_enabled" to -1 - ) - try { - val developerOptions = Settings.Global.getInt( - context.contentResolver, - Settings.Global.DEVELOPMENT_SETTINGS_ENABLED, - 0 - ) - fields["developer_options_enabled"] = if (developerOptions == 1) 1 else 0 - } catch (_: Throwable) { - } - try { - val adb = Settings.Global.getInt( - context.contentResolver, - Settings.Global.ADB_ENABLED, - 0 - ) - val enabled = if (adb == 1) 1 else 0 - fields["adb_enabled"] = enabled - fields["usb_debug_enabled"] = enabled - } catch (_: Throwable) { - } - return fields - } - - private fun sensorFields(): Map { - val fields = mutableMapOf( - "sensor_count_supported" to -1, - "has_accelerometer" to -1, - "has_gyroscope" to -1, - "has_magnetometer" to -1, - "has_light_sensor" to -1, - "has_proximity_sensor" to -1 - ) - try { - val manager = context.getSystemService(Context.SENSOR_SERVICE) as? SensorManager - ?: return fields - fields["sensor_count_supported"] = manager.getSensorList(Sensor.TYPE_ALL).size - fields["has_accelerometer"] = - if (manager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER) != null) 1 else 0 - fields["has_gyroscope"] = - if (manager.getDefaultSensor(Sensor.TYPE_GYROSCOPE) != null) 1 else 0 - fields["has_magnetometer"] = - if (manager.getDefaultSensor(Sensor.TYPE_MAGNETIC_FIELD) != null) 1 else 0 - fields["has_light_sensor"] = - if (manager.getDefaultSensor(Sensor.TYPE_LIGHT) != null) 1 else 0 - fields["has_proximity_sensor"] = - if (manager.getDefaultSensor(Sensor.TYPE_PROXIMITY) != null) 1 else 0 - } catch (_: Throwable) { - } - return fields - } - - private fun startProximityEvents(events: EventChannel.EventSink?) { - try { - val manager = context.getSystemService(Context.SENSOR_SERVICE) as? SensorManager - if (manager == null) { - events?.error("proximity_unavailable", "SensorManager unavailable", null) - return - } - val sensor = manager.getDefaultSensor(Sensor.TYPE_PROXIMITY) - if (sensor == null) { - events?.error("proximity_unavailable", "Proximity sensor unavailable", null) - return - } - val listener = object : SensorEventListener { - override fun onSensorChanged(event: SensorEvent) { - val value = event.values.firstOrNull() ?: return - val isNear = value < sensor.maximumRange - mainHandler.post { - proximityEventSink?.success(if (isNear) 1 else 0) - } - } - - override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) { - } - } - proximityListener = listener - manager.registerListener(listener, sensor, SensorManager.SENSOR_DELAY_NORMAL) - } catch (e: Throwable) { - events?.error("proximity_error", e.message ?: "Proximity listener failed", null) - } - } - - private fun stopProximityEvents() { - try { - val manager = context.getSystemService(Context.SENSOR_SERVICE) as? SensorManager - val listener = proximityListener - if (manager != null && listener != null) { - manager.unregisterListener(listener) - } - } catch (_: Throwable) { - } - proximityListener = null - proximityEventSink = null - } - - private fun installSource(): String { - return try { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - context.packageManager.getInstallSourceInfo(context.packageName) - .installingPackageName ?: "" - } else { - @Suppress("DEPRECATION") - context.packageManager.getInstallerPackageName(context.packageName) ?: "" - } - } catch (_: Throwable) { - "" - } - } - - private fun hasBaseband(): Int { - return try { - val radio = Build.getRadioVersion()?.trim().orEmpty() - if (radio.isEmpty() || radio.equals("unknown", ignoreCase = true)) -1 else 1 - } catch (_: Throwable) { - -1 - } - } - - private fun rootedState(): Int { - return try { - if (isRooted()) 1 else 0 - } catch (_: Throwable) { - -1 - } - } - - private fun isRooted(): Boolean { - val tags = Build.TAGS ?: "" - if (tags.contains("test-keys")) return true - val paths = listOf( - "/system/app/Superuser.apk", - "/sbin/su", - "/system/bin/su", - "/system/xbin/su", - "/data/local/xbin/su", - "/data/local/bin/su", - "/system/sd/xbin/su", - "/system/bin/failsafe/su", - "/data/local/su" - ) - return paths.any { File(it).exists() } - } - private fun bytesToGb(bytes: Long): Long { - if (bytes <= 0L) return -1L - return bytes / (1024L * 1024L * 1024L) - } - - private fun unknownIfBlank(value: String?): String { - val text = value?.trim().orEmpty() - return if (text.isEmpty()) "unknown" else text - } -} diff --git a/analytics_sdk/android/src/main/kotlin/com/example/analytics_sdk/ClientIdentifierResolver.kt b/analytics_sdk/android/src/main/kotlin/com/example/analytics_sdk/ClientIdentifierResolver.kt deleted file mode 100644 index 97a1175..0000000 --- a/analytics_sdk/android/src/main/kotlin/com/example/analytics_sdk/ClientIdentifierResolver.kt +++ /dev/null @@ -1,27 +0,0 @@ -package com.example.analytics_sdk - -import java.util.Locale - -internal object ClientIdentifierResolver { - fun resolve(vararg candidates: () -> String?): String { - for (candidate in candidates) { - val value = try { - candidate()?.trim().orEmpty() - } catch (_: Throwable) { - continue - } - if (isValid(value)) return value - } - return "" - } - - private fun isValid(value: String?): Boolean { - val text = value?.trim().orEmpty() - if (text.isEmpty()) return false - when (text.lowercase(Locale.ROOT)) { - "null", "unknown", "unkown" -> return false - } - val withoutHyphens = text.replace("-", "") - return withoutHyphens.isNotEmpty() && !withoutHyphens.all { it == '0' } - } -} diff --git a/analytics_sdk/android/src/test/kotlin/com/example/analytics_sdk/ClientIdentifierResolverTest.kt b/analytics_sdk/android/src/test/kotlin/com/example/analytics_sdk/ClientIdentifierResolverTest.kt deleted file mode 100644 index 93795e8..0000000 --- a/analytics_sdk/android/src/test/kotlin/com/example/analytics_sdk/ClientIdentifierResolverTest.kt +++ /dev/null @@ -1,80 +0,0 @@ -package com.example.analytics_sdk - -import kotlin.test.Test -import kotlin.test.assertEquals - -internal class ClientIdentifierResolverTest { - @Test - fun resolve_returnsFirstValidCandidateAndStopsReading() { - val calls = mutableListOf() - - val result = ClientIdentifierResolver.resolve( - { calls += "imei"; " imei-123 " }, - { calls += "oaid"; "oaid-456" }, - { calls += "android"; "android-789" }, - { calls += "guid"; "guid-000" } - ) - - assertEquals("imei-123", result) - assertEquals(listOf("imei"), calls) - } - - @Test - fun resolve_skipsEverySupportedInvalidSentinel() { - val invalidValues = listOf( - null, - "", - " ", - "null", - "NULL", - "unknown", - "UnKnOwN", - "unkown", - "0", - "000000", - "---", - "000000-000-0000-00000000", - "00000000-0000-0000-0000-000000000000" - ) - - for (invalidValue in invalidValues) { - val result = ClientIdentifierResolver.resolve( - { invalidValue }, - { "android-id" } - ) - assertEquals("android-id", result, "invalidValue=$invalidValue") - } - } - - @Test - fun resolve_preservesValidHyphenatedIdentifier() { - val result = ClientIdentifierResolver.resolve( - { "00000000-0000-0000-0000-00000000000a" }, - { "fallback" } - ) - - assertEquals("00000000-0000-0000-0000-00000000000a", result) - } - - @Test - fun resolve_skipsThrowingCandidate() { - val result = ClientIdentifierResolver.resolve( - { throw IllegalStateException("imei unavailable") }, - { "oaid-123" } - ) - - assertEquals("oaid-123", result) - } - - @Test - fun resolve_returnsEmptyWhenEveryCandidateIsInvalid() { - val result = ClientIdentifierResolver.resolve( - { null }, - { "unknown" }, - { "0000-0000" }, - { "" } - ) - - assertEquals("", result) - } -} diff --git a/analytics_sdk/android/src/test/kotlin/com/example/data_plus_sdk/DataPlusSdkPluginTest.kt b/analytics_sdk/android/src/test/kotlin/com/example/data_plus_sdk/DataPlusSdkPluginTest.kt deleted file mode 100644 index 46c6cae..0000000 --- a/analytics_sdk/android/src/test/kotlin/com/example/data_plus_sdk/DataPlusSdkPluginTest.kt +++ /dev/null @@ -1,27 +0,0 @@ -package com.example.analytics_sdk - -import io.flutter.plugin.common.MethodCall -import io.flutter.plugin.common.MethodChannel -import kotlin.test.Test -import org.mockito.Mockito - -/* - * This demonstrates a simple unit test of the Kotlin portion of this plugin's implementation. - * - * Once you have built the plugin's example app, you can run these tests from the command - * line by running `./gradlew testDebugUnitTest` in the `example/android/` directory, or - * you can run them directly from IDEs that support JUnit such as Android Studio. - */ - -internal class AnalyticsSdkPluginTest { - @Test - fun onMethodCall_getPlatformVersion_returnsExpectedValue() { - val plugin = AnalyticsSdkPlugin() - - val call = MethodCall("getPlatformVersion", null) - val mockResult: MethodChannel.Result = Mockito.mock(MethodChannel.Result::class.java) - plugin.onMethodCall(call, mockResult) - - Mockito.verify(mockResult).success("Android " + android.os.Build.VERSION.RELEASE) - } -} diff --git a/analytics_sdk/ios/.gitignore b/analytics_sdk/ios/.gitignore deleted file mode 100644 index 034771f..0000000 --- a/analytics_sdk/ios/.gitignore +++ /dev/null @@ -1,38 +0,0 @@ -.idea/ -.vagrant/ -.sconsign.dblite -.svn/ - -.DS_Store -*.swp -profile - -DerivedData/ -build/ -GeneratedPluginRegistrant.h -GeneratedPluginRegistrant.m - -.generated/ - -*.pbxuser -*.mode1v3 -*.mode2v3 -*.perspectivev3 - -!default.pbxuser -!default.mode1v3 -!default.mode2v3 -!default.perspectivev3 - -xcuserdata - -*.moved-aside - -*.pyc -*sync/ -Icon? -.tags* - -/Flutter/Generated.xcconfig -/Flutter/ephemeral/ -/Flutter/flutter_export_environment.sh diff --git a/analytics_sdk/ios/Assets/.gitkeep b/analytics_sdk/ios/Assets/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/analytics_sdk/ios/Classes/DataPlusSdkPlugin.swift b/analytics_sdk/ios/Classes/DataPlusSdkPlugin.swift deleted file mode 100644 index dc37144..0000000 --- a/analytics_sdk/ios/Classes/DataPlusSdkPlugin.swift +++ /dev/null @@ -1,320 +0,0 @@ -import Flutter -import UIKit -import AdSupport -import AppTrackingTransparency -import CFNetwork -import CoreTelephony -import Darwin - -public class AnalyticsSdkPlugin: NSObject, FlutterPlugin, FlutterStreamHandler { - private var proximityEventSink: FlutterEventSink? - private var proximityMonitoringWasEnabled = false - - public static func register(with registrar: FlutterPluginRegistrar) { - let channel = FlutterMethodChannel(name: "analytics_sdk", binaryMessenger: registrar.messenger()) - let instance = AnalyticsSdkPlugin() - registrar.addMethodCallDelegate(instance, channel: channel) - let proximityChannel = FlutterEventChannel( - name: "analytics_sdk/proximity", - binaryMessenger: registrar.messenger() - ) - proximityChannel.setStreamHandler(instance) - } - - public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { - switch call.method { - case "getPlatformVersion": - result("iOS " + UIDevice.current.systemVersion) - case "getDeviceId": - result(UIDevice.current.identifierForVendor?.uuidString ?? "") - case "getSdkInitPlatformFields": - result(sdkInitPlatformFields()) - default: - result(FlutterMethodNotImplemented) - } - } - - public func onListen( - withArguments arguments: Any?, - eventSink events: @escaping FlutterEventSink - ) -> FlutterError? { - _ = onCancel(withArguments: nil) - proximityEventSink = events - proximityMonitoringWasEnabled = UIDevice.current.isProximityMonitoringEnabled - UIDevice.current.isProximityMonitoringEnabled = true - guard UIDevice.current.isProximityMonitoringEnabled else { - proximityEventSink = nil - return FlutterError( - code: "proximity_unavailable", - message: "Proximity monitoring unavailable", - details: nil - ) - } - NotificationCenter.default.addObserver( - self, - selector: #selector(proximityStateDidChange), - name: UIDevice.proximityStateDidChangeNotification, - object: UIDevice.current - ) - events(UIDevice.current.proximityState ? 1 : 0) - return nil - } - - public func onCancel(withArguments arguments: Any?) -> FlutterError? { - NotificationCenter.default.removeObserver( - self, - name: UIDevice.proximityStateDidChangeNotification, - object: UIDevice.current - ) - UIDevice.current.isProximityMonitoringEnabled = proximityMonitoringWasEnabled - proximityEventSink = nil - return nil - } - - @objc private func proximityStateDidChange() { - proximityEventSink?(UIDevice.current.proximityState ? 1 : 0) - } - - private func sdkInitPlatformFields() -> [String: Any] { - var fields: [String: Any] = [ - "language": normalizedLocaleIdentifier(), - "timezone": TimeZone.current.identifier, - "bundle_id": Bundle.main.bundleIdentifier ?? "", - "ios_device_model_raw": rawDeviceModel(), - "idfv": UIDevice.current.identifierForVendor?.uuidString ?? "", - "is_jailbroken": isJailbroken() ? 1 : 0, - "cpu_cores": ProcessInfo.processInfo.activeProcessorCount, - "device_memory_gb": bytesToGb(Int64(ProcessInfo.processInfo.physicalMemory)), - "touch_support": 1 - ] - - fields.merge(screenFields()) { _, new in new } - fields.merge(storageFields()) { _, new in new } - fields.merge(batteryFields()) { _, new in new } - fields.merge(telephonyFields()) { _, new in new } - fields.merge(advertisingFields()) { _, new in new } - fields.merge(networkStateFields()) { _, new in new } - - return fields - } - - private func normalizedLocaleIdentifier() -> String { - return Locale.current.identifier.replacingOccurrences(of: "_", with: "-") - } - - private func rawDeviceModel() -> String { - var systemInfo = utsname() - uname(&systemInfo) - let mirror = Mirror(reflecting: systemInfo.machine) - let identifier = mirror.children.reduce(into: "") { value, element in - guard let byte = element.value as? Int8, byte != 0 else { return } - value.append(String(UnicodeScalar(UInt8(byte)))) - } - return identifier - } - - private func screenFields() -> [String: Any] { - let screen = UIScreen.main - let nativeBounds = screen.nativeBounds - let scale = screen.scale - return [ - "screen_width": Int(nativeBounds.width.rounded()), - "screen_height": Int(nativeBounds.height.rounded()), - "screen_dpi": Int((scale * 160).rounded()), - "device_pixel_ratio": scale, - "orientation": interfaceOrientation() - ] - } - - private func interfaceOrientation() -> String { - if #available(iOS 13.0, *) { - let orientation = UIApplication.shared.connectedScenes - .compactMap { $0 as? UIWindowScene } - .first? - .interfaceOrientation - if orientation?.isLandscape == true { - return "landscape" - } - if orientation?.isPortrait == true { - return "portrait" - } - } else { - let orientation = UIApplication.shared.statusBarOrientation - if orientation.isLandscape { - return "landscape" - } - if orientation.isPortrait { - return "portrait" - } - } - return screenFallbackOrientation() - } - - private func screenFallbackOrientation() -> String { - let bounds = UIScreen.main.nativeBounds - return bounds.width >= bounds.height ? "landscape" : "portrait" - } - - private func storageFields() -> [String: Any] { - do { - let attributes = try FileManager.default.attributesOfFileSystem(forPath: NSHomeDirectory()) - let total = (attributes[.systemSize] as? NSNumber)?.int64Value ?? -1 - let free = (attributes[.systemFreeSize] as? NSNumber)?.int64Value ?? -1 - return [ - "storage": bytesToGb(total), - "available_storage": bytesToGb(free) - ] - } catch { - return ["storage": -1, "available_storage": -1] - } - } - - private func batteryFields() -> [String: Any] { - UIDevice.current.isBatteryMonitoringEnabled = true - let level = UIDevice.current.batteryLevel - let batteryLevel = level >= 0 ? Int((level * 100).rounded()) : -1 - let charging: Int - switch UIDevice.current.batteryState { - case .charging, .full: - charging = 1 - case .unplugged: - charging = 0 - default: - charging = -1 - } - return ["battery_level": batteryLevel, "is_charging": charging] - } - - private func telephonyFields() -> [String: Any] { - var fields: [String: Any] = [ - "carrier": "", - "mcc": "unknown", - "mnc": "unknown", - "has_sim": -1 - ] - - let info = CTTelephonyNetworkInfo() - var carrier: CTCarrier? - if #available(iOS 12.0, *) { - carrier = info.serviceSubscriberCellularProviders?.values.first - } else { - carrier = info.subscriberCellularProvider - } - - guard let carrier = carrier else { - fields["has_sim"] = 0 - return fields - } - - fields["carrier"] = carrier.carrierName ?? "" - fields["mcc"] = carrier.mobileCountryCode ?? "unknown" - fields["mnc"] = carrier.mobileNetworkCode ?? "unknown" - fields["has_sim"] = 1 - return fields - } - - private func advertisingFields() -> [String: Any] { - if #available(iOS 14, *) { - switch ATTrackingManager.trackingAuthorizationStatus { - case .authorized: - return [ - "idfa": ASIdentifierManager.shared().advertisingIdentifier.uuidString, - "att_status": "authorized", - "lat": 0 - ] - case .denied: - return ["idfa": "", "att_status": "denied", "lat": 1] - case .restricted: - return ["idfa": "", "att_status": "restricted", "lat": 1] - case .notDetermined: - return ["idfa": "", "att_status": "not_determined", "lat": -1] - @unknown default: - return ["idfa": "", "att_status": "unknown", "lat": -1] - } - } else { - let manager = ASIdentifierManager.shared() - let trackingEnabled = manager.isAdvertisingTrackingEnabled - return [ - "idfa": trackingEnabled ? manager.advertisingIdentifier.uuidString : "", - "att_status": "unknown", - "lat": trackingEnabled ? 0 : 1 - ] - } - } - - private func networkStateFields() -> [String: Any] { - return [ - "vpn_enabled": isVpnEnabled() ? 1 : 0, - "proxy_enabled": isProxyEnabled() - ] - } - - private func isProxyEnabled() -> Int { - guard - let settings = CFNetworkCopySystemProxySettings()?.takeRetainedValue() as? [String: Any] - else { - return -1 - } - - let httpEnabled = (settings[kCFNetworkProxiesHTTPEnable as String] as? NSNumber)?.intValue == 1 - let httpsEnabled = (settings["HTTPSEnable"] as? NSNumber)?.intValue == 1 - return httpEnabled || httpsEnabled ? 1 : 0 - } - - private func isVpnEnabled() -> Bool { - var addresses: UnsafeMutablePointer? - guard getifaddrs(&addresses) == 0, let firstAddress = addresses else { - return false - } - defer { freeifaddrs(firstAddress) } - - var pointer: UnsafeMutablePointer? = firstAddress - while pointer != nil { - if let name = pointer?.pointee.ifa_name { - let interface = String(cString: name) - if interface.hasPrefix("utun") || - interface.hasPrefix("ppp") || - interface.hasPrefix("ipsec") || - interface.hasPrefix("tap") || - interface.hasPrefix("tun") { - return true - } - } - pointer = pointer?.pointee.ifa_next - } - return false - } - - private func isJailbroken() -> Bool { - #if targetEnvironment(simulator) - return false - #else - let paths = [ - "/Applications/Cydia.app", - "/Library/MobileSubstrate/MobileSubstrate.dylib", - "/bin/bash", - "/usr/sbin/sshd", - "/etc/apt", - "/private/var/lib/apt/" - ] - if paths.contains(where: { FileManager.default.fileExists(atPath: $0) }) { - return true - } - do { - let path = "/private/analytics_sdk_jailbreak_test.txt" - try "test".write(toFile: path, atomically: true, encoding: .utf8) - try FileManager.default.removeItem(atPath: path) - return true - } catch { - return false - } - #endif - } - - private func bytesToGb(_ bytes: Int64) -> Int64 { - if bytes <= 0 { - return -1 - } - return bytes / (1024 * 1024 * 1024) - } -} diff --git a/analytics_sdk/ios/analytics_sdk.podspec b/analytics_sdk/ios/analytics_sdk.podspec deleted file mode 100644 index 3bba147..0000000 --- a/analytics_sdk/ios/analytics_sdk.podspec +++ /dev/null @@ -1,23 +0,0 @@ -# -# To learn more about a Podspec see http://guides.cocoapods.org/syntax/podspec.html. -# Run `pod lib lint analytics_sdk.podspec` to validate before publishing. -# -Pod::Spec.new do |s| - s.name = 'analytics_sdk' - s.version = '0.0.1' - s.summary = 'A new Flutter project.' - s.description = <<-DESC -A new Flutter project. - DESC - s.homepage = 'http://example.com' - s.license = { :file => '../LICENSE' } - s.author = { 'Your Company' => 'email@example.com' } - s.source = { :path => '.' } - s.source_files = 'Classes/**/*' - s.dependency 'Flutter' - s.platform = :ios, '12.0' - - # Flutter.framework does not contain a i386 slice. - s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES', 'EXCLUDED_ARCHS[sdk=iphonesimulator*]' => 'i386' } - s.swift_version = '5.0' -end diff --git a/analytics_sdk/lib/analytics_sdk.dart b/analytics_sdk/lib/analytics_sdk.dart deleted file mode 100644 index fa17738..0000000 --- a/analytics_sdk/lib/analytics_sdk.dart +++ /dev/null @@ -1,1589 +0,0 @@ -import 'dart:async'; -import 'dart:convert'; - -export 'widget/analytics_debug_banner.dart'; -export 'entity/analytics_tab.dart'; -export 'entity/ad_sdk_events.dart'; -export 'entity/app_page_item_event.dart'; -export 'entity/app_lifecycle_event.dart'; -export 'entity/app_session_summary_event.dart'; -export 'entity/network_state_change_event.dart'; -export 'extension/tab_analytics_extension.dart'; -export 'enum/page_route_enum.dart'; - -import 'package:analytics_sdk/config/api_config.dart'; -import 'package:analytics_sdk/config/sdk_config.dart'; -import 'package:analytics_sdk/const/event_type.dart'; -import 'package:analytics_sdk/entity/ad_sdk_events.dart'; -import 'package:analytics_sdk/entity/ad_impression_event.dart'; -import 'package:analytics_sdk/entity/app_lifecycle_event.dart'; -import 'package:analytics_sdk/entity/app_session_summary_event.dart'; -import 'package:analytics_sdk/manager/sdk_context.dart'; -import 'package:analytics_sdk/enum/user_type_enum.dart'; -import 'package:analytics_sdk/entity/sdk_init_event.dart'; -import 'package:analytics_sdk/manager/ad_impression_manager.dart'; -import 'package:analytics_sdk/manager/event_processor.dart'; -import 'package:analytics_sdk/manager/user_manager.dart'; -import 'package:analytics_sdk/manager/domain_manager.dart'; -import 'package:analytics_sdk/manager/event_type_config_manager.dart'; -import 'package:analytics_sdk/manager/session_manager.dart'; -import 'package:analytics_sdk/manager/sdk_activation_store.dart'; -import 'package:analytics_sdk/manager/sdk_init_payload_builder.dart'; -import 'package:analytics_sdk/manager/sdk_init_platform_fields.dart'; -import 'package:analytics_sdk/manager/session_behavior_manager.dart'; -import 'package:analytics_sdk/manager/session_behavior_monitor.dart'; -import 'package:analytics_sdk/manager/session_metrics_manager.dart'; -import 'package:analytics_sdk/manager/session_sensor_sampler.dart'; -import 'package:analytics_sdk/observer/app_lifecycle_observer.dart'; -import 'package:analytics_sdk/observer/app_lifecycle_web_unload_stub.dart' - if (dart.library.html) 'package:analytics_sdk/observer/app_lifecycle_web_unload_web.dart'; -import 'package:analytics_sdk/entity/app_page_view_event.dart'; -import 'package:analytics_sdk/entity/navigation_event.dart'; -import 'package:analytics_sdk/manager/page_name_manager.dart'; -import 'package:analytics_sdk/manager/page_observer_registration_guard.dart'; -import 'package:analytics_sdk/observer/page_lifecycle_observer.dart'; -import 'package:analytics_sdk/utils/aes_gcm_util.dart'; -import 'package:analytics_sdk/utils/device_id_util.dart'; -import 'package:analytics_sdk/utils/device_fingerprint_util.dart'; -import 'package:analytics_sdk/utils/device_info_util.dart'; -import 'package:analytics_sdk/utils/environment_info_util.dart'; -import 'package:analytics_sdk/utils/event_validator.dart'; -import 'package:analytics_sdk/utils/logger.dart'; -import 'package:analytics_sdk/utils/widget_bridge.dart'; -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; - -class AnalyticsSdk { - static final AnalyticsSdk instance = AnalyticsSdk._internal(); - - factory AnalyticsSdk() => instance; - - AnalyticsSdk._internal(); - - final EventProcessor _eventProcessor = EventProcessor(); - - /// 单调递增的导航序号,用于快速连续切换时的去重。 - /// 每次 [trackNavigation] 调用自增;回调触发时序号不匹配则丢弃。 - /// 不在 init() 重置:计数器单调递增,旧回调 generation 永远小于新调用,天然隔离。 - int _navigationGeneration = 0; - - /// 最近一次成功上报的导航 pageKey,用于拦截同 Tab 重复点击。 - String _lastReportedNavigationKey = ''; - - PageLifecycleObserver? _pageObserver; - PageLifecycleObserver? _sessionObserver; - String Function(String? actualRoute)? _realRouteResolver; - final PageObserverRegistrationGuard _pageObserverRegistrationGuard = - PageObserverRegistrationGuard(); - - PageLifecycleObserver _createPageObserver({ - required bool autoTrackPageViews, - }) { - return PageLifecycleObserver( - track: track, - getUserType: () => AnalyticsSdk.userTypeProvider(), - onPageExit: (pageKey) => AdImpressionManager.instance.clearPage(pageKey), - resolveRealRoute: _realRouteResolver, - autoTrackPageViews: autoTrackPageViews, - ); - } - - static bool _enableDebugBanner = false; - - /// 是否启用调试条(供 AnalyticsDebugBanner 读取) - static bool get enableDebugBanner => _enableDebugBanner; - - /// 防止 init() 并发调用(异步挂起期间重复调用时直接跳过) - bool _isInitializing = false; - - /// 当前进程内 sdk_init 只上报一次,匹配冷启动/Web 首次加载语义。 - bool _sdkInitReported = false; - - String _lastSessionSummarySid = ''; - - AppLifecycleWebUnloadDisposer? _webLifecycleUnloadDisposer; - - // ==================== 初始化 ==================== - - /// 初始化 SDK。 - /// - /// 本方法永远不会抛出异常,即使初始化失败也不影响应用运行。 - /// - /// **参数说明** - /// - /// - [appId]:应用 ID(必填) - /// - [channel]:渠道标识,可在初始化后通过 [setChannel] 更新 - /// - [uid]:用户 ID,未登录时可传空,登录后通过 [setUserIdAndType] 或 [setUid] 更新 - /// - [deviceId]:已废弃,SDK 不再信任外部传入 device_id,会内部生成 - /// - [appVersion]:应用版本号,格式 `1.0.0`,由接入方传入,可不传 - /// - [encryptedConfig]:加密配置字符串(Base64 编码 AES 密文),解密后为 - /// `{"domainList":["https://..."],"eventList":["event_type",...]}` 格式,可选 - /// - /// device、deviceBrand、deviceModel、userAgent、systemName、systemVersion - /// 由 SDK 内部通过 device_info_plus 自动检测,无需外部传入。 - Future init({ - required String appId, - required String? encryptedConfig, - String? channel, - String? uid, - @Deprecated('device_id 由 SDK 内部生成,外部传入值会被忽略') String? deviceId, - String? appVersion, - bool enableDebugBanner = false, - }) async { - if (_isInitializing) { - Logger.analyticsSdk('init() 正在进行中,跳过重复调用'); - return; - } - final initStartedAt = DateTime.now(); - _isInitializing = true; - _pageObserverRegistrationGuard.reset(); - try { - _enableDebugBanner = !kReleaseMode && enableDebugBanner; - WidgetBridge.register( - track: track, - getUserType: userTypeProvider, - enableDebugBanner: _enableDebugBanner, - getDebugSteps: getDebugSteps, - ); - - _eventProcessor.stopAutoUploadTimer(); - _eventProcessor.resetState(); - _lastSessionSummarySid = ''; - SessionMetricsManager.instance.reset(); - SessionBehaviorManager.instance.reset(); - SessionSensorSampler.instance.reset(); - SessionBehaviorMonitor.instance.start(); - - await DeviceInfoUtil.initialize(); - await EnvironmentInfoUtil.initialize(); - - final device = DeviceInfoUtil.deviceType; - final deviceBrand = DeviceInfoUtil.deviceBrand; - final deviceModel = DeviceInfoUtil.deviceModel; - final userAgent = DeviceInfoUtil.userAgent; - final systemName = DeviceInfoUtil.systemName; - final systemVersion = DeviceInfoUtil.systemVersion; - final sdkDeviceId = await DeviceIdUtil.getDeviceId(); - - SdkContext.configure( - appId: appId, - channel: channel, - uid: uid, - appVersion: appVersion, - device: device, - deviceId: sdkDeviceId, - deviceBrand: deviceBrand, - deviceModel: deviceModel, - userAgent: userAgent, - systemName: systemName, - systemVersion: systemVersion, - environmentFields: EnvironmentInfoUtil.fields, - ); - EnvironmentInfoUtil.startMonitoring(track: track); - - Logger.analyticsSdk('响应解密已启用(使用内置密钥)'); - - // re-init 时先重置会话,确保新一轮初始化生成新 session - SessionManager.instance.reset(); - _lastReportedNavigationKey = ''; - // 注入新 session 回调:新 session 开始时清空广告去重状态 - SessionManager.instance.onNewSession = () { - AdImpressionManager.instance.clear(); - SessionMetricsManager.instance.reset( - sid: SessionManager.instance.currentSessionId ?? '', - ); - final pageLifecycleObserver = _pageObserver ?? _sessionObserver; - final sessionStartedAt = SessionManager.instance.sessionStartTime; - if (pageLifecycleObserver != null && sessionStartedAt != null) { - pageLifecycleObserver.startCurrentPageStayForNewSession( - sessionStartedAt, - ); - } - SessionBehaviorManager.instance.reset( - sid: SessionManager.instance.currentSessionId ?? '', - ); - SessionSensorSampler.instance.reset( - sid: SessionManager.instance.currentSessionId ?? '', - ); - }; - SessionManager.instance.onSessionEnding = - (reason) => _trackSessionSummary(sessionEndReason: reason); - SessionManager.instance.initialize(); - // 注入 SID 提供者,避免 entity 层直接依赖 SessionManager - SdkContext.sidProvider = () { - return SessionManager.instance.getSessionId(); - }; - AppLifecycleObserver.instance.initialize(track: track); - - // 初始化设备指纹 - _prepareDeviceFingerprint(); - - await _applyEncryptedConfig( - encryptedConfig: encryptedConfig, - source: 'init', - ); - - try { - await _eventProcessor.initPersistence(); - } catch (e) { - Logger.analyticsSdk('持久化初始化失败,继续运行: $e'); - } - - try { - await _eventProcessor.loadCachedEvents(); - } catch (e) { - Logger.analyticsSdk('缓存加载失败,继续运行: $e'); - } - - await _trackSdkInit(initStartedAt: initStartedAt); - - _eventProcessor.startAutoUploadTimer(); - _schedulePageObserverRegistrationCheck(); - } catch (e) { - Logger.analyticsSdk('初始化异常: $e'); - } finally { - _isInitializing = false; - } - } - - void _schedulePageObserverRegistrationCheck() { - try { - WidgetsBinding.instance.addPostFrameCallback((_) { - _pageObserverRegistrationGuard.check( - pageObserverAttached: _pageObserver?.navigator != null, - sessionObserverAttached: _sessionObserver?.navigator != null, - ); - }); - // init() 发生在首帧之后时,主动请求一帧,避免接入检查长期不执行。 - WidgetsBinding.instance.ensureVisualUpdate(); - } catch (e) { - Logger.analyticsSdk('页面 Observer 接入检查注册失败: $e'); - } - } - - // ==================== UID / Channel 设置 ==================== - - /// 更新用户 ID 和用户类型(登录、切换账号时调用)。 - /// - /// [userId] 为空时表示未登录状态。 - /// 本方法永远不会抛出异常。 - static void setUserIdAndType( - {String userId = '', UserTypeEnum? userTypeEnum}) { - try { - SdkContext.setUid(userId); - UserManager.instance.updateUserType(userTypeEnum == null - ? UserTypeEnum.normal.label - : userTypeEnum.label); - Logger.analyticsSdk('用户ID已更新为: $userId'); - } catch (e) { - Logger.analyticsSdk('设置用户信息失败: $e'); - } - } - - /// 单独更新用户 ID(不影响用户类型)。 - /// - /// 适用于已知 userType 无变化、只需更新 uid 的场景(如静默登录)。 - /// 本方法永远不会抛出异常。 - static void setUid(String uid) { - try { - SdkContext.setUid(uid); - Logger.analyticsSdk('uid 已更新为: $uid'); - } catch (e) { - Logger.analyticsSdk('setUid 失败: $e'); - } - } - - /// 更新渠道标识(渠道变更时调用,后续事件自动带上最新值)。 - /// - /// 本方法永远不会抛出异常。 - static void setChannel(String channel) { - try { - SdkContext.setChannel(channel); - Logger.analyticsSdk('channel 已更新为: $channel'); - } catch (e) { - Logger.analyticsSdk('setChannel 失败: $e'); - } - } - - /// 登出:清空 uid 和 userType。 - /// - /// 本方法永远不会抛出异常。 - static void logoutUser() { - try { - SdkContext.setUid(''); - UserManager.instance.logout(); - Logger.analyticsSdk('用户已登出'); - } catch (e) { - Logger.analyticsSdk('用户登出失败: $e'); - } - } - - // ==================== 设备公共字段 ==================== - - /// 获取当前设备公共字段(供产品方在服务端上报事件时复用)。 - /// - /// 返回字段:sid、device、device_id、device_brand、device_model、 - /// user_agent、system_name、system_version、device_fingerprint、fp_version。 - /// 未配置的字段返回空字符串。 - /// - /// `sid` 为客户端 SDK 自动生成的会话 ID;服务端上报事件时应将其一并回传。 - /// - /// 本方法永远不会抛出异常。 - static Map getDeviceCommonFields() { - try { - return SdkContext.getDeviceCommonFields(); - } catch (e) { - Logger.analyticsSdk('getDeviceCommonFields 失败: $e'); - return {}; - } - } - - /// 获取 SDK 当前全部公共参数,或按 [keys] 指定只返回部分参数。 - /// - /// - 不传 [keys](或传 null):返回全部参数 - /// - 传入 key 列表:只返回命中的 key,不存在的 key 会被忽略 - /// - /// 可用 key:`app_id` `channel` `uid` `user_type` `sid` - /// `device` `device_id` `device_brand` `device_model` - /// `user_agent` `system_name` `system_version` - /// `device_fingerprint` `fp_version` `sdk_version` `app_version` - /// - /// `sid` 为客户端 SDK 自动生成的会话ID,服务端上报事件时需将其一并回传, - /// 以确保服务端事件与客户端事件归属同一会话。 - /// - /// 本方法永远不会抛出异常。 - static Map getParams([List? keys]) { - try { - final all = { - 'app_id': SdkContext.appId ?? '', - 'channel': SdkContext.channel ?? '', - 'uid': SdkContext.uid ?? '', - 'user_type': UserManager.instance.userType, - 'sid': SessionManager.instance.currentSessionId ?? '', - 'sdk_version': SdkContext.kSdkVersion, - 'app_version': SdkContext.appVersion ?? SdkContext.kDefaultAppVersion, - ...SdkContext.getDeviceCommonFields(), - }; - if (keys == null || keys.isEmpty) return all; - return { - for (final k in keys) - if (all.containsKey(k)) k: all[k] - }; - } catch (e) { - Logger.analyticsSdk('getParams 失败: $e'); - return {}; - } - } - - // ==================== 上报 ==================== - - /// 自动页面曝光模式的生命周期观察者。 - /// - /// 注册后既记录 Session 页面停留,也自动追踪所有命名路由的 - /// [AppPageViewEvent]。与 [sessionObserver] 必须二选一,不能同时注册。 - /// - /// ```dart - /// MaterialApp( - /// navigatorObservers: [AnalyticsSdk.instance.pageObserver], - /// ) - /// ``` - NavigatorObserver get pageObserver => - _pageObserver ??= _createPageObserver(autoTrackPageViews: true); - - /// 手动页面曝光模式的生命周期观察者。 - /// - /// 只记录 Session 页面停留,不自动上报 [AppPageViewEvent]。如果业务已手动 - /// 上报页面曝光,应在 [MaterialApp.navigatorObservers] 中注册此 observer。 - /// 与 [pageObserver] 必须二选一,不能同时注册。 - /// - /// ```dart - /// MaterialApp( - /// navigatorObservers: [AnalyticsSdk.instance.sessionObserver], - /// ) - /// ``` - NavigatorObserver get sessionObserver => - _sessionObserver ??= _createPageObserver(autoTrackPageViews: false); - - /// 注入路由解析回调,将实际路径(如 `/video/123`)转换为模板路径(`/video/:id`)。 - /// - /// 与 [PageNameMapper.addMappings] 配合使用,实现带参数路由的稳定 pageKey 归因。 - /// GoRouter 场景通常不需要注入(`settings.name` 即为完整路径); - /// GetX 等路由框架可能需要注入以获取模板路径。 - /// - /// 传入 `null` 可清除已注入的回调,恢复默认行为(直接使用 `settings.name`)。 - void setRealRouteResolver(String Function(String? actualRoute)? resolver) { - _realRouteResolver = resolver; - _pageObserver?.setRealRouteResolver(resolver); - _sessionObserver?.setRealRouteResolver(resolver); - } - - /// 手动上报导航事件,在用户点击导航菜单时调用。 - /// - /// **适用场景**:底部 Tab、顶部 Tab、侧边抽屉等导航菜单点击。 - /// 内部同时调用 [PageLifecycleObserver.recordNavigation],更新全局 currentPageKey - /// 及栈顶 effectivePageKey,确保后续 push 的 referrer 和 page_click 归属均正确。 - /// - /// **已接入 [BottomNavigationBar.withAnalytics] / [TabBar.withAnalytics] 时无需调用**, - /// 包装器内部已处理;仅在完全手动管理导航时使用。 - /// - /// 同时上报 [AppPageViewEvent] 和 [NavigationEvent],通过 - /// [WidgetsBinding.addPostFrameCallback] 在渲染完成后触发; - /// [pageLoadTime] 反映从调用到渲染完成的实际耗时。 - /// 快速连续切换时只上报最后一次目标,中间状态自动丢弃。 - /// - /// - [pageKey]:导航目标的唯一标识,建议使用 [AppPageEnum](如 `AppPageEnum.video.key`), - /// 也可传入自定义字符串;与 app_page_view 的 page_key 保持一致 - /// - [pageName]:导航目标的展示名称,建议使用 [AppPageEnum](如 `AppPageEnum.video.name`) - /// - /// 本方法永远不会抛出异常。 - void trackNavigation({ - required String pageKey, - required String pageName, - }) { - try { - PageNameMapper.addMapping(pageKey, pageName); - final referrerKey = PageLifecycleObserver.currentPageKey; - final referrerName = PageNameMapper.getPageName(referrerKey); - final tapTime = DateTime.now(); - PageLifecycleObserver.recordNavigation(pageKey); - final generation = ++_navigationGeneration; - - bool callbackExecuted = false; - Timer? loadTimer; - - void fireEvents() { - if (callbackExecuted) return; - callbackExecuted = true; - loadTimer?.cancel(); - if (generation != _navigationGeneration) return; - if (pageKey == _lastReportedNavigationKey) return; - _lastReportedNavigationKey = pageKey; - try { - final pageLoadTime = - DateTime.now().difference(tapTime).inMilliseconds; - track(AppPageViewEvent( - userType: AnalyticsSdk.userTypeProvider(), - pageKey: pageKey, - pageName: pageName, - referrerPageKey: referrerKey, - referrerPageName: referrerName, - currentPageKey: pageKey, - currentPageName: pageName, - pageLoadTime: pageLoadTime, - pageEnterTs: tapTime.millisecondsSinceEpoch ~/ 1000, - )); - track(NavigationEvent( - navigationKey: pageKey, - navigationName: pageName, - )); - Logger.analyticsSdk( - 'trackNavigation() 上报成功: pageKey=$pageKey, referrer=$referrerKey, pageLoadTime=${pageLoadTime}ms', - ); - } catch (e) { - Logger.analyticsSdk('trackNavigation() post-frame 上报异常,已安全处理: $e'); - } - } - - try { - WidgetsBinding.instance.addPostFrameCallback((_) => fireEvents()); - loadTimer = Timer(SdkConfig.pageLoadTimeout, fireEvents); - } catch (e) { - Logger.analyticsSdk('trackNavigation() WidgetsBinding 未初始化,降级同步上报: $e'); - fireEvents(); - } - } catch (e) { - Logger.analyticsSdk('trackNavigation() 异常,已安全处理: $e'); - } - } - - /// 手动同步当前页面状态,专用于 PageView + AutomaticKeepAliveClientMixin 场景。 - /// - /// [PageView] 内 Tab 切换不经过路由系统,[PageLifecycleObserver] 无法感知; - /// 在 `PageView.onPageChanged` 中调用此方法,确保后续 page_click 的 `page_key` - /// 归属到实际可见的子页面,而非父路由 key。 - /// - /// 内部调用 [PageLifecycleObserver.recordNavigation],同时更新 - /// [PageLifecycleObserver.currentPageKey] 和栈顶的 effectivePageKey; - /// pop 返回时会自动恢复到最后一次调用的 pageKey。 - /// - /// - [pageKey]:当前可见子页面的唯一标识,建议使用 [AppPageEnum](如 `AppPageEnum.videoList.key`), - /// 也可传入自定义字符串 - /// - [pageName]:当前可见子页面的展示名称(仅用于日志,不上报事件) - /// - /// 本方法永远不会抛出异常。 - void updateCurrentPage({ - required String pageKey, - required String pageName, - }) { - try { - PageLifecycleObserver.recordNavigation(pageKey); - Logger.analyticsSdk( - 'updateCurrentPage: pageKey=$pageKey, pageName=$pageName'); - } catch (e) { - Logger.analyticsSdk('updateCurrentPage() 异常,已安全处理: $e'); - } - } - - /// 手动上报任意事件(事件对象必须有 toJson() 方法)。 - /// - /// 本方法永远不会抛出异常,即使事件无效也不影响应用运行。 - void track(dynamic event) { - try { - unawaited(EnvironmentInfoUtil.refreshBatteryLevelIfNeeded()); - if (event == null) { - Logger.analyticsSdk('事件为 null,跳过'); - return; - } - - final adResult = _TrackPipeline.deduplicateAdImpression(event); - if (adResult == null) return; - event = adResult.processedEvent; - - final json = _TrackPipeline.serializeEvent(event); - if (json == null) return; - - final validated = _TrackPipeline.validateEvent(json); - if (validated == null) return; - - if (!_TrackPipeline.checkEventSize(validated)) return; - if (!_TrackPipeline.isEventTypeEnabled(validated)) return; - - final accepted = _eventProcessor.enqueueEvent(validated); - - if (accepted && - adResult.adsToMark != null && - adResult.adsToMark!.isNotEmpty) { - try { - AdImpressionManager.instance.markAsReportedBatch(adResult.adsToMark!, - pageKey: adResult.pageKey); - } catch (e) { - Logger.analyticsSdk('标记广告ID为已上报失败: $e'); - } - } - if (accepted && - adResult.sdkTraceToMark != null && - adResult.sdkTraceToMark!.isNotEmpty) { - try { - AdImpressionManager.instance - .markSdkTraceReported(adResult.sdkTraceToMark!); - } catch (e) { - Logger.analyticsSdk('标记广告 SDK 曝光 trace_id 失败: $e'); - } - } - } catch (e) { - Logger.analyticsSdk('track() 发生异常,已安全处理: $e'); - } - } - - /// 使用事件名和业务 payload 上报事件。 - /// - /// 公共字段与 event_id 由埋点 SDK 统一生成,调用方无需构造事件实体。 - /// 本方法永远不会抛出异常,即使参数无效也不影响应用运行。 - void trackEvent({ - required String eventName, - required Map payload, - }) { - try { - final normalizedEventName = eventName.trim(); - if (normalizedEventName.isEmpty) { - Logger.analyticsSdk('trackEvent() eventName 为空,跳过'); - return; - } - final payloadCopy = Map.from(payload); - if (normalizedEventName == EventType.adImpressionSdk.event) { - track(AdImpressionSdkEvent( - traceId: payloadCopy['trace_id']?.toString() ?? '', - requestId: payloadCopy['request_id']?.toString() ?? '', - adSlotKey: payloadCopy['ad_slot_key']?.toString() ?? '', - adId: payloadCopy['ad_id']?.toString() ?? '', - adType: payloadCopy['ad_type']?.toString() ?? '', - materialSize: payloadCopy['material_size']?.toString() ?? '', - )); - return; - } - final commonFields = SdkContext.generateCommonFields(normalizedEventName); - final sortedPayloadKeys = payloadCopy.keys.toList()..sort(); - final eventId = SdkContext.generateEventIdFromCommonFields( - commonFields, - [ - jsonEncode({ - for (final key in sortedPayloadKeys) key: payloadCopy[key], - }), - ], - ); - track({ - ...commonFields, - 'event_id': eventId, - 'payload': payloadCopy, - }); - } catch (e) { - Logger.analyticsSdk('trackEvent() 发生异常,已安全处理: $e'); - } - } - - Future _trackSdkInit({required DateTime initStartedAt}) async { - if (_sdkInitReported) { - Logger.analyticsSdk('sdk_init 当前进程已上报,跳过重复启动快照'); - return; - } - try { - final platformFields = { - ...await collectSdkInitPlatformFields(), - 'activation_time': await SdkActivationStore.getOrCreateActivationTime(), - }; - _applySdkInitEnvironmentOverrides(platformFields); - final commonFields = - SdkContext.generateCommonFields(EventType.sdkInit.event); - final sdkInitSnapshotFields = { - ...commonFields, - ...SdkContext.getEnvironmentSnapshotFields(), - }; - final payload = SdkInitPayloadBuilder.build( - commonFields: sdkInitSnapshotFields, - platformFields: platformFields, - isWeb: kIsWeb, - ); - final fingerprint = - DeviceFingerprintUtil.generateV3FromSdkInitPayload(payload); - if (fingerprint.isNotEmpty) { - SdkContext.configureGeneratedDeviceFingerprint(fingerprint); - } - final sdkInitEvent = SdkInitEvent(payload: payload); - track(sdkInitEvent); - _sdkInitReported = true; - _trackInitialAppLifecycle( - initStartedAt: initStartedAt, - platformFields: platformFields, - ); - _registerWebLifecycleUnload(initStartedAt); - Logger.analyticsSdk('sdk_init 上报已入队'); - } catch (e) { - Logger.analyticsSdk('sdk_init 上报失败,已安全处理: $e'); - } - } - - void _trackInitialAppLifecycle({ - required DateTime initStartedAt, - required Map platformFields, - }) { - try { - final durationMs = - DateTime.now().difference(initStartedAt).inMilliseconds; - if (kIsWeb) { - track(AppLifecycleEvent.webLoad( - pageUrl: platformFields['current_url']?.toString() ?? '', - pageLoadTimeMs: durationMs, - viewportWidth: _intField(platformFields['viewport_width'], -1), - viewportHeight: _intField(platformFields['viewport_height'], -1), - )); - } else { - track(AppLifecycleEvent.coldStart( - coldStartDurationMs: durationMs, - lastExitType: - platformFields['last_exit_type']?.toString() ?? 'unknown', - bootTime: _intField(platformFields['boot_time'], 0), - )); - } - } catch (e) { - Logger.analyticsSdk('app_lifecycle 初始事件上报失败,已安全处理: $e'); - track(AppLifecycleEvent.unknown(unknownReason: 'collect_error')); - } - } - - void _registerWebLifecycleUnload(DateTime pageEnterTime) { - try { - _webLifecycleUnloadDisposer?.call(); - _webLifecycleUnloadDisposer = registerAppLifecycleWebUnloadListener( - track: track, - pageEnterTime: pageEnterTime, - ); - } catch (e) { - Logger.analyticsSdk('Web 生命周期卸载监听注册失败,已安全处理: $e'); - } - } - - 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; - } - - void _trackSessionSummary({required String sessionEndReason}) { - final currentSid = SessionManager.instance.currentSessionId ?? ''; - if (currentSid.isEmpty || _lastSessionSummarySid == currentSid) return; - - final previousSidProvider = SdkContext.sidProvider; - try { - SdkContext.sidProvider = () => currentSid; - final summaryEvent = AppSessionSummaryEvent( - sessionEndReason: sessionEndReason, - endedAt: SessionManager.instance.sessionEndTimeForNotification ?? - DateTime.now(), - ); - final serialized = _TrackPipeline.serializeEvent(summaryEvent); - if (serialized == null) return; - - final validated = _TrackPipeline.validateEvent(serialized); - if (validated == null) return; - - final summaryJson = validated; - if (!_TrackPipeline.checkEventSize(summaryJson)) return; - if (!_TrackPipeline.isEventTypeEnabled(summaryJson)) return; - - final accepted = _eventProcessor.enqueueEvent(summaryJson); - if (accepted) { - _lastSessionSummarySid = currentSid; - } - } catch (e) { - Logger.analyticsSdk('app_session_summary 上报失败,已安全处理: $e'); - } finally { - SdkContext.sidProvider = previousSidProvider; - } - } - - /// Debug/QA only: 模拟旧 sid idle 超时,触发 app_session_summary 后创建新 sid。 - /// - /// Release 包中返回 false 且不做任何事。 - Future debugTriggerIdleSessionSummaryForQa({ - Duration idleFor = const Duration(minutes: 31), - }) async { - if (kReleaseMode) return false; - try { - final oldSid = SessionManager.instance.getSessionId(); - final armed = - SessionManager.instance.debugSetBackgroundElapsedForQa(idleFor); - if (!armed) return false; - SessionManager.instance.onAppForeground(); - await flush(); - return SessionManager.instance.currentSessionId != oldSid; - } catch (e) { - Logger.analyticsSdk('QA 触发 app_session_summary 失败: $e'); - return false; - } - } - - void _applySdkInitEnvironmentOverrides(Map platformFields) { - const keys = { - 'timezone', - 'package_name', - 'carrier', - 'network_type', - 'screen_width', - 'screen_height', - 'screen_dpi', - 'device_pixel_ratio', - 'orientation', - 'screen_brightness', - 'volume', - 'is_screen_on', - 'battery_level', - 'is_charging', - 'battery_state', - 'cpu_cores', - 'device_memory_gb', - 'touch_support', - 'max_touch_points', - }; - final overrides = {}; - for (final key in keys) { - final value = platformFields[key]; - if (_isUsefulSdkInitEnvironmentValue(value)) { - overrides[key] = value; - } - } - SdkContext.updateEnvironmentFields(overrides); - } - - bool _isUsefulSdkInitEnvironmentValue(dynamic value) { - if (value == null) return false; - if (value is num) return value >= 0; - final text = value.toString().trim(); - return text.isNotEmpty && text != 'unknown'; - } - - /// 强制立即上报。 - /// - /// 本方法永远不会抛出异常。 - Future flush() async { - try { - await _eventProcessor.flush(); - } catch (e) { - Logger.analyticsSdk('flush() 发生异常,已安全处理: $e'); - } - } - - /// 清理 SDK 资源(先尝试上报剩余事件,再释放资源)。 - /// - /// 本方法永远不会抛出异常。 - Future dispose() async { - try { - _trackSessionSummary(sessionEndReason: 'app_exit'); - - try { - await flush(); - } catch (e) { - Logger.analyticsSdk('dispose() 时上报失败: $e'); - } - - await _eventProcessor.disposeAsync(); - - try { - DomainManager.instance.dispose(); - } catch (e) { - Logger.analyticsSdk('销毁域名管理器失败: $e'); - } - - try { - EventTypeConfigManager.instance.dispose(); - } catch (e) { - Logger.analyticsSdk('销毁事件类型配置管理器失败: $e'); - } - - try { - _webLifecycleUnloadDisposer?.call(); - _webLifecycleUnloadDisposer = null; - } catch (e) { - Logger.analyticsSdk('销毁 Web 生命周期监听失败: $e'); - } - - try { - AppLifecycleObserver.instance.dispose(); - } catch (e) { - Logger.analyticsSdk('销毁应用生命周期观察者失败: $e'); - } - - try { - await EnvironmentInfoUtil.dispose(); - } catch (e) { - Logger.analyticsSdk('销毁环境字段监听失败: $e'); - } - - try { - await SessionSensorSampler.instance.dispose(); - } catch (e) { - Logger.analyticsSdk('销毁传感器采样器失败: $e'); - } - - try { - SessionBehaviorMonitor.instance.dispose(); - } catch (e) { - Logger.analyticsSdk('销毁行为监听器失败: $e'); - } - - try { - _pageObserver?.dispose(); - _sessionObserver?.dispose(); - _pageObserver = null; - _sessionObserver = null; - } catch (e) { - Logger.analyticsSdk('销毁页面生命周期观察者失败: $e'); - } - } catch (e) { - Logger.analyticsSdk('dispose() 发生异常: $e'); - } - } - - // ==================== 调试 ==================== - - /// 更新用户类型(登录、升级会员、切换账号时调用)。 - /// - /// 本方法永远不会抛出异常。 - void updateUserType(String newType) { - try { - UserManager.instance.updateUserType(newType); - } catch (e) { - Logger.analyticsSdk('更新用户类型失败: $e'); - } - } - - /// 在 init() 之后、需要更新配置时调用。 - /// - /// [encryptedConfig] 格式与 init() 中的同名参数一致。 - /// 本方法永远不会抛出异常。 - Future refreshDomainConfig({ - String? encryptedConfig, - }) async { - try { - if (encryptedConfig == null || encryptedConfig.isEmpty) { - Logger.analyticsSdk('refreshDomainConfig encryptedConfig 为空,忽略本次刷新', - level: LogLevel.warn); - return; - } - await _applyEncryptedConfig( - encryptedConfig: encryptedConfig, - source: 'refreshDomainConfig', - ); - } catch (e) { - Logger.analyticsSdk('refreshDomainConfig 异常: $e', level: LogLevel.warn); - } - } - - /// 测试包用:获取埋点调试信息 - Map getDebugInfo() { - try { - final steps = getDebugSteps(); - final lastStep = steps.isNotEmpty ? steps.last : {}; - return { - 'inited': steps.isNotEmpty && steps.length >= 2 - ? steps[1]['status'] ?? '未知' - : '未知', - 'queueLength': '${_eventProcessor.queueLength}', - 'reportUrl': _eventProcessor.debugReportUrl != null - ? (_eventProcessor.debugReportUrl!.length > 40 - ? '${_eventProcessor.debugReportUrl!.substring(0, 40)}...' - : _eventProcessor.debugReportUrl!) - : '未配置', - 'debugReportEventLogPath': - _eventProcessor.debugReportEventLogPath ?? '不可用', - 'lastStep': lastStep['name'] ?? '', - 'lastStatus': lastStep['status'] ?? '', - }; - } catch (e) { - Logger.analyticsSdk('getDebugInfo 异常,已安全处理: $e', level: LogLevel.warn); - return {}; - } - } - - /// 测试包用:获取埋点流程步骤 - List> getDebugSteps() { - try { - final dm = DomainManager.instance; - final ep = _eventProcessor; - final steps = >[]; - - final hasDomains = dm.reportDomains.isNotEmpty; - steps.add({ - 'name': '①域名配置', - 'status': hasDomains ? '✓已配置' : '✗未配置', - 'detail': hasDomains ? '由业务方传入' : '未传入 encryptedConfig', - }); - - final appIdOk = SdkContext.appId != null && SdkContext.appId!.isNotEmpty; - steps.add({ - 'name': '②SDK初始化', - 'status': appIdOk ? '✓已初始化' : '✗未初始化', - 'detail': appIdOk ? 'appId已设置' : '-', - }); - - final domains = dm.reportDomains; - steps.add({ - 'name': '③域名列表', - 'status': domains.isEmpty - ? (appIdOk ? '✗未配置或测速中' : '-') - : '✓已配置(${domains.length}个)', - 'detail': domains.isEmpty ? '-' : domains.join(','), - }); - - final fastest = dm.fastestDomain; - String speedStatus; - if (fastest != null && fastest.isNotEmpty) { - speedStatus = '✓已选'; - } else if (domains.isNotEmpty) { - speedStatus = '✗测速失败或进行中'; - } else { - speedStatus = '-'; - } - steps.add({ - 'name': '④域名测速', - 'status': speedStatus, - 'detail': fastest ?? '-', - }); - - final hasUrl = ep.debugReportUrl != null && ep.debugReportUrl!.isNotEmpty; - String urlDetail = '-'; - if (hasUrl && ep.debugReportUrl != null) { - try { - final uri = Uri.parse(ep.debugReportUrl!); - urlDetail = '${uri.host}${uri.path}'; - } catch (_) { - urlDetail = '已配置'; - } - } - steps.add({ - 'name': '⑤上报地址', - 'status': hasUrl ? '✓已就绪' : '✗未就绪', - 'detail': urlDetail, - }); - - steps.add({ - 'name': '⑥待上报队列', - 'status': '${ep.queueLength}条', - 'detail': ep.queueLength > 0 ? '有事件待上报' : '空', - }); - - String uploadStatus; - String uploadDetail = '-'; - if (ep.lastUploadTime == null) { - uploadStatus = '未尝试'; - } else if (ep.lastUploadSuccess == true) { - uploadStatus = '✓成功'; - uploadDetail = _DebugInspector.formatTime(ep.lastUploadTime); - } else { - uploadStatus = '✗失败'; - uploadDetail = ep.lastUploadError ?? '未知'; - } - steps.add({ - 'name': '⑦最近上报', - 'status': uploadStatus, - 'detail': uploadDetail, - }); - - return steps; - } catch (e) { - Logger.analyticsSdk('getDebugSteps 异常,已安全处理: $e', level: LogLevel.warn); - return []; - } - } - - /// 验证加密配置字符串格式(仅 debug 包可用) - /// - /// 支持以下解密后格式: - /// - JSON 数组:`["https://api.example.com", ...]`(域名列表或事件类型列表) - /// - JSON 对象含 `domainList`:`{"domainList":[...]}` - /// - JSON 对象含 `eventList`:`{"eventList":[...]}` - /// - JSON 对象含 `enabled_event_types`:`{"enabled_event_types":[...]}` - Map validateEncryptedConfig(String ciphertext) { - return _DebugInspector.validateEncryptedConfig(ciphertext); - } - - // ==================== 私有方法 ==================== - - /// 用户类型提供者 - static String Function() userTypeProvider = - () => UserManager.instance.userType; - - /// 准备设备指纹状态。 - /// - /// v3 指纹必须在 sdk_init payload 采集完整后计算,这里只清理旧缓存并 - /// 提前写入版本号,避免 re-init 复用旧设备参数。 - void _prepareDeviceFingerprint() { - try { - DeviceFingerprintUtil.clearCache(); - SdkContext.resetGeneratedDeviceFingerprint(); - Logger.analyticsSdk('设备指纹状态已准备,规则版本: ${DeviceFingerprintUtil.kVersion}'); - } catch (e) { - Logger.analyticsSdk('设备指纹状态准备失败,device_fingerprint 将为空: $e'); - } - } - - /// 解密并应用域名配置与事件类型配置(供 init() 和 refreshDomainConfig() 复用) - /// - /// [encryptedConfig] 解密后格式:`{"domainList":[...],"eventList":[...]}` - Future _applyEncryptedConfig({ - String? encryptedConfig, - required String source, - }) async { - final appId = SdkContext.appId ?? ''; - - if (encryptedConfig != null && encryptedConfig.isNotEmpty) { - Logger.analyticsSdk( - '$source 收到 encryptedConfig: length=${encryptedConfig.length}'); - DomainManager.instance.reset(); - - // ── 阶段 1:域名配置(独立 try-catch,异常不影响阶段 2)────────────── - List? parsedEventList; - try { - final plaintext = AesGcmUtil.decryptResponseAuto(encryptedConfig); - Logger.analyticsSdkDeveloperLog( - '$source encryptedConfig 解密成功,明文(敏感字段已脱敏): ${_redactEncryptedConfigForLog(plaintext)}', - ); - final raw = jsonDecode(plaintext); - - // 纯 JSON 数组:直接作为域名列表处理,不含 eventList - if (raw is List) { - final domains = raw - .map((e) => e.toString().trim()) - .where((e) => e.isNotEmpty) - .toList(); - if (domains.isNotEmpty) { - _startSpeedTest(domains, appId); - Logger.analyticsSdk( - '$source encryptedConfig 解析成功: type=List, domainList=${domains.length}, eventList=0'); - } else { - Logger.analyticsSdk('$source encryptedConfig 解析结果为空数组,尝试加载本地缓存', - level: LogLevel.warn); - await _applyDomainCache(appId); - } - // 纯数组格式不含 eventList,parsedEventList 保持 null(走缓存) - } else if (raw is! Map) { - Logger.analyticsSdk( - '$source encryptedConfig 解析格式异常:期望 Map,实际 ${raw.runtimeType},尝试加载本地缓存', - level: LogLevel.warn); - await _applyDomainCache(appId); - // parsedEventList 保持 null,走缓存逻辑(阶段 2 处理) - } else { - final decoded = raw; - - // 解析 domainList - final rawDomains = decoded['domainList']; - final domains = rawDomains is List - ? rawDomains - .map((e) => e.toString().trim()) - .where((e) => e.isNotEmpty) - .toList() - : []; - - if (domains.isNotEmpty) { - _startSpeedTest(domains, appId); - } else { - Logger.analyticsSdk('domainList 解析结果为空,尝试加载本地缓存', - level: LogLevel.warn); - await _applyDomainCache(appId); - } - - // 捕获 eventList 供阶段 2 使用 - final rawEvents = - decoded['eventList'] ?? decoded['enabled_event_types']; - if (rawEvents is List && rawEvents.isNotEmpty) { - parsedEventList = rawEvents.map((e) => e.toString()).toList(); - } - - _applyEventSignatureConfig(decoded); - Logger.analyticsSdk( - '$source encryptedConfig 解析成功: type=Map, domainList=${domains.length}, eventList=${parsedEventList?.length ?? 0}, eventSignature=${_hasEventSignatureConfig(decoded)}'); - } - } catch (e) { - Logger.analyticsSdk('$source encryptedConfig 解密/解析失败: $e,尝试加载本地缓存', - level: LogLevel.warn); - await _applyDomainCache(appId); - } - - // ── 阶段 2:事件类型配置(独立 try-catch,异常不触发第二轮测速)────── - try { - if (parsedEventList != null) { - await EventTypeConfigManager.instance - .initWithConfig(jsonEncode(parsedEventList)); - } else { - Logger.analyticsSdk('eventList 解析结果为空,尝试加载本地缓存', - level: LogLevel.warn); - await EventTypeConfigManager.instance.loadCachedConfig(); - } - } catch (e) { - Logger.analyticsSdk('eventList 初始化失败: $e,尝试加载本地缓存', - level: LogLevel.warn); - await EventTypeConfigManager.instance.loadCachedConfig(); - } - } else { - Logger.analyticsSdk('$source encryptedConfig 未传入,尝试加载本地缓存', - level: LogLevel.warn); - await _applyDomainCache(appId); - await EventTypeConfigManager.instance.loadCachedConfig(); - } - } - - String _redactEncryptedConfigForLog(String plaintext) { - try { - final decoded = jsonDecode(plaintext); - return jsonEncode(_redactSensitiveConfigValue(decoded)); - } catch (_) { - return plaintext; - } - } - - dynamic _redactSensitiveConfigValue(dynamic value) { - const sensitiveKeys = { - 'signKey', - 'eventSignKey', - 'event_signature_key', - 'event_sig_key', - 'hmacKey', - 'key', - }; - if (value is Map) { - return value.map((key, child) { - final keyText = key.toString(); - if (sensitiveKeys.contains(keyText)) { - return MapEntry(keyText, '***'); - } - return MapEntry(keyText, _redactSensitiveConfigValue(child)); - }); - } - if (value is List) { - return value.map(_redactSensitiveConfigValue).toList(); - } - return value; - } - - bool _hasEventSignatureConfig(Map decoded) { - if (_firstConfigString(decoded, const [ - 'signKey', - 'eventSignKey', - 'event_signature_key', - 'event_sig_key', - ]) != - null) { - return true; - } - final rawEventSignature = decoded['eventSignature']; - if (rawEventSignature is Map) { - final eventSignature = Map.from(rawEventSignature); - return _firstConfigString(eventSignature, const [ - 'signKey', - 'key', - 'hmacKey', - ]) != - null; - } - return false; - } - - void _applyEventSignatureConfig(Map decoded) { - try { - final directSignKey = _firstConfigString(decoded, const [ - 'signKey', - 'eventSignKey', - 'event_signature_key', - 'event_sig_key', - ]); - final directVersion = _firstConfigString(decoded, const [ - 'signVersion', - 'eventSigVersion', - 'event_sig_version', - ]); - - String? nestedSignKey; - String? nestedVersion; - final rawEventSignature = decoded['eventSignature']; - if (rawEventSignature is Map) { - final eventSignature = Map.from(rawEventSignature); - nestedSignKey = _firstConfigString(eventSignature, const [ - 'signKey', - 'key', - 'hmacKey', - ]); - nestedVersion = _firstConfigString(eventSignature, const [ - 'version', - 'signVersion', - 'eventSigVersion', - 'event_sig_version', - ]); - } - - final signKey = directSignKey ?? nestedSignKey; - if (signKey == null) return; - - SdkContext.configureEventSignature( - signKey: signKey, - version: directVersion ?? nestedVersion, - ); - } catch (e) { - Logger.analyticsSdk('签名配置解析失败,事件签名将保持当前状态: $e', level: LogLevel.warn); - } - } - - String? _firstConfigString( - Map decoded, - List keys, - ) { - for (final key in keys) { - final value = decoded[key]; - if (value is String && value.trim().isNotEmpty) { - return value.trim(); - } - } - return null; - } - - Future _applyDomainCache(String appId) async { - try { - final cachedDomains = await DomainManager.instance.loadCachedDomains(); - if (cachedDomains.isNotEmpty) { - Logger.analyticsSdk('使用缓存域名降级: $cachedDomains'); - _startSpeedTest(cachedDomains, appId); - } else { - Logger.analyticsSdk( - '无可用域名缓存,上报 URL 保持未配置,事件将在 refreshDomainConfig() 后上报', - level: LogLevel.warn); - } - } catch (e) { - Logger.analyticsSdk('加载域名缓存失败: $e', level: LogLevel.warn); - } - } - - /// 设置域名测速回调并启动测速(fire-and-forget) - void _startSpeedTest(List domains, String appId) { - DomainManager.instance.onFastestDomainChanged = (fastest) { - try { - if (fastest != null && fastest.isNotEmpty) { - final url = ApiConfig.getReportUrl(fastest, appId: appId); - _eventProcessor.updateReportUrl(url); - Logger.analyticsSdk('域名测速完成,已更新上报URL: $url'); - } - } catch (e) { - Logger.analyticsSdk('_startSpeedTest 回调异常,已安全处理: $e', - level: LogLevel.warn); - } - }; - DomainManager.instance.initWithDomains(domains); - } -} - -/// 广告展示去重结果(仅在 [AnalyticsSdk] 内部使用) -class _AdDedupeResult { - /// 经过去重处理后的事件(可能是过滤了部分 adId 的 [AdImpressionEvent],或原始事件) - final dynamic processedEvent; - - /// 成功入队后需要标记为已上报的 adId 字符串;非广告展示事件时为 null - final String? adsToMark; - - /// 广告所在页面的 pageKey,用于页面级去重;非广告事件时为空字符串 - final String pageKey; - - /// 成功入队后需要标记为已上报的广告 SDK trace_id。 - final String? sdkTraceToMark; - - const _AdDedupeResult( - this.processedEvent, - this.adsToMark, [ - this.pageKey = '', - this.sdkTraceToMark, - ]); -} - -/// track() 管道辅助方法(纯静态,不依赖 AnalyticsSdk 实例) -class _TrackPipeline { - /// 广告展示事件去重处理。返回 null 表示全部 adId 均已上报,应跳过整条事件。 - static _AdDedupeResult? deduplicateAdImpression(dynamic event) { - if (event is AdImpressionSdkEvent) { - return _deduplicateAdSdkImpression(event); - } - if (event is! AdImpressionEvent) { - return _AdDedupeResult(event, null); - } - final AdImpressionEvent adEvent = event; - try { - final unreportedAdIds = _getUnreportedAdIds(adEvent); - if (unreportedAdIds != null && unreportedAdIds.isEmpty) { - Logger.analyticsSdk('广告展示事件已上报,跳过: ${adEvent.adId}'); - return null; - } - if (unreportedAdIds != null) { - final originalAdIdsCount = adEvent.adId - .split(',') - .map((id) => id.trim()) - .where((id) => id.isNotEmpty) - .length; - if (unreportedAdIds.length < originalAdIdsCount) { - final filtered = _createFilteredAdEvent(adEvent, unreportedAdIds); - Logger.analyticsSdk( - '广告展示事件已过滤: 原始$originalAdIdsCount个ID,只上报${unreportedAdIds.length}个未上报的ID: ${filtered.adId}'); - return _AdDedupeResult(filtered, filtered.adId, filtered.pageKey); - } - } - return _AdDedupeResult(adEvent, adEvent.adId, adEvent.pageKey); - } catch (e) { - Logger.analyticsSdk('广告去重检查异常,跳过去重: $e'); - return _AdDedupeResult(adEvent, adEvent.adId, adEvent.pageKey); - } - } - - /// 广告 SDK 曝光以服务端下发的 trace_id 为单次投放标识,在同一 session 内去重。 - static _AdDedupeResult? _deduplicateAdSdkImpression( - AdImpressionSdkEvent event, - ) { - final traceId = event.traceId.trim(); - if (traceId.isEmpty) return _AdDedupeResult(event, null); - if (AdImpressionManager.instance.isSdkTraceReported(traceId)) { - Logger.analyticsSdk('广告 SDK 曝光已上报,跳过: $traceId'); - return null; - } - return _AdDedupeResult(event, null, '', traceId); - } - - /// 将事件对象序列化为 JSON Map。返回 null 表示序列化失败,应丢弃事件。 - static Map? serializeEvent(dynamic event) { - try { - final json = event is Map ? event : event.toJson(); - if (json == null) { - Logger.analyticsSdk('事件 toJson() 返回 null,跳过'); - return null; - } - return SdkContext.attachDeviceEnvIfNeeded(json); - } catch (e) { - Logger.analyticsSdk('事件序列化失败,跳过: $e'); - return null; - } - } - - /// 校验事件字段。返回 null 表示校验不通过,应丢弃事件。 - static Map? validateEvent(Map json) { - final validated = EventValidator.validate(json); - if (validated == null) { - Logger.analyticsSdk('事件校验不通过,已丢弃: ${json['event']}', - level: LogLevel.warn); - } - return validated; - } - - /// 检查单条事件大小。返回 false 表示超限应丢弃。 - static bool checkEventSize(Map json) { - try { - final encoded = jsonEncode(json); - final sizeBytes = utf8.encode(encoded).length; - if (sizeBytes > SdkConfig.maxSingleEventSize) { - final sizeKB = (sizeBytes / 1024).toStringAsFixed(1); - final limitKB = - (SdkConfig.maxSingleEventSize / 1024).toStringAsFixed(0); - Logger.analyticsSdk( - '事件过大 (${sizeKB}KB > ${limitKB}KB 限制),丢弃。event=${json['event']}', - level: LogLevel.warn); - return false; - } - return true; - } catch (e) { - Logger.analyticsSdk('事件大小检查失败,继续上报: $e'); - return true; - } - } - - /// 检查事件类型是否在配置中启用。返回 false 表示未启用应跳过。 - static bool isEventTypeEnabled(Map json) { - try { - final eventType = json['event'] as String?; - if (eventType != null && - !EventTypeConfigManager.instance.isEventTypeEnabled(eventType)) { - Logger.analyticsSdk('事件类型 $eventType 未在配置中启用,跳过上报'); - return false; - } - return true; - } catch (e) { - Logger.analyticsSdk('事件类型检查失败,继续上报: $e'); - return true; - } - } - - static List? _getUnreportedAdIds(AdImpressionEvent event) { - try { - if (event.adId.isEmpty) return null; - final trimmedAdId = event.adId.trim(); - if (trimmedAdId.isEmpty) return null; - return AdImpressionManager.instance - .getUnreportedAdIds(trimmedAdId, pageKey: event.pageKey); - } catch (e) { - Logger.analyticsSdk('广告去重检查异常,跳过: $e'); - return null; - } - } - - static AdImpressionEvent _createFilteredAdEvent( - AdImpressionEvent originalEvent, List unreportedIds) { - try { - final originalAdIds = originalEvent.adId - .split(',') - .map((id) => id.trim()) - .where((id) => id.isNotEmpty) - .toList(); - - List? originalCreativeIds; - if (originalEvent.creativeId.isNotEmpty) { - originalCreativeIds = - originalEvent.creativeId.split(',').map((id) => id.trim()).toList(); - } - - final filteredAdIds = []; - final filteredCreativeIds = []; - - for (int i = 0; i < originalAdIds.length; i++) { - if (unreportedIds.contains(originalAdIds[i])) { - filteredAdIds.add(originalAdIds[i]); - if (originalCreativeIds != null && i < originalCreativeIds.length) { - filteredCreativeIds.add(originalCreativeIds[i]); - } else { - filteredCreativeIds.add(''); - } - } - } - - return AdImpressionEvent( - pageKey: originalEvent.pageKey, - pageName: originalEvent.pageName, - adSlotKey: originalEvent.adSlotKey, - requestId: originalEvent.requestId, - adId: filteredAdIds.join(','), - materialSize: originalEvent.materialSize, - creativeId: originalEvent.creativeId.isNotEmpty - ? filteredCreativeIds.join(',') - : "", - adType: originalEvent.adType, - ); - } catch (e) { - Logger.analyticsSdk('创建过滤后的广告事件失败,使用原始事件: $e'); - return originalEvent; - } - } -} - -/// Debug 调试辅助方法(纯静态,不依赖 AnalyticsSdk 实例) -class _DebugInspector { - static String formatTime(DateTime? dt) { - if (dt == null) return '-'; - return '${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')}:${dt.second.toString().padLeft(2, '0')}'; - } - - /// 验证加密配置字符串格式(仅 debug 包可用) - static Map validateEncryptedConfig(String ciphertext) { - if (kReleaseMode) { - return {'success': false, 'preview': null, 'error': 'release 包不可用'}; - } - try { - final plaintext = AesGcmUtil.decryptResponseAuto(ciphertext); - final raw = jsonDecode(plaintext); - - if (raw is List) { - if (raw.isNotEmpty) { - return { - 'success': true, - 'preview': raw[0].toString(), - 'count': raw.length, - 'error': null, - }; - } - final preview = - plaintext.length > 60 ? plaintext.substring(0, 60) : plaintext; - return { - 'success': false, - 'preview': preview, - 'error': '解密成功但列表为空(0 个元素),请检查配置', - }; - } - - if (raw is! Map) { - return { - 'success': false, - 'preview': - plaintext.length > 60 ? plaintext.substring(0, 60) : plaintext, - 'error': '解密成功但格式错误:应为 JSON 数组或对象,实际为 ${raw.runtimeType}', - }; - } - - final decoded = raw; - List? items; - if (decoded['domainList'] is List) { - items = decoded['domainList'] as List; - } else if (decoded['eventList'] is List) { - items = decoded['eventList'] as List; - } else if (decoded['enabled_event_types'] is List) { - items = decoded['enabled_event_types'] as List; - } - - if (items != null && items.isNotEmpty) { - return { - 'success': true, - 'preview': items[0].toString(), - 'count': items.length, - 'error': null, - }; - } - - final preview = - plaintext.length > 60 ? plaintext.substring(0, 60) : plaintext; - return { - 'success': false, - 'preview': preview, - 'error': '解密成功但格式无法识别(0 个元素),请检查 JSON 格式', - }; - } catch (e) { - return {'success': false, 'preview': null, 'error': '解密失败: $e'}; - } - } -} diff --git a/analytics_sdk/lib/analytics_sdk_web.dart b/analytics_sdk/lib/analytics_sdk_web.dart deleted file mode 100644 index 6f917f6..0000000 --- a/analytics_sdk/lib/analytics_sdk_web.dart +++ /dev/null @@ -1,12 +0,0 @@ -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 平台功能通过条件导入的工具类直接实现。 - } -} diff --git a/analytics_sdk/lib/config/api_config.dart b/analytics_sdk/lib/config/api_config.dart deleted file mode 100644 index f6c2244..0000000 --- a/analytics_sdk/lib/config/api_config.dart +++ /dev/null @@ -1,84 +0,0 @@ -/// 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.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)}'; - } - } -} diff --git a/analytics_sdk/lib/config/sdk_config.dart b/analytics_sdk/lib/config/sdk_config.dart deleted file mode 100644 index 98be36a..0000000 --- a/analytics_sdk/lib/config/sdk_config.dart +++ /dev/null @@ -1,334 +0,0 @@ -/// 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; - } -} diff --git a/analytics_sdk/lib/const/event_type.dart b/analytics_sdk/lib/const/event_type.dart deleted file mode 100644 index f54585a..0000000 --- a/analytics_sdk/lib/const/event_type.dart +++ /dev/null @@ -1,189 +0,0 @@ -// 如果需要其他 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') - } - } -} diff --git a/analytics_sdk/lib/entity/ad_click_event.dart b/analytics_sdk/lib/entity/ad_click_event.dart deleted file mode 100644 index 49a24be..0000000 --- a/analytics_sdk/lib/entity/ad_click_event.dart +++ /dev/null @@ -1,79 +0,0 @@ -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 _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 toJson() { - Map 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 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? ?? '', - ); - } -} diff --git a/analytics_sdk/lib/entity/ad_impression_event.dart b/analytics_sdk/lib/entity/ad_impression_event.dart deleted file mode 100644 index 7082190..0000000 --- a/analytics_sdk/lib/entity/ad_impression_event.dart +++ /dev/null @@ -1,97 +0,0 @@ -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 _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 toJson() { - Map 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 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? ?? '', - ); - } -} diff --git a/analytics_sdk/lib/entity/ad_sdk_events.dart b/analytics_sdk/lib/entity/ad_sdk_events.dart deleted file mode 100644 index 37334a4..0000000 --- a/analytics_sdk/lib/entity/ad_sdk_events.dart +++ /dev/null @@ -1,459 +0,0 @@ -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 _commonFields; - - _AdSdkEvent(this.event, List eventIdFields) { - _commonFields = SdkContext.generateCommonFields(event); - eventId = SdkContext.generateEventIdFromCommonFields( - _commonFields, - eventIdFields, - ); - } - - Map buildPayload(); - - Map 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 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 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 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 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 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 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 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 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 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 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 buildPayload() => { - 'trace_id': traceId, - 'request_id': requestId, - 'ad_slot_key': adSlotKey, - 'ad_type': adType, - 'error_code': errorCode, - 'error_msg': errorMsg, - 'error_stage': errorStage, - }; -} diff --git a/analytics_sdk/lib/entity/advertising_event.dart b/analytics_sdk/lib/entity/advertising_event.dart deleted file mode 100644 index c8fcbef..0000000 --- a/analytics_sdk/lib/entity/advertising_event.dart +++ /dev/null @@ -1,57 +0,0 @@ -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 _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 toJson() { - Map payload = { - 'event_type': eventType, - 'advertising_key': advertisingKey, - 'advertising_name': advertisingName, - 'advertising_id': advertisingId, - }; - return {..._commonFields, 'event_id': eventId, "payload": payload}; - } - - factory AdvertisingEvent.fromJson(Map 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, - ); - } -} diff --git a/analytics_sdk/lib/entity/analytics_tab.dart b/analytics_sdk/lib/entity/analytics_tab.dart deleted file mode 100644 index 2913c71..0000000 --- a/analytics_sdk/lib/entity/analytics_tab.dart +++ /dev/null @@ -1,24 +0,0 @@ -/// 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]); -} diff --git a/analytics_sdk/lib/entity/analytics_utils.dart b/analytics_sdk/lib/entity/analytics_utils.dart deleted file mode 100644 index 587ad85..0000000 --- a/analytics_sdk/lib/entity/analytics_utils.dart +++ /dev/null @@ -1,5 +0,0 @@ -import 'package:analytics_sdk/manager/sdk_context.dart'; -export 'package:analytics_sdk/manager/sdk_context.dart'; - -// SdkContext 的向后兼容别名,内部使用。 -typedef AnalyticsUtils = SdkContext; diff --git a/analytics_sdk/lib/entity/app_install_event.dart b/analytics_sdk/lib/entity/app_install_event.dart deleted file mode 100644 index 504f45e..0000000 --- a/analytics_sdk/lib/entity/app_install_event.dart +++ /dev/null @@ -1,46 +0,0 @@ -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 _commonFields; - - AppInstallEvent({ - required this.traceId, - this.attributionFlag = 0, - }) { - _commonFields = SdkContext.generateCommonFields(event); - - eventId = SdkContext.generateEventIdFromCommonFields(_commonFields, [ - traceId, - attributionFlag.toString(), - ]); - } - - Map toJson() { - final payload = { - 'trace_id': traceId, - 'attribution_flag': attributionFlag, - }; - return {..._commonFields, 'event_id': eventId, 'payload': payload}; - } - - factory AppInstallEvent.fromJson(Map json) { - return AppInstallEvent( - traceId: json['trace_id'] as String, - attributionFlag: (json['attribution_flag'] as int?) ?? 0, - ); - } -} diff --git a/analytics_sdk/lib/entity/app_lifecycle_event.dart b/analytics_sdk/lib/entity/app_lifecycle_event.dart deleted file mode 100644 index a27947a..0000000 --- a/analytics_sdk/lib/entity/app_lifecycle_event.dart +++ /dev/null @@ -1,189 +0,0 @@ -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 _fields; - - late final String eventId; - late final Map _commonFields; - - AppLifecycleEvent._( - this.lifecycleEventKey, - Map fields, - ) : _fields = Map.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 toJson() { - return { - ..._commonFields, - 'event_id': eventId, - 'payload': { - 'lifecycle_event_key': lifecycleEventKey, - ..._fields, - }, - }; - } - - Map 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 allowed, - String fallback, - ) { - final normalized = value.trim(); - return allowed.contains(normalized) ? normalized : fallback; - } -} diff --git a/analytics_sdk/lib/entity/app_page_item_event.dart b/analytics_sdk/lib/entity/app_page_item_event.dart deleted file mode 100644 index 4e143c6..0000000 --- a/analytics_sdk/lib/entity/app_page_item_event.dart +++ /dev/null @@ -1,217 +0,0 @@ -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 _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 toJson() { - final payload = { - '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 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 _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 toJson() { - final payload = { - '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 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 _payloadOrSelf(Map json) { - final payload = json['payload']; - if (payload is Map) return Map.from(payload); - return json; -} diff --git a/analytics_sdk/lib/entity/app_page_view_event.dart b/analytics_sdk/lib/entity/app_page_view_event.dart deleted file mode 100644 index 8fe81f1..0000000 --- a/analytics_sdk/lib/entity/app_page_view_event.dart +++ /dev/null @@ -1,181 +0,0 @@ -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 _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 toJson() { - Map 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 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; - } - } -} diff --git a/analytics_sdk/lib/entity/app_session_summary_event.dart b/analytics_sdk/lib/entity/app_session_summary_event.dart deleted file mode 100644 index 1bff765..0000000 --- a/analytics_sdk/lib/entity/app_session_summary_event.dart +++ /dev/null @@ -1,128 +0,0 @@ -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 _payloadFields; - - late final String eventId; - late final Map _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 toJson() { - return { - ..._commonFields, - 'event_id': eventId, - 'payload': Map.from(_payloadFields), - }; - } - - Map get _stablePayloadFields { - final sortedKeys = _payloadFields.keys.toList()..sort(); - return { - for (final key in sortedKeys) key: _payloadFields[key], - }; - } - - static Map _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'; - } -} diff --git a/analytics_sdk/lib/entity/comic_event.dart b/analytics_sdk/lib/entity/comic_event.dart deleted file mode 100644 index dc4c5c5..0000000 --- a/analytics_sdk/lib/entity/comic_event.dart +++ /dev/null @@ -1,130 +0,0 @@ -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 _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 toJson() { - Map 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 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? ?? '', - ); - } -} diff --git a/analytics_sdk/lib/entity/keyword_click_event.dart b/analytics_sdk/lib/entity/keyword_click_event.dart deleted file mode 100644 index 2dc60ac..0000000 --- a/analytics_sdk/lib/entity/keyword_click_event.dart +++ /dev/null @@ -1,71 +0,0 @@ -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 _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 toJson() { - Map 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 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? ?? '', - ); - } -} diff --git a/analytics_sdk/lib/entity/keyword_search_event.dart b/analytics_sdk/lib/entity/keyword_search_event.dart deleted file mode 100644 index f64aa2b..0000000 --- a/analytics_sdk/lib/entity/keyword_search_event.dart +++ /dev/null @@ -1,65 +0,0 @@ -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 _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 toJson() { - Map 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 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? ?? '', - ); - } -} diff --git a/analytics_sdk/lib/entity/navigation_event.dart b/analytics_sdk/lib/entity/navigation_event.dart deleted file mode 100644 index 5024bbe..0000000 --- a/analytics_sdk/lib/entity/navigation_event.dart +++ /dev/null @@ -1,67 +0,0 @@ -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 _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 toJson() { - Map payload = { - 'navigation_key': navigationKey, - 'navigation_name': navigationName, - }; - return {..._commonFields, 'event_id': eventId, "payload": payload}; - } - - /// 从 JSON 反序列化(通常用于缓存恢复场景)。 - factory NavigationEvent.fromJson(Map json) { - return NavigationEvent( - navigationKey: json['navigation_key'] as String, - navigationName: json['navigation_name'] as String, - ); - } -} diff --git a/analytics_sdk/lib/entity/network_state_change_event.dart b/analytics_sdk/lib/entity/network_state_change_event.dart deleted file mode 100644 index f02d92e..0000000 --- a/analytics_sdk/lib/entity/network_state_change_event.dart +++ /dev/null @@ -1,89 +0,0 @@ -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 _payloadFields; - - late final String eventId; - late final Map _commonFields; - - NetworkStateChangeEvent({ - required String networkType, - required String fromNetworkType, - required int isOnline, - required int changeCountInSid, - String carrier = '', - Map 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 toJson() { - return { - ..._commonFields, - 'event_id': eventId, - 'payload': Map.from(_payloadFields), - }; - } - - Map get _stablePayloadFields { - final sortedKeys = _payloadFields.keys.toList()..sort(); - return { - for (final key in sortedKeys) key: _payloadFields[key], - }; - } - - static Map _buildPayloadFields({ - required String networkType, - required String fromNetworkType, - required int isOnline, - required int changeCountInSid, - required String carrier, - required Map webConnectionFields, - }) { - final normalizedNetworkType = _networkType(networkType); - final fields = { - '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); - } -} diff --git a/analytics_sdk/lib/entity/novel_event.dart b/analytics_sdk/lib/entity/novel_event.dart deleted file mode 100644 index 1cb4926..0000000 --- a/analytics_sdk/lib/entity/novel_event.dart +++ /dev/null @@ -1,130 +0,0 @@ -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 _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 toJson() { - Map 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 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? ?? '', - ); - } -} diff --git a/analytics_sdk/lib/entity/page_click_event.dart b/analytics_sdk/lib/entity/page_click_event.dart deleted file mode 100644 index 38eda6b..0000000 --- a/analytics_sdk/lib/entity/page_click_event.dart +++ /dev/null @@ -1,284 +0,0 @@ -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 _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 toJson() { - Map 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 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; - } -} diff --git a/analytics_sdk/lib/entity/recommend_list_click_event.dart b/analytics_sdk/lib/entity/recommend_list_click_event.dart deleted file mode 100644 index 7e66f8e..0000000 --- a/analytics_sdk/lib/entity/recommend_list_click_event.dart +++ /dev/null @@ -1,87 +0,0 @@ -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 _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 toJson() { - final payload = { - '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 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? ?? '', - ); - } -} diff --git a/analytics_sdk/lib/entity/recommend_list_view_event.dart b/analytics_sdk/lib/entity/recommend_list_view_event.dart deleted file mode 100644 index 7f222ee..0000000 --- a/analytics_sdk/lib/entity/recommend_list_view_event.dart +++ /dev/null @@ -1,88 +0,0 @@ -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 _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 toJson() { - final payload = { - '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 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? ?? '', - ); - } -} diff --git a/analytics_sdk/lib/entity/sdk_init_event.dart b/analytics_sdk/lib/entity/sdk_init_event.dart deleted file mode 100644 index 75259cd..0000000 --- a/analytics_sdk/lib/entity/sdk_init_event.dart +++ /dev/null @@ -1,32 +0,0 @@ -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 payload; - - late final Map _commonFields; - - SdkInitEvent({required Map payload}) - : payload = Map.from(payload) { - _commonFields = SdkContext.generateCommonFields(event); - eventId = SdkContext.generateEventIdFromCommonFields(_commonFields, [ - jsonEncode(this.payload), - ]); - } - - Map toJson() { - return { - ..._commonFields, - 'event_id': eventId, - 'payload': payload, - }; - } -} diff --git a/analytics_sdk/lib/entity/video_event.dart b/analytics_sdk/lib/entity/video_event.dart deleted file mode 100644 index 594551d..0000000 --- a/analytics_sdk/lib/entity/video_event.dart +++ /dev/null @@ -1,132 +0,0 @@ -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 _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 toJson() { - Map 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 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? ?? '', - ); - } -} diff --git a/analytics_sdk/lib/enum/ad_type_enum.dart b/analytics_sdk/lib/enum/ad_type_enum.dart deleted file mode 100644 index b8157fd..0000000 --- a/analytics_sdk/lib/enum/ad_type_enum.dart +++ /dev/null @@ -1,40 +0,0 @@ -/// 广告类型枚举 -/// 对应埋点中的 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; - } -} - diff --git a/analytics_sdk/lib/enum/auth_type_enum.dart b/analytics_sdk/lib/enum/auth_type_enum.dart deleted file mode 100644 index 335fb7a..0000000 --- a/analytics_sdk/lib/enum/auth_type_enum.dart +++ /dev/null @@ -1,25 +0,0 @@ -/// 认证/注册/登录方式枚举 -/// 对应文档中的 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, - }); -} - diff --git a/analytics_sdk/lib/enum/banner_event_enum.dart b/analytics_sdk/lib/enum/banner_event_enum.dart deleted file mode 100644 index dd19afe..0000000 --- a/analytics_sdk/lib/enum/banner_event_enum.dart +++ /dev/null @@ -1,11 +0,0 @@ -enum BannerEventEnum{ - CLICK(label: 'click'), - CLOSE(label: 'close'), - SHOW(label: 'show'); - - final String label; - - const BannerEventEnum({ - required this.label, - }); -} \ No newline at end of file diff --git a/analytics_sdk/lib/enum/click_item_type_enum.dart b/analytics_sdk/lib/enum/click_item_type_enum.dart deleted file mode 100644 index ce3f8e0..0000000 --- a/analytics_sdk/lib/enum/click_item_type_enum.dart +++ /dev/null @@ -1,34 +0,0 @@ -/// 点击项目类型。 -/// -/// 可直接使用预定义常量,也可传入自定义字符串: -/// ```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', '漫画'); -} diff --git a/analytics_sdk/lib/enum/consume_reason_enum.dart b/analytics_sdk/lib/enum/consume_reason_enum.dart deleted file mode 100644 index 7071928..0000000 --- a/analytics_sdk/lib/enum/consume_reason_enum.dart +++ /dev/null @@ -1,25 +0,0 @@ -/// 金币消耗原因枚举 -/// 对应文档中的 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, - }); -} - diff --git a/analytics_sdk/lib/enum/currency_enum.dart b/analytics_sdk/lib/enum/currency_enum.dart deleted file mode 100644 index 3ba47f6..0000000 --- a/analytics_sdk/lib/enum/currency_enum.dart +++ /dev/null @@ -1,19 +0,0 @@ -/// 货币类型枚举 -/// 对应文档中的 currency 字段:CNY(人民币), USD(美元) -enum CurrencyEnum { - /// 人民币 - CNY(label: 'CNY'), - - /// 美元 - USD(label: 'USD'), - - ; - - /// 上报时使用的字符串值 - final String label; - - const CurrencyEnum({ - required this.label, - }); -} - diff --git a/analytics_sdk/lib/enum/device_enum.dart b/analytics_sdk/lib/enum/device_enum.dart deleted file mode 100644 index 8bbfd03..0000000 --- a/analytics_sdk/lib/enum/device_enum.dart +++ /dev/null @@ -1,11 +0,0 @@ -enum DeviceEnum { - Android(label: 'Android'), - iOS(label: 'iOS'), - PC(label: 'PC'); - - final String label; - - const DeviceEnum({ - required this.label, - }); -} diff --git a/analytics_sdk/lib/enum/order_type_enum.dart b/analytics_sdk/lib/enum/order_type_enum.dart deleted file mode 100644 index 8469dd9..0000000 --- a/analytics_sdk/lib/enum/order_type_enum.dart +++ /dev/null @@ -1,19 +0,0 @@ -/// 订单类型枚举 -/// 对应文档中的 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, - }); -} - diff --git a/analytics_sdk/lib/enum/page_route_enum.dart b/analytics_sdk/lib/enum/page_route_enum.dart deleted file mode 100644 index 8a6c635..0000000 --- a/analytics_sdk/lib/enum/page_route_enum.dart +++ /dev/null @@ -1,174 +0,0 @@ -/// 预定义页面路由枚举,提供标准 [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; -} diff --git a/analytics_sdk/lib/enum/pay_type_enum.dart b/analytics_sdk/lib/enum/pay_type_enum.dart deleted file mode 100644 index 6ca196f..0000000 --- a/analytics_sdk/lib/enum/pay_type_enum.dart +++ /dev/null @@ -1,25 +0,0 @@ -/// 支付方式枚举 -/// 对应文档中的 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, - }); -} - diff --git a/analytics_sdk/lib/enum/read_behavior_enum.dart b/analytics_sdk/lib/enum/read_behavior_enum.dart deleted file mode 100644 index 1d23926..0000000 --- a/analytics_sdk/lib/enum/read_behavior_enum.dart +++ /dev/null @@ -1,35 +0,0 @@ -/// 阅读行为类型,用于小说和漫画事件。 -/// -/// 可使用预定义常量,也可传入自定义字符串: -/// ```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', '读完'); -} diff --git a/analytics_sdk/lib/enum/recommend_content_type_enum.dart b/analytics_sdk/lib/enum/recommend_content_type_enum.dart deleted file mode 100644 index 5be5605..0000000 --- a/analytics_sdk/lib/enum/recommend_content_type_enum.dart +++ /dev/null @@ -1,28 +0,0 @@ -/// 推荐列表内容类型,用于推荐列表展示/点击事件。 -/// -/// 可使用预定义常量,也可传入自定义字符串: -/// ```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'); -} diff --git a/analytics_sdk/lib/enum/user_type_enum.dart b/analytics_sdk/lib/enum/user_type_enum.dart deleted file mode 100644 index 2f166fb..0000000 --- a/analytics_sdk/lib/enum/user_type_enum.dart +++ /dev/null @@ -1,28 +0,0 @@ -/// 用户类型,用于上报用户身份信息。 -/// -/// 可使用预定义常量,也可传入自定义字符串: -/// ```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'); -} diff --git a/analytics_sdk/lib/enum/video_content_type_enum.dart b/analytics_sdk/lib/enum/video_content_type_enum.dart deleted file mode 100644 index e439ec4..0000000 --- a/analytics_sdk/lib/enum/video_content_type_enum.dart +++ /dev/null @@ -1,30 +0,0 @@ -/// 视频内容类型,用于视频事件。 -/// -/// 可使用预定义常量,也可传入自定义字符串: -/// ```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'); -} diff --git a/analytics_sdk/lib/enum/video_event_enum.dart b/analytics_sdk/lib/enum/video_event_enum.dart deleted file mode 100644 index 62363a4..0000000 --- a/analytics_sdk/lib/enum/video_event_enum.dart +++ /dev/null @@ -1,38 +0,0 @@ -/// 视频行为类型,用于视频事件。 -/// -/// 可使用预定义常量,也可传入自定义字符串: -/// ```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', '快退'); -} diff --git a/analytics_sdk/lib/extension/tab_analytics_extension.dart b/analytics_sdk/lib/extension/tab_analytics_extension.dart deleted file mode 100644 index 4d74ab9..0000000 --- a/analytics_sdk/lib/extension/tab_analytics_extension.dart +++ /dev/null @@ -1,51 +0,0 @@ -// 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 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 tabs}) { - return AnalyticsTabBarWrapper( - tabs: tabs, - child: this, - ); - } -} diff --git a/analytics_sdk/lib/manager/ad_impression_manager.dart b/analytics_sdk/lib/manager/ad_impression_manager.dart deleted file mode 100644 index 8bd6a82..0000000 --- a/analytics_sdk/lib/manager/ad_impression_manager.dart +++ /dev/null @@ -1,105 +0,0 @@ -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> _reportedAdIdsByPage = {}; - - /// 广告 SDK 曝光已上报的 trace_id;同一 session 内全局去重。 - final Set _reportedSdkTraceIds = {}; - - /// 当前已存储的广告ID总数(跨所有页面) - int get reportedCount => - _reportedAdIdsByPage.values.fold(0, (sum, s) => sum + s.length); - - /// 批量检查多个广告ID,返回在指定页面内尚未上报的列表(不修改状态) - List 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); -} diff --git a/analytics_sdk/lib/manager/domain_manager.dart b/analytics_sdk/lib/manager/domain_manager.dart deleted file mode 100644 index a52d8ca..0000000 --- a/analytics_sdk/lib/manager/domain_manager.dart +++ /dev/null @@ -1,204 +0,0 @@ -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 _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 get reportDomains => List.unmodifiable(_reportDomains); - - /// 传入已解密的域名列表,执行并发测速并选出最快域名 - /// - /// [domains] 每项为带协议的完整域名,如 "https://api.example.com" - /// - /// 注意:此方法设计为 fire-and-forget,调用方无需 await, - /// 测速完成后通过 [onFastestDomainChanged] 回调通知结果。 - Future initWithDomains(List 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> 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((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.from(_reportDomains); - Future(() async { - try { - await PlatformStorage.write( - SdkConfig.domainCacheFileName, - jsonEncode(domainsSnapshot), - ); - Logger.domainManager('域名缓存已写入: $domainsSnapshot'); - } catch (e) { - Logger.domainManager('写入域名缓存失败: $e'); - } - }); - } - - /// 对所有域名进行并发测速,返回最快的域名 - Future _speedTestAndSelect(List domains) async { - if (domains.isEmpty) return null; - - final speedResults = {}; - - 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 _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; - } - } -} diff --git a/analytics_sdk/lib/manager/event_persistence.dart b/analytics_sdk/lib/manager/event_persistence.dart deleted file mode 100644 index b027b0f..0000000 --- a/analytics_sdk/lib/manager/event_persistence.dart +++ /dev/null @@ -1,7 +0,0 @@ -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 diff --git a/analytics_sdk/lib/manager/event_persistence_native.dart b/analytics_sdk/lib/manager/event_persistence_native.dart deleted file mode 100644 index cc43170..0000000 --- a/analytics_sdk/lib/manager/event_persistence_native.dart +++ /dev/null @@ -1,398 +0,0 @@ -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> _cacheBuffer = []; - static int get _cacheBufferThreshold => SdkConfig.cacheBufferThreshold; - static Duration get _cacheFlushInterval => SdkConfig.cacheFlushInterval; - Timer? _cacheFlushTimer; - - Future 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 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 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 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>> loadEvents({ - required Queue> 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 = >[]; - 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) { - 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 removeEvents(List> reportedEvents) async { - if (_tombstoneFilePath == null || reportedEvents.isEmpty) return; - - final ids = reportedEvents - .map((e) => e['event_id']) - .whereType() - .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 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>.from(_cacheBuffer); - _cacheBuffer.clear(); - Future.microtask(() async { - await _saveBatchToCache(batch); - }); - } - - Future flushBufferAsync() async { - if (_cacheFilePath == null || _cacheBuffer.isEmpty) return; - _cacheFlushTimer?.cancel(); - _cacheFlushTimer = null; - final batch = List>.from(_cacheBuffer); - _cacheBuffer.clear(); - await _saveBatchToCache(batch); - } - - /// 发送前记录 batch 中的 event_id,便于诊断未确认请求。 - /// - /// 崩溃重启后这些事件会与缓存一起恢复并重试,采用 at-least-once - /// 语义;服务端应按 event_id 去重。 - Future markInFlight(List> batch) async { - if (_inFlightFilePath == null) return; - final ids = batch - .map((e) => e['event_id']) - .whereType() - .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 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 _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 _saveBatchToCache(List> 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> _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> _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 _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 = []; - int removed = 0; - - for (final line in lines) { - if (line.trim().isEmpty) continue; - try { - final decoded = jsonDecode(line); - if (decoded is Map) { - 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'); - } - } -} diff --git a/analytics_sdk/lib/manager/event_persistence_web.dart b/analytics_sdk/lib/manager/event_persistence_web.dart deleted file mode 100644 index e9c9d8c..0000000 --- a/analytics_sdk/lib/manager/event_persistence_web.dart +++ /dev/null @@ -1,35 +0,0 @@ -import 'dart:collection'; - -/// Web 平台事件持久化:内存 only,所有持久化操作为空实现。 -/// Web 会话通常短暂,不需要跨页面持久化事件。 -class EventPersistenceImpl { - Future init() async {} - - String? get debugReportEventLogFilePath => null; - - void appendDebugReportEvent(Map event) {} - - void bufferEvent(Map event) {} - - void persistCriticalEvent(Map event) {} - - Future>> loadEvents({ - required Queue> queue, - required int maxQueueSize, - required int maxCacheLines, - required bool Function(String) isEventTypeEnabled, - }) async => - const []; - - Future removeEvents(List> events) async {} - - Future markInFlight(List> events) async {} - - Future clearInFlight() async {} - - Future clearCache() async {} - - void flushBuffer() {} - - Future flushBufferAsync() async {} -} diff --git a/analytics_sdk/lib/manager/event_processor.dart b/analytics_sdk/lib/manager/event_processor.dart deleted file mode 100644 index d6ff614..0000000 --- a/analytics_sdk/lib/manager/event_processor.dart +++ /dev/null @@ -1,552 +0,0 @@ -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> _eventQueue = Queue>(); - - 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 _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 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 flush() async { - try { - await uploadBatch(); - } catch (e) { - Logger.analyticsSdk('flush() 发生异常,已安全处理: $e'); - } - } - - /// 批量上报队列中的事件 - Future 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>.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().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 _ensureEventSignatureBeforeUpload( - Map 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 json) { - if (!SdkConfig.enableDebugReportLog) return; - try { - Logger.analyticsSdkDeveloperLog( - 'accepted event json: ${jsonEncode(json)}', - ); - } catch (e) { - Logger.analyticsSdk('事件日志打印失败: $e', level: LogLevel.warn); - } - } - - void _writeDebugReportEvent(Map 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> batch) { - return jsonEncode(batch); - } - - /// 在后台 isolate 中异步执行 JSON 编码,避免阻塞主线程 - Future _encodeBatchAsync(List> 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 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 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 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 removeEventsFromCacheForTest( - List> events) => - _persistence.removeEvents(events); - - /// 测试专用入口:注入 HTTP client 以验证最终请求体。 - @visibleForTesting - void setHttpClientForTest(http.Client client) { - _httpClient?.close(); - _httpClient = client; - } - - /// 异步释放资源(先 await 缓存刷写,再释放其他资源) - /// - /// 推荐在 SDK dispose 时使用此方法替代 [dispose],确保缓存 buffer 写入磁盘。 - Future disposeAsync() async { - try { - stopAutoUploadTimer(); - _retryTimer?.cancel(); - _retryTimer = null; - _isUploading = false; - await _persistence.flushBufferAsync(); - _httpClient?.close(); - _httpClient = null; - } catch (e) { - Logger.analyticsSdk('EventProcessor.disposeAsync 异常: $e'); - } - } -} diff --git a/analytics_sdk/lib/manager/event_type_config_manager.dart b/analytics_sdk/lib/manager/event_type_config_manager.dart deleted file mode 100644 index e16d9f5..0000000 --- a/analytics_sdk/lib/manager/event_type_config_manager.dart +++ /dev/null @@ -1,168 +0,0 @@ -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 _enabledEventTypes = {}; - - /// 获取已启用的事件类型集合 - Set 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 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 loadCachedConfig() async { - try { - await _loadCachedConfig(); - } catch (e) { - Logger.eventTypeConfigManager('加载缓存配置异常: $e'); - } - } - - /// 清理资源 - void dispose() { - _enabledEventTypes.clear(); - } - - /// 解析 JSON 字符串,提取事件类型列表(供外部验证用,如 validateEncryptedConfig) - /// - /// 返回 List,解析失败或结果为空则返回空列表,不抛出异常。 - static List 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((e) => e.toString().trim()) - .where((e) => e.isNotEmpty) - .toList(); - } else if (decoded is Map) { - final list = decoded['enabled_event_types'] as List? ?? - decoded['event_types'] as List? ?? - decoded['data'] as List?; - if (list != null) { - return list - .map((e) => e.toString().trim()) - .where((e) => e.isNotEmpty) - .toList(); - } - } - } catch (_) {} - return []; - } - - // ==================== 私有方法 ==================== - - /// 解析 JSON 字符串,提取事件类型列表 - Set _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((e) => e.toString().trim()) - .where((e) => e.isNotEmpty) - .toSet(); - } else if (decoded is Map) { - final list = decoded['enabled_event_types'] as List? ?? - decoded['event_types'] as List? ?? - decoded['data'] as List?; - if (list != null) { - return list - .map((e) => e.toString().trim()) - .where((e) => e.isNotEmpty) - .toSet(); - } - } - } catch (e) { - Logger.eventTypeConfigManager('事件类型 JSON 解析失败: $e'); - } - return {}; - } - - Future _saveConfigToCache(Set 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 _loadCachedConfig() async { - try { - final content = - await PlatformStorage.read(SdkConfig.eventTypeConfigCacheFileName); - if (content == null) { - Logger.eventTypeConfigManager('缓存文件不存在'); - return; - } - final json = jsonDecode(content) as Map; - final list = json['enabled_event_types']; - if (list is List) { - _enabledEventTypes = (list as List) - .map((e) => e.toString().trim()) - .where((e) => e.isNotEmpty) - .toSet(); - Logger.eventTypeConfigManager( - '从缓存加载 ${_enabledEventTypes.length} 个事件类型'); - } - } catch (e) { - Logger.eventTypeConfigManager('加载缓存失败: $e'); - } - } -} diff --git a/analytics_sdk/lib/manager/network_state_fields_stub.dart b/analytics_sdk/lib/manager/network_state_fields_stub.dart deleted file mode 100644 index cb5f84b..0000000 --- a/analytics_sdk/lib/manager/network_state_fields_stub.dart +++ /dev/null @@ -1,3 +0,0 @@ -Future> collectNetworkStateFields() async { - return const {}; -} diff --git a/analytics_sdk/lib/manager/network_state_fields_web.dart b/analytics_sdk/lib/manager/network_state_fields_web.dart deleted file mode 100644 index d6ffd81..0000000 --- a/analytics_sdk/lib/manager/network_state_fields_web.dart +++ /dev/null @@ -1,65 +0,0 @@ -// ignore: avoid_web_libraries_in_flutter -import 'dart:html' as html; - -import 'package:analytics_sdk/utils/js_interop_compat.dart'; - -Future> collectNetworkStateFields() async { - final connection = _connection(html.window.navigator); - if (connection == null) return const {}; - final fields = {}; - 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; -} diff --git a/analytics_sdk/lib/manager/page_click_auto_fields.dart b/analytics_sdk/lib/manager/page_click_auto_fields.dart deleted file mode 100644 index ee4c62b..0000000 --- a/analytics_sdk/lib/manager/page_click_auto_fields.dart +++ /dev/null @@ -1,2 +0,0 @@ -export 'page_click_auto_fields_stub.dart' - if (dart.library.html) 'page_click_auto_fields_web.dart'; diff --git a/analytics_sdk/lib/manager/page_click_auto_fields_stub.dart b/analytics_sdk/lib/manager/page_click_auto_fields_stub.dart deleted file mode 100644 index b92ac4e..0000000 --- a/analytics_sdk/lib/manager/page_click_auto_fields_stub.dart +++ /dev/null @@ -1,33 +0,0 @@ -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; -} diff --git a/analytics_sdk/lib/manager/page_click_auto_fields_web.dart b/analytics_sdk/lib/manager/page_click_auto_fields_web.dart deleted file mode 100644 index 21f7153..0000000 --- a/analytics_sdk/lib/manager/page_click_auto_fields_web.dart +++ /dev/null @@ -1,77 +0,0 @@ -// 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 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; diff --git a/analytics_sdk/lib/manager/page_name_manager.dart b/analytics_sdk/lib/manager/page_name_manager.dart deleted file mode 100644 index e3b62e9..0000000 --- a/analytics_sdk/lib/manager/page_name_manager.dart +++ /dev/null @@ -1,177 +0,0 @@ -// 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 _defaultMap = {}; - - // 老格式:精确匹配映射表(路由名 → pageName) - static final Map _customMap = {}; - - // 新格式::param 模式映射表(pattern → ResolvedPageInfo) - static final Map _patternMap = {}; - - // 新格式 resolvedKey 缓存(resolvedKey → pageName),与 _customMap 隔离 - // 供 getPageName 在 currentPageKey 已被解析为稳定 key 时反查 pageName - static final Map _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 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); -} diff --git a/analytics_sdk/lib/manager/page_observer_registration_guard.dart b/analytics_sdk/lib/manager/page_observer_registration_guard.dart deleted file mode 100644 index 0613a03..0000000 --- a/analytics_sdk/lib/manager/page_observer_registration_guard.dart +++ /dev/null @@ -1,41 +0,0 @@ -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, - ); - } - } -} diff --git a/analytics_sdk/lib/manager/page_view_auto_fields.dart b/analytics_sdk/lib/manager/page_view_auto_fields.dart deleted file mode 100644 index 1609db2..0000000 --- a/analytics_sdk/lib/manager/page_view_auto_fields.dart +++ /dev/null @@ -1,2 +0,0 @@ -export 'page_view_auto_fields_stub.dart' - if (dart.library.html) 'page_view_auto_fields_web.dart'; diff --git a/analytics_sdk/lib/manager/page_view_auto_fields_stub.dart b/analytics_sdk/lib/manager/page_view_auto_fields_stub.dart deleted file mode 100644 index 2c0657a..0000000 --- a/analytics_sdk/lib/manager/page_view_auto_fields_stub.dart +++ /dev/null @@ -1 +0,0 @@ -String currentPageUrl() => ''; diff --git a/analytics_sdk/lib/manager/page_view_auto_fields_web.dart b/analytics_sdk/lib/manager/page_view_auto_fields_web.dart deleted file mode 100644 index 02dd5d4..0000000 --- a/analytics_sdk/lib/manager/page_view_auto_fields_web.dart +++ /dev/null @@ -1,27 +0,0 @@ -// 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 ''; - } -} diff --git a/analytics_sdk/lib/manager/sdk_activation_store.dart b/analytics_sdk/lib/manager/sdk_activation_store.dart deleted file mode 100644 index 9cc1526..0000000 --- a/analytics_sdk/lib/manager/sdk_activation_store.dart +++ /dev/null @@ -1,21 +0,0 @@ -import 'package:analytics_sdk/utils/platform_storage.dart'; - -class SdkActivationStore { - SdkActivationStore._(); - - static const String _storageKey = 'analytics_sdk_activation_time.txt'; - - static Future 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; - } - } -} diff --git a/analytics_sdk/lib/manager/sdk_context.dart b/analytics_sdk/lib/manager/sdk_context.dart deleted file mode 100644 index b14dc9b..0000000 --- a/analytics_sdk/lib/manager/sdk_context.dart +++ /dev/null @@ -1,439 +0,0 @@ -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 _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? 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 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 get environmentFields => - Map.from(_environmentFields); - - /// 获取设备公共字段(供产品方在服务端上报事件时复用) - /// - /// 返回的 Map 包含:sid、device、device_id、device_brand、device_model、 - /// user_agent、system_name、system_version、device_fingerprint、fp_version。 - /// 未配置的字段返回空字符串。 - static Map 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 getEnvironmentSnapshotFields() { - return Map.from(_environmentFields); - } - - /// 给 SDK 客户端事件补充外层签名字段。 - /// - /// 历史版本曾把轻量环境签名写入 payload;这里会清理这些保留字段, - /// 避免同一事件同时出现新旧两套签名。 - static Map attachDeviceEnvIfNeeded( - Map event, - ) { - final eventName = event['event']?.toString() ?? ''; - final isServerOnlyEvent = _serverOnlyEvents.contains(eventName); - final result = Map.from(event); - final existingPayload = result['payload']; - final existingDeviceEnv = result.remove('device_env'); - - if (existingPayload is Map || existingPayload == null) { - final payload = { - if (existingPayload is Map) - ...Map.from(existingPayload), - if (existingDeviceEnv is Map) - ...Map.from(existingDeviceEnv), - }; - payload.removeWhere( - (key, _) => _reservedPayloadSignatureFields.contains(key)); - result['payload'] = payload; - } - - if (!isServerOnlyEvent && _eventSignatureHmacKey.isNotEmpty) { - final eventForSignature = Map.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 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 fields) { - try { - final fieldDigests = []; - 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 _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 _normalizeEnvironmentFields( - Map fields, - ) { - final normalized = Map.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 _serverOnlyEvents = { - 'user_register', - 'user_login', - 'realtime_online', - 'order_created', - 'order_paid', - 'coin_consume', - 'video_status_change', - }; - - static const Set _reservedPayloadSignatureFields = { - 'sdk_type', - 'client_type', - 'id_type', - 'event_sig', - 'event_sig_version', - 'security_key_version', - 'sign_algorithm', - 'sign_ts', - }; - - static String _eventSignature( - Map 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 commonFields, List 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, - ]); - } -} diff --git a/analytics_sdk/lib/manager/sdk_init_payload_builder.dart b/analytics_sdk/lib/manager/sdk_init_payload_builder.dart deleted file mode 100644 index a3efad9..0000000 --- a/analytics_sdk/lib/manager/sdk_init_payload_builder.dart +++ /dev/null @@ -1,239 +0,0 @@ -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 build({ - required Map commonFields, - required bool isWeb, - Map platformFields = const {}, - }) { - final fields = { - ...commonFields, - ...platformFields, - }; - - final osFamily = _osFamily(_string(fields['system_name'], 'unknown')); - final payload = { - '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 _nativeSharedPayload( - Map 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 _androidPayload(Map 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 _iosPayload(Map 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 _flutterPayload() { - return const {}; - } - - static Map _webPayload(Map 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'; - } -} diff --git a/analytics_sdk/lib/manager/sdk_init_platform_fields.dart b/analytics_sdk/lib/manager/sdk_init_platform_fields.dart deleted file mode 100644 index 637c7bc..0000000 --- a/analytics_sdk/lib/manager/sdk_init_platform_fields.dart +++ /dev/null @@ -1,3 +0,0 @@ -export 'sdk_init_platform_fields_native.dart' - // ignore: uri_does_not_exist - if (dart.library.html) 'sdk_init_platform_fields_web.dart'; diff --git a/analytics_sdk/lib/manager/sdk_init_platform_fields_native.dart b/analytics_sdk/lib/manager/sdk_init_platform_fields_native.dart deleted file mode 100644 index 38a6472..0000000 --- a/analytics_sdk/lib/manager/sdk_init_platform_fields_native.dart +++ /dev/null @@ -1,61 +0,0 @@ -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> collectSdkInitPlatformFields() async { - final fields = {}; - - 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> _collectNativeChannelFields() async { - try { - final result = await _nativeChannel - .invokeMapMethod('getSdkInitPlatformFields'); - if (result == null) return const {}; - return Map.from(result) - ..removeWhere((key, value) => value == null); - } on MissingPluginException { - return const {}; - } catch (_) { - return const {}; - } -} diff --git a/analytics_sdk/lib/manager/sdk_init_platform_fields_web.dart b/analytics_sdk/lib/manager/sdk_init_platform_fields_web.dart deleted file mode 100644 index 4d1809e..0000000 --- a/analytics_sdk/lib/manager/sdk_init_platform_fields_web.dart +++ /dev/null @@ -1,540 +0,0 @@ -// 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> 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 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 = { - '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 _webglParams(Object gl) { - const parameters = [ - 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 = []; - 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? _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 = {}; - 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 = []; - 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 _plugins(html.Navigator navigator) { - try { - return _indexedPropertyList(getJsProperty(navigator, 'plugins'), 'name'); - } catch (_) { - return const []; - } -} - -List _mimes(html.Navigator navigator) { - try { - return _indexedPropertyList(getJsProperty(navigator, 'mimeTypes'), 'type'); - } catch (_) { - return const []; - } -} - -List _indexedPropertyList(Object? collection, String property) { - if (collection == null) return const []; - final length = _number(collection, 'length', 0).round(); - if (length <= 0) return const []; - final values = []; - 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 _jsStringArray(Object? collection) { - if (collection == null) return const []; - final length = _number(collection, 'length', 0).round(); - if (length <= 0) return const []; - final values = []; - 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 _headlessReasons(html.Navigator navigator, List plugins) { - final reasons = []; - 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 _unique(List values) { - final seen = {}; - 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(); -} diff --git a/analytics_sdk/lib/manager/session_behavior_manager.dart b/analytics_sdk/lib/manager/session_behavior_manager.dart deleted file mode 100644 index 481f0d0..0000000 --- a/analytics_sdk/lib/manager/session_behavior_manager.dart +++ /dev/null @@ -1,292 +0,0 @@ -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 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 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, - }); -} diff --git a/analytics_sdk/lib/manager/session_behavior_monitor.dart b/analytics_sdk/lib/manager/session_behavior_monitor.dart deleted file mode 100644 index cc0b65d..0000000 --- a/analytics_sdk/lib/manager/session_behavior_monitor.dart +++ /dev/null @@ -1,2 +0,0 @@ -export 'session_behavior_monitor_stub.dart' - if (dart.library.html) 'session_behavior_monitor_web.dart'; diff --git a/analytics_sdk/lib/manager/session_behavior_monitor_stub.dart b/analytics_sdk/lib/manager/session_behavior_monitor_stub.dart deleted file mode 100644 index b8e0d01..0000000 --- a/analytics_sdk/lib/manager/session_behavior_monitor_stub.dart +++ /dev/null @@ -1,10 +0,0 @@ -class SessionBehaviorMonitor { - static final SessionBehaviorMonitor instance = - SessionBehaviorMonitor._internal(); - - SessionBehaviorMonitor._internal(); - - void start() {} - - void dispose() {} -} diff --git a/analytics_sdk/lib/manager/session_behavior_monitor_web.dart b/analytics_sdk/lib/manager/session_behavior_monitor_web.dart deleted file mode 100644 index 1dd8a7a..0000000 --- a/analytics_sdk/lib/manager/session_behavior_monitor_web.dart +++ /dev/null @@ -1,195 +0,0 @@ -// 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> _subscriptions = []; - final Map _touchStarts = {}; - final Map> _activeTouches = >{}; - - 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 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 a, math.Point 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 point; - final DateTime startedAt; - - const _TouchStart({ - required this.point, - required this.startedAt, - }); -} diff --git a/analytics_sdk/lib/manager/session_manager.dart b/analytics_sdk/lib/manager/session_manager.dart deleted file mode 100644 index 1ec848d..0000000 --- a/analytics_sdk/lib/manager/session_manager.dart +++ /dev/null @@ -1,316 +0,0 @@ -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 _effectiveLifecycleKeys = { - 'cold_start', - 'web_load', - 'warm_start', - 'foreground', - }; - - static const Set _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 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 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 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() ?? ''; - } -} diff --git a/analytics_sdk/lib/manager/session_metrics_manager.dart b/analytics_sdk/lib/manager/session_metrics_manager.dart deleted file mode 100644 index 5b0cfef..0000000 --- a/analytics_sdk/lib/manager/session_metrics_manager.dart +++ /dev/null @@ -1,300 +0,0 @@ -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 _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 _pageKeys = {}; - final Set _pagesWithInteraction = {}; - final List _behaviorSequence = []; - final List<_PageStayRecord> _activePageStays = <_PageStayRecord>[]; - final List<_PageStayRecord> _completedPageStays = <_PageStayRecord>[]; - - void recordAcceptedEvent(Map 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 event) { - final pageKey = _payloadPageKey(event); - if (pageKey.isNotEmpty) _pageKeys.add(pageKey); - } - - String _payloadPageKey(Map event) { - return _payloadString(event, 'page_key'); - } - - String _payloadString( - Map 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> _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> 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 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, - }; - } -} diff --git a/analytics_sdk/lib/manager/session_sensor_proximity.dart b/analytics_sdk/lib/manager/session_sensor_proximity.dart deleted file mode 100644 index c1f5b41..0000000 --- a/analytics_sdk/lib/manager/session_sensor_proximity.dart +++ /dev/null @@ -1,2 +0,0 @@ -export 'session_sensor_proximity_stub.dart' - if (dart.library.io) 'session_sensor_proximity_native.dart'; diff --git a/analytics_sdk/lib/manager/session_sensor_proximity_native.dart b/analytics_sdk/lib/manager/session_sensor_proximity_native.dart deleted file mode 100644 index 5e29f7f..0000000 --- a/analytics_sdk/lib/manager/session_sensor_proximity_native.dart +++ /dev/null @@ -1,17 +0,0 @@ -import 'dart:io'; - -import 'package:flutter/services.dart'; - -const EventChannel _proximityChannel = EventChannel('analytics_sdk/proximity'); - -Stream proximityEvents() { - if (!Platform.isAndroid && !Platform.isIOS) { - return const Stream.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; - }); -} diff --git a/analytics_sdk/lib/manager/session_sensor_proximity_stub.dart b/analytics_sdk/lib/manager/session_sensor_proximity_stub.dart deleted file mode 100644 index 9990d14..0000000 --- a/analytics_sdk/lib/manager/session_sensor_proximity_stub.dart +++ /dev/null @@ -1 +0,0 @@ -Stream proximityEvents() => const Stream.empty(); diff --git a/analytics_sdk/lib/manager/session_sensor_sampler.dart b/analytics_sdk/lib/manager/session_sensor_sampler.dart deleted file mode 100644 index e8085b2..0000000 --- a/analytics_sdk/lib/manager/session_sensor_sampler.dart +++ /dev/null @@ -1,467 +0,0 @@ -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 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> _subscriptions = - >[]; - final Map _stats = - {}; - - 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 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 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(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 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 = >[]; - 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 = ['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 _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); - } -} diff --git a/analytics_sdk/lib/manager/user_manager.dart b/analytics_sdk/lib/manager/user_manager.dart deleted file mode 100644 index 97ba935..0000000 --- a/analytics_sdk/lib/manager/user_manager.dart +++ /dev/null @@ -1,35 +0,0 @@ -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',根据业务决定 - } -} diff --git a/analytics_sdk/lib/observer/app_lifecycle_observer.dart b/analytics_sdk/lib/observer/app_lifecycle_observer.dart deleted file mode 100644 index 1ef675f..0000000 --- a/analytics_sdk/lib/observer/app_lifecycle_observer.dart +++ /dev/null @@ -1,160 +0,0 @@ -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; - } -} diff --git a/analytics_sdk/lib/observer/app_lifecycle_web_unload_stub.dart b/analytics_sdk/lib/observer/app_lifecycle_web_unload_stub.dart deleted file mode 100644 index 999604e..0000000 --- a/analytics_sdk/lib/observer/app_lifecycle_web_unload_stub.dart +++ /dev/null @@ -1,8 +0,0 @@ -typedef AppLifecycleWebUnloadDisposer = void Function(); - -AppLifecycleWebUnloadDisposer registerAppLifecycleWebUnloadListener({ - required void Function(dynamic event) track, - required DateTime pageEnterTime, -}) { - return () {}; -} diff --git a/analytics_sdk/lib/observer/app_lifecycle_web_unload_web.dart b/analytics_sdk/lib/observer/app_lifecycle_web_unload_web.dart deleted file mode 100644 index e01b790..0000000 --- a/analytics_sdk/lib/observer/app_lifecycle_web_unload_web.dart +++ /dev/null @@ -1,88 +0,0 @@ -// 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? 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'; -} diff --git a/analytics_sdk/lib/observer/page_lifecycle_observer.dart b/analytics_sdk/lib/observer/page_lifecycle_observer.dart deleted file mode 100644 index a40e6a8..0000000 --- a/analytics_sdk/lib/observer/page_lifecycle_observer.dart +++ /dev/null @@ -1,458 +0,0 @@ -// 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 _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 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 _activeTimers = {}; - - /// 本 observer 管辖的路由集合,用于 dispose 时精准清理, - /// 避免多 Navigator 场景下影响其他 observer 的堆栈条目。 - final Set _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; -} diff --git a/analytics_sdk/lib/utils/aes_gcm_util.dart b/analytics_sdk/lib/utils/aes_gcm_util.dart deleted file mode 100644 index 92af02f..0000000 --- a/analytics_sdk/lib/utils/aes_gcm_util.dart +++ /dev/null @@ -1,346 +0,0 @@ -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 data) { - final encoder = GZipEncoder(); - final compressed = encoder.encode(data) ?? []; - return Uint8List.fromList(compressed); - } - - /// GZIP 解码(使用 package:archive,兼容 Web) - static Uint8List gzipDecode(List 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 decryptResponseToMap( - String keyBase64, - String responseBody, { - bool useGzip = false, - }) { - final decryptedJson = - decryptResponse(keyBase64, responseBody, useGzip: useGzip); - return jsonDecode(decryptedJson) as Map; - } - - /// 解密接口响应并解析为指定类型 - /// 接口直接返回加密字符串,解密后再按 JSON 解析并转为 T。 - /// [keyBase64] Base64 编码的密钥 - /// [responseBody] 接口返回的加密字符串 - /// [fromJson] JSON 解析函数 - /// [useGzip] 是否对解密结果再做 GZIP 解压(默认:false) - static T decryptResponseToObject( - String keyBase64, - String responseBody, - T Function(Map) fromJson, { - bool useGzip = false, - }) { - final decryptedMap = - decryptResponseToMap(keyBase64, responseBody, useGzip: useGzip); - return fromJson(decryptedMap); - } -} diff --git a/analytics_sdk/lib/utils/device_fingerprint_native.dart b/analytics_sdk/lib/utils/device_fingerprint_native.dart deleted file mode 100644 index 99aad97..0000000 --- a/analytics_sdk/lib/utils/device_fingerprint_native.dart +++ /dev/null @@ -1,52 +0,0 @@ -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 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 ''; - } -} diff --git a/analytics_sdk/lib/utils/device_fingerprint_util.dart b/analytics_sdk/lib/utils/device_fingerprint_util.dart deleted file mode 100644 index b40690e..0000000 --- a/analytics_sdk/lib/utils/device_fingerprint_util.dart +++ /dev/null @@ -1,216 +0,0 @@ -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 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 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 payload) { - if (_string(payload['platform']) == 'web') return 'web'; - final osFamily = _string(payload['os_family']); - if (osFamily == 'ios') return 'ios'; - return 'android'; - } - - static List _androidFingerprintValues(Map 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 _iosFingerprintValues(Map 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 _webFingerprintValues(Map 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 payload, - List 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 payload, String key) => - '$key=${_string(payload[key])}'; - - static String _kvInt(Map 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); -} diff --git a/analytics_sdk/lib/utils/device_fingerprint_web.dart b/analytics_sdk/lib/utils/device_fingerprint_web.dart deleted file mode 100644 index 0bfcca7..0000000 --- a/analytics_sdk/lib/utils/device_fingerprint_web.dart +++ /dev/null @@ -1,78 +0,0 @@ -// 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 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 _collectBrowserEnv() { - final factors = []; - 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; -} diff --git a/analytics_sdk/lib/utils/device_id_native.dart b/analytics_sdk/lib/utils/device_id_native.dart deleted file mode 100644 index 6d52bea..0000000 --- a/analytics_sdk/lib/utils/device_id_native.dart +++ /dev/null @@ -1,29 +0,0 @@ -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 generateOrLoadDeviceId() async { - try { - final nativeDeviceId = - (await _kNativeChannel.invokeMethod('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 ''; - } -} diff --git a/analytics_sdk/lib/utils/device_id_util.dart b/analytics_sdk/lib/utils/device_id_util.dart deleted file mode 100644 index 0f27454..0000000 --- a/analytics_sdk/lib/utils/device_id_util.dart +++ /dev/null @@ -1,13 +0,0 @@ -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 getDeviceId() => generateOrLoadDeviceId(); -} diff --git a/analytics_sdk/lib/utils/device_id_web.dart b/analytics_sdk/lib/utils/device_id_web.dart deleted file mode 100644 index 3cd9e5c..0000000 --- a/analytics_sdk/lib/utils/device_id_web.dart +++ /dev/null @@ -1,18 +0,0 @@ -import 'package:analytics_sdk/utils/platform_storage.dart'; -import 'package:analytics_sdk/utils/uuid_util.dart'; - -const _kDeviceIdStorageKey = 'analytics_device_id'; - -Future 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 ''; - } -} diff --git a/analytics_sdk/lib/utils/device_info_native.dart b/analytics_sdk/lib/utils/device_info_native.dart deleted file mode 100644 index c56a323..0000000 --- a/analytics_sdk/lib/utils/device_info_native.dart +++ /dev/null @@ -1,60 +0,0 @@ -import 'dart:io'; - -import 'package:device_info_plus/device_info_plus.dart'; - -String? _cachedBrand; -String? _cachedModel; -String? _cachedVersion; - -Future 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() => ''; diff --git a/analytics_sdk/lib/utils/device_info_util.dart b/analytics_sdk/lib/utils/device_info_util.dart deleted file mode 100644 index 8f85147..0000000 --- a/analytics_sdk/lib/utils/device_info_util.dart +++ /dev/null @@ -1,36 +0,0 @@ -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 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(); -} diff --git a/analytics_sdk/lib/utils/device_info_web.dart b/analytics_sdk/lib/utils/device_info_web.dart deleted file mode 100644 index 2da56f2..0000000 --- a/analytics_sdk/lib/utils/device_info_web.dart +++ /dev/null @@ -1,86 +0,0 @@ -// ignore: avoid_web_libraries_in_flutter -import 'dart:html' as html; - -/// Web 端设备信息:浏览器无法稳定获取品牌/型号,系统名和版本从 UA 解析。 -Future 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); -} diff --git a/analytics_sdk/lib/utils/environment_info_native.dart b/analytics_sdk/lib/utils/environment_info_native.dart deleted file mode 100644 index 3737900..0000000 --- a/analytics_sdk/lib/utils/environment_info_native.dart +++ /dev/null @@ -1,119 +0,0 @@ -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> collectEnvironmentInfo() async { - final screenInfo = _screenInfo(); - final batteryState = await _batteryState(); - final fields = { - '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 _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 _batteryLevel() async { - try { - return await Battery().batteryLevel; - } catch (_) { - return -1; - } -} - -Future _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 _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': '', - }; - } -} diff --git a/analytics_sdk/lib/utils/environment_info_util.dart b/analytics_sdk/lib/utils/environment_info_util.dart deleted file mode 100644 index 007b27a..0000000 --- a/analytics_sdk/lib/utils/environment_info_util.dart +++ /dev/null @@ -1,291 +0,0 @@ -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 _fields = _defaultFields(); - static bool _initialized = false; - static bool _monitoring = false; - static DateTime? _lastBatteryRefreshAt; - static StreamSubscription? _connectivitySubscription; - static StreamSubscription? _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 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 = { - '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 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 refreshRuntimeObservationFields() async { - try { - _mergeFields(await runtime_observation.collectRuntimeObservationFields()); - } catch (_) {} - } - - /// 停止监听并清理订阅。 - static Future dispose() async { - _monitoring = false; - await _connectivitySubscription?.cancel(); - await _batteryStateSubscription?.cancel(); - await _runtimeObservationMonitor?.dispose(); - _connectivitySubscription = null; - _batteryStateSubscription = null; - _runtimeObservationMonitor = null; - _trackNetworkStateChange = null; - } - - /// 当前已缓存的环境快照字段。 - static Map get fields => Map.from(_fields); - - static void _mergeFields(Map fields) { - if (fields.isEmpty) return; - _fields = { - ..._fields, - ...fields, - }; - SdkContext.updateEnvironmentFields(fields); - } - - static Future _handleNetworkChange(ConnectivityResult result) async { - final webConnectionFields = - kIsWeb ? await collectNetworkStateFields() : const {}; - 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 _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 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 _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, - }; -} diff --git a/analytics_sdk/lib/utils/environment_info_web.dart b/analytics_sdk/lib/utils/environment_info_web.dart deleted file mode 100644 index dfe150a..0000000 --- a/analytics_sdk/lib/utils/environment_info_web.dart +++ /dev/null @@ -1,181 +0,0 @@ -// 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> 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 _packageName() async { - try { - final info = await PackageInfo.fromPlatform(); - return info.packageName; - } catch (_) { - return ''; - } -} - -Future _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 _batteryLevel() async { - try { - return await Battery().batteryLevel; - } catch (_) { - return -1; - } -} - -Future _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 = { - '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); -} diff --git a/analytics_sdk/lib/utils/event_validator.dart b/analytics_sdk/lib/utils/event_validator.dart deleted file mode 100644 index c83ab72..0000000 --- a/analytics_sdk/lib/utils/event_validator.dart +++ /dev/null @@ -1,592 +0,0 @@ -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 _criticalIdFields = { - 'video_id', - 'novel_id', - 'comic_id', - 'click_item_id', - }; - - /// 普通 ID 字段:校验失败仅移除该字段 - /// 注意:ad_id / recommend_id 已在 _commaSeparatedFields 中处理,不重复列入 - static const Set _normalIdFields = { - 'order_id', - 'item_id', - 'page_id', - }; - - /// 逗号分隔字段(分割 → trim → 过滤空 → 重新拼接) - static const Set _commaSeparatedFields = { - 'ad_id', - }; - - /// 名称字段,最大 128 字符 - static const Set _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 _titleFields = { - 'video_title', - 'novel_title', - 'comic_title', - }; - - /// 关键词/评论字段,最大 500 字符 - static const Set _keywordFields = { - 'keyword', - 'comment_content', - }; - - /// 百分比字段,范围 0~100 - static const Set _percentageFields = { - 'play_progress', - 'read_progress', - 'click_x_percent', - 'click_y_percent', - }; - - /// 位置字段,值 ≥ 1 - static const Set _positionFields = { - 'page_no', - 'click_position', - }; - - /// 非负数字字段,值 ≥ 0 - static const Set _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 _timestampFields = { - 'create_time', - 'vip_expiration_time', - }; - - // ───────────────────────────────────────────────────────── - // 公开 API - // ───────────────────────────────────────────────────────── - - /// 校验并自动纠正事件 Map。 - /// - /// 返回纠正后的 Map;若关键字段无法修复则返回 `null`(调用方应丢弃该事件)。 - /// 本方法永远不会抛出异常。 - static Map? validate(Map event) { - try { - final result = Map.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'] = {}; - } else if (rawPayload is! Map) { - Logger.analyticsSdk('校验失败:payload 类型错误(${rawPayload.runtimeType}),丢弃事件', - level: LogLevel.warn); - return null; - } else { - final validated = _validatePayload( - Map.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? _validatePayload( - Map payload, { - required String eventType, - }) { - final result = {}; - - 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(','); - } -} diff --git a/analytics_sdk/lib/utils/js_interop_compat.dart b/analytics_sdk/lib/utils/js_interop_compat.dart deleted file mode 100644 index 2efc663..0000000 --- a/analytics_sdk/lib/utils/js_interop_compat.dart +++ /dev/null @@ -1,2 +0,0 @@ -export 'js_interop_legacy.dart' - if (dart.library.js_interop) 'js_interop_modern.dart'; diff --git a/analytics_sdk/lib/utils/js_interop_legacy.dart b/analytics_sdk/lib/utils/js_interop_legacy.dart deleted file mode 100644 index 07a5182..0000000 --- a/analytics_sdk/lib/utils/js_interop_legacy.dart +++ /dev/null @@ -1,23 +0,0 @@ -// 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, property); - -bool hasJsProperty(Object object, String property) => - js_util.hasProperty(object, property); - -Object? callJsMethod( - Object object, - String method, [ - List arguments = const [], -]) => - js_util.callMethod(object, method, arguments); - -Object? constructJsObject( - Object constructor, [ - List arguments = const [], -]) => - js_util.callConstructor(constructor, arguments); diff --git a/analytics_sdk/lib/utils/js_interop_modern.dart b/analytics_sdk/lib/utils/js_interop_modern.dart deleted file mode 100644 index ec31db0..0000000 --- a/analytics_sdk/lib/utils/js_interop_modern.dart +++ /dev/null @@ -1,57 +0,0 @@ -// 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( - _propertyToJs(property), - ); - return _toDartValue(value); -} - -bool hasJsProperty(Object object, String property) => - (object as JSObject).has(property); - -Object? callJsMethod( - Object object, - String method, [ - List arguments = const [], -]) { - final value = (object as JSObject).callMethodVarArgs( - method.toJS, - arguments.map(_valueToJs).toList(), - ); - return _toDartValue(value); -} - -Object? constructJsObject( - Object constructor, [ - List arguments = const [], -]) => - (constructor as JSFunction).callAsConstructorVarArgs( - 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; -} diff --git a/analytics_sdk/lib/utils/logger.dart b/analytics_sdk/lib/utils/logger.dart deleted file mode 100644 index e4e13d4..0000000 --- a/analytics_sdk/lib/utils/logger.dart +++ /dev/null @@ -1,218 +0,0 @@ -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 _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); - } -} diff --git a/analytics_sdk/lib/utils/network_type_resolver.dart b/analytics_sdk/lib/utils/network_type_resolver.dart deleted file mode 100644 index 6c1a39b..0000000 --- a/analytics_sdk/lib/utils/network_type_resolver.dart +++ /dev/null @@ -1,57 +0,0 @@ -const Set 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; - } -} diff --git a/analytics_sdk/lib/utils/platform_storage.dart b/analytics_sdk/lib/utils/platform_storage.dart deleted file mode 100644 index 47158c4..0000000 --- a/analytics_sdk/lib/utils/platform_storage.dart +++ /dev/null @@ -1,14 +0,0 @@ -import 'platform_storage_native.dart' - // ignore: uri_does_not_exist - if (dart.library.html) 'platform_storage_web.dart'; - -/// 跨平台 key-value 存储。 -/// Native:文件存储(path_provider);Web:localStorage。 -/// key 即文件名(native)或 localStorage key(web),不含路径。 -class PlatformStorage { - static Future read(String key) => platformRead(key); - static Future write(String key, String value) => - platformWrite(key, value); - static Future has(String key) => platformHas(key); - static Future delete(String key) => platformDelete(key); -} diff --git a/analytics_sdk/lib/utils/platform_storage_native.dart b/analytics_sdk/lib/utils/platform_storage_native.dart deleted file mode 100644 index 7f2e083..0000000 --- a/analytics_sdk/lib/utils/platform_storage_native.dart +++ /dev/null @@ -1,38 +0,0 @@ -import 'dart:io'; - -import 'package:path_provider/path_provider.dart'; - -Future platformRead(String key) async { - try { - final dir = await getApplicationDocumentsDirectory(); - final file = File('${dir.path}/$key'); - if (!await file.exists()) return null; - return await file.readAsString(); - } catch (_) { - return null; - } -} - -Future platformWrite(String key, String value) async { - try { - final dir = await getApplicationDocumentsDirectory(); - await File('${dir.path}/$key').writeAsString(value); - } catch (_) {} -} - -Future platformHas(String key) async { - try { - final dir = await getApplicationDocumentsDirectory(); - return File('${dir.path}/$key').existsSync(); - } catch (_) { - return false; - } -} - -Future platformDelete(String key) async { - try { - final dir = await getApplicationDocumentsDirectory(); - final file = File('${dir.path}/$key'); - if (await file.exists()) await file.delete(); - } catch (_) {} -} diff --git a/analytics_sdk/lib/utils/platform_storage_web.dart b/analytics_sdk/lib/utils/platform_storage_web.dart deleted file mode 100644 index 109e129..0000000 --- a/analytics_sdk/lib/utils/platform_storage_web.dart +++ /dev/null @@ -1,14 +0,0 @@ -// ignore: avoid_web_libraries_in_flutter -import 'dart:html' as html; - -Future platformRead(String key) async => - html.window.localStorage[key]; - -Future platformWrite(String key, String value) async => - html.window.localStorage[key] = value; - -Future platformHas(String key) async => - html.window.localStorage.containsKey(key); - -Future platformDelete(String key) async => - html.window.localStorage.remove(key); diff --git a/analytics_sdk/lib/utils/runtime_observation_native.dart b/analytics_sdk/lib/utils/runtime_observation_native.dart deleted file mode 100644 index bf382b3..0000000 --- a/analytics_sdk/lib/utils/runtime_observation_native.dart +++ /dev/null @@ -1,164 +0,0 @@ -import 'dart:async'; -import 'dart:io' show Platform; -import 'dart:ui' show window; - -import 'package:flutter/widgets.dart'; -import 'package:screen_brightness/screen_brightness.dart'; -import 'package:screen_state/screen_state.dart'; -import 'package:volume_controller/volume_controller.dart'; - -/// Native runtime observation fields backed by platform plugins. -class RuntimeObservationMonitor { - final List> _subscriptions; - final WidgetsBindingObserver? _metricsObserver; - - const RuntimeObservationMonitor( - this._subscriptions, { - WidgetsBindingObserver? metricsObserver, - }) : _metricsObserver = metricsObserver; - - Future dispose() async { - for (final subscription in _subscriptions) { - await subscription.cancel(); - } - final metricsObserver = _metricsObserver; - if (metricsObserver != null) { - try { - WidgetsBinding.instance.removeObserver(metricsObserver); - } catch (_) {} - } - try { - VolumeController().removeListener(); - } catch (_) {} - } -} - -Future> collectRuntimeObservationFields() async { - return { - ..._screenObservationFields(), - 'screen_brightness': await _readScreenBrightness(), - 'volume': await _readVolume(), - 'is_screen_on': _initialScreenOn(), - }; -} - -RuntimeObservationMonitor startRuntimeObservationMonitoring( - void Function(Map fields) onFields, -) { - final subscriptions = >[]; - WidgetsBindingObserver? metricsObserver; - - try { - metricsObserver = _MetricsObserver( - onMetricsChanged: () => onFields(_screenObservationFields()), - ); - WidgetsBinding.instance.addObserver(metricsObserver); - } catch (_) {} - - try { - subscriptions.add(ScreenBrightness().onCurrentBrightnessChanged.listen( - (value) => onFields({ - 'screen_brightness': _normalizedPercent(value), - }), onError: (_) { - onFields({'screen_brightness': -1}); - })); - } catch (_) {} - - try { - subscriptions.add(VolumeController().listener((value) { - onFields({'volume': _normalizedPercent(value)}); - })); - } catch (_) {} - - try { - if (Platform.isAndroid || Platform.isIOS) { - subscriptions.add(Screen().screenStateStream.listen((event) { - onFields({'is_screen_on': _screenStateValue(event)}); - }, onError: (_) { - onFields({'is_screen_on': -1}); - })); - } - } catch (_) {} - - return RuntimeObservationMonitor( - subscriptions, - metricsObserver: metricsObserver, - ); -} - -Map _screenObservationFields() { - try { - final size = window.physicalSize; - final width = size.width.round(); - final height = size.height.round(); - final pixelRatio = (window.devicePixelRatio * 100).round(); - final dpi = (window.devicePixelRatio * 160).round(); - return { - 'screen_width': width, - 'screen_height': height, - 'screen_dpi': dpi, - 'device_pixel_ratio': pixelRatio, - 'orientation': width >= height ? 'landscape' : 'portrait', - }; - } catch (_) { - return const { - 'screen_width': -1, - 'screen_height': -1, - 'screen_dpi': -1, - 'device_pixel_ratio': -1, - 'orientation': '', - }; - } -} - -Future _readScreenBrightness() async { - try { - return _normalizedPercent(await ScreenBrightness().current); - } catch (_) { - return -1; - } -} - -Future _readVolume() async { - try { - return _normalizedPercent(await VolumeController().getVolume()); - } catch (_) { - return -1; - } -} - -int _initialScreenOn() { - try { - return Platform.isAndroid || Platform.isIOS ? 1 : -1; - } catch (_) { - return -1; - } -} - -int _screenStateValue(ScreenStateEvent event) { - switch (event) { - case ScreenStateEvent.SCREEN_ON: - case ScreenStateEvent.SCREEN_UNLOCKED: - return 1; - case ScreenStateEvent.SCREEN_OFF: - return 0; - } -} - -int _normalizedPercent(num value) { - final numeric = value.toDouble(); - if (!numeric.isFinite) return -1; - final scaled = numeric <= 1 ? numeric * 100 : numeric; - return scaled.clamp(0, 100).round(); -} - -class _MetricsObserver with WidgetsBindingObserver { - final void Function() onMetricsChanged; - - const _MetricsObserver({required this.onMetricsChanged}); - - @override - void didChangeMetrics() { - onMetricsChanged(); - } -} diff --git a/analytics_sdk/lib/utils/runtime_observation_stub.dart b/analytics_sdk/lib/utils/runtime_observation_stub.dart deleted file mode 100644 index de5dd3b..0000000 --- a/analytics_sdk/lib/utils/runtime_observation_stub.dart +++ /dev/null @@ -1,16 +0,0 @@ -/// Runtime observation fields for platforms without native observers. -class RuntimeObservationMonitor { - const RuntimeObservationMonitor(); - - Future dispose() async {} -} - -Future> collectRuntimeObservationFields() async { - return const {}; -} - -RuntimeObservationMonitor startRuntimeObservationMonitoring( - void Function(Map fields) onFields, -) { - return const RuntimeObservationMonitor(); -} diff --git a/analytics_sdk/lib/utils/uuid_util.dart b/analytics_sdk/lib/utils/uuid_util.dart deleted file mode 100644 index 01b2047..0000000 --- a/analytics_sdk/lib/utils/uuid_util.dart +++ /dev/null @@ -1,15 +0,0 @@ -import 'dart:math'; - -/// 生成符合 RFC 4122 的 UUID v4 字符串 -/// 替代 uuid 包,消除跨 Dart 版本的依赖约束冲突 -String generateUuidV4() { - final random = Random.secure(); - final bytes = List.generate(16, (_) => random.nextInt(256)); - bytes[6] = (bytes[6] & 0x0f) | 0x40; // version 4 - bytes[8] = (bytes[8] & 0x3f) | 0x80; // variant 1 - String toHex(int b) => b.toRadixString(16).padLeft(2, '0'); - return '${toHex(bytes[0])}${toHex(bytes[1])}${toHex(bytes[2])}${toHex(bytes[3])}-' - '${toHex(bytes[4])}${toHex(bytes[5])}-${toHex(bytes[6])}${toHex(bytes[7])}-' - '${toHex(bytes[8])}${toHex(bytes[9])}-' - '${toHex(bytes[10])}${toHex(bytes[11])}${toHex(bytes[12])}${toHex(bytes[13])}${toHex(bytes[14])}${toHex(bytes[15])}'; -} diff --git a/analytics_sdk/lib/utils/widget_bridge.dart b/analytics_sdk/lib/utils/widget_bridge.dart deleted file mode 100644 index 81ef892..0000000 --- a/analytics_sdk/lib/utils/widget_bridge.dart +++ /dev/null @@ -1,34 +0,0 @@ -/// 内部桥接层:解除 widget 对 AnalyticsSdk 的直接依赖。 -/// -/// AnalyticsSdk.init() 调用 [register] 注册实现; -/// widget 层通过此类的静态方法访问 SDK 能力, -/// 无需 import analytics_sdk.dart,避免上行依赖。 -class WidgetBridge { - static void Function(dynamic event)? _track; - static String Function()? _getUserType; - static bool _enableDebugBanner = false; - static List> Function()? _getDebugSteps; - - /// 由 AnalyticsSdk.init() 调用,注册 SDK 实现 - static void register({ - required void Function(dynamic event) track, - required String Function() getUserType, - required bool enableDebugBanner, - required List> Function() getDebugSteps, - }) { - _track = track; - _getUserType = getUserType; - _enableDebugBanner = enableDebugBanner; - _getDebugSteps = getDebugSteps; - } - - static void track(dynamic event) => _track?.call(event); - - /// 获取当前用户类型,SDK 未初始化时返回 'normal'。 - static String getUserType() => _getUserType?.call() ?? 'normal'; - - static bool get enableDebugBanner => _enableDebugBanner; - - static List> getDebugSteps() => - _getDebugSteps?.call() ?? []; -} diff --git a/analytics_sdk/lib/widget/_tab_helpers.dart b/analytics_sdk/lib/widget/_tab_helpers.dart deleted file mode 100644 index 1931212..0000000 --- a/analytics_sdk/lib/widget/_tab_helpers.dart +++ /dev/null @@ -1,20 +0,0 @@ -import 'package:analytics_sdk/entity/analytics_tab.dart'; -import 'package:analytics_sdk/manager/page_name_manager.dart'; -import 'package:analytics_sdk/utils/widget_bridge.dart'; - -/// Tab 解析与 track 分发的包内共享工具。 -/// -/// 供 widget 层各 analytics 包装使用,消除跨文件重复逻辑。 -/// 文件名以下划线开头,表示不对外暴露。 - -/// 按索引解析 tab,超出范围时用 tab_N 兜底。 -AnalyticsTab resolveTab(List tabs, int index) => - index < tabs.length ? tabs[index] : AnalyticsTab('tab_$index'); - -/// 解析 tab 展示名称,未配置时通过 PageNameMapper 自动映射。 -String resolveTabName(AnalyticsTab tab) => - tab.name ?? PageNameMapper.getPageName(tab.key); - -/// 上报事件:优先使用测试注入的 override,否则走 WidgetBridge.track。 -void trackWith(void Function(dynamic)? override, Object event) => - (override ?? WidgetBridge.track)(event); diff --git a/analytics_sdk/lib/widget/analytics_bottom_nav_wrapper.dart b/analytics_sdk/lib/widget/analytics_bottom_nav_wrapper.dart deleted file mode 100644 index a81ea05..0000000 --- a/analytics_sdk/lib/widget/analytics_bottom_nav_wrapper.dart +++ /dev/null @@ -1,163 +0,0 @@ -import 'package:analytics_sdk/entity/analytics_tab.dart'; -import 'package:analytics_sdk/entity/app_page_view_event.dart'; -import 'package:analytics_sdk/entity/navigation_event.dart'; -import 'package:analytics_sdk/manager/page_name_manager.dart'; -import 'package:analytics_sdk/observer/page_lifecycle_observer.dart'; -import 'package:analytics_sdk/utils/logger.dart'; -import 'package:analytics_sdk/utils/widget_bridge.dart'; -import 'package:analytics_sdk/widget/_tab_helpers.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/scheduler.dart'; - -/// BottomNavigationBar 的 analytics 包装。 -/// -/// 使用 Listener + LayoutBuilder 方案:保持原始 child 完整进入 widget 树, -/// 通过指针位移计算 tab 索引,无需拷贝 BottomNavigationBar 的所有属性。 -/// 这样 Flutter 升级新增属性时,SDK 不需要任何改动。 -/// -/// 不应直接使用;通过 [AnalyticsBottomNavExtension.withAnalytics] 调用。 -class AnalyticsBottomNavWrapper extends StatefulWidget { - final BottomNavigationBar child; - final List tabs; - - // ignore: invalid_use_of_visible_for_testing_member - /// 仅供测试使用;null 时走 AnalyticsSdk.instance.track。 - @visibleForTesting - final void Function(dynamic event)? onTrack; - - const AnalyticsBottomNavWrapper({ - super.key, - required this.child, - required this.tabs, - this.onTrack, - }); - - @override - State createState() => - _AnalyticsBottomNavWrapperState(); -} - -class _AnalyticsBottomNavWrapperState extends State { - /// 记录 pointer-down 位置,用于判断是否为 tap(而非拖拽) - Offset? _tapDownPosition; - - /// 去重:记录最近一次上报的 tab 索引,防止快速连点重复上报 - int _lastTrackedIndex = -1; - - @override - void didUpdateWidget(AnalyticsBottomNavWrapper oldWidget) { - super.didUpdateWidget(oldWidget); - // 父组件以编程方式切换 currentIndex 时同步去重状态 - if (widget.child.currentIndex != oldWidget.child.currentIndex) { - _lastTrackedIndex = widget.child.currentIndex; - } - } - - /// 纯计算:根据 tap 坐标解析 tab 索引。 - /// 返回 null 表示无效点击(越界、重复、当前已选中)。 - int? _indexFromTap( - double tapX, double totalWidth, TextDirection textDirection) { - final itemCount = widget.child.items.length; - if (itemCount == 0 || totalWidth <= 0) return null; - - var index = - (tapX / (totalWidth / itemCount)).floor().clamp(0, itemCount - 1); - - // RTL 布局下 BottomNavigationBar items 从右到左排列,镜像索引 - if (textDirection == TextDirection.rtl) { - index = itemCount - 1 - index; - } - - if (index == _lastTrackedIndex || index == widget.child.currentIndex) { - return null; - } - return index; - } - - /// 根据横坐标、容器宽度和文字方向解析 tab 索引并上报埋点。 - /// - /// 通过 [SchedulerBinding.addPostFrameCallback] 在渲染完成后上报 - /// [AppPageViewEvent](含来路 key)和 [NavigationEvent], - /// pageLoadTime 反映从点击到渲染完成的真实耗时,与 [PageLifecycleObserver] 保持一致。 - void _handleTapAt( - double tapX, double totalWidth, TextDirection textDirection) { - try { - if (widget.tabs.isEmpty) return; - - final index = _indexFromTap(tapX, totalWidth, textDirection); - if (index == null) return; - _lastTrackedIndex = index; - - final tab = resolveTab(widget.tabs, index); - final pageKey = tab.key; - final pageName = resolveTabName(tab); - - PageNameMapper.addMapping(pageKey, pageName); - final referrerKey = PageLifecycleObserver.currentPageKey; - final referrerName = PageNameMapper.getPageName(referrerKey); - PageLifecycleObserver.recordNavigation(pageKey); - - // 记录 tap 时刻;在下一帧渲染完成后测量实际耗时,与 PageLifecycleObserver 保持一致 - final tapTime = DateTime.now(); - final onTrackSnapshot = widget.onTrack; - SchedulerBinding.instance.addPostFrameCallback((_) { - final pageLoadTime = DateTime.now().difference(tapTime).inMilliseconds; - try { - trackWith( - onTrackSnapshot, - AppPageViewEvent( - userType: WidgetBridge.getUserType(), - pageKey: pageKey, - pageName: pageName, - referrerPageKey: referrerKey, - referrerPageName: referrerName, - currentPageKey: pageKey, - currentPageName: pageName, - pageLoadTime: pageLoadTime, - pageEnterTs: tapTime.millisecondsSinceEpoch ~/ 1000, - )); - trackWith( - onTrackSnapshot, - NavigationEvent( - navigationKey: pageKey, - navigationName: pageName, - )); - } catch (e) { - Logger.analyticsSdk('BottomNavWrapper.post-frame 上报异常,已安全处理: $e'); - } - }); - } catch (e) { - Logger.analyticsSdk('BottomNavWrapper._handleTapAt 异常,已安全处理: $e'); - } - } - - @override - Widget build(BuildContext context) { - return LayoutBuilder( - builder: (context, constraints) { - final textDirection = Directionality.of(context); - return Listener( - // 透传所有指针事件,不干扰 BottomNavigationBar 自身的手势识别 - behavior: HitTestBehavior.translucent, - onPointerDown: (event) { - _tapDownPosition = event.localPosition; - }, - onPointerUp: (event) { - final down = _tapDownPosition; - _tapDownPosition = null; - if (down == null) return; - // 位移 > 18px(与 Flutter kTouchSlop 对齐)视为拖拽,不触发 tab 埋点 - if ((event.localPosition - down).distance > 18) return; - _handleTapAt( - event.localPosition.dx, constraints.maxWidth, textDirection); - }, - onPointerCancel: (_) { - _tapDownPosition = null; - }, - // 原始 child 完整保留,onTap 由 BottomNavigationBar 自身调用 - child: widget.child, - ); - }, - ); - } -} diff --git a/analytics_sdk/lib/widget/analytics_debug_banner.dart b/analytics_sdk/lib/widget/analytics_debug_banner.dart deleted file mode 100644 index 19edfc1..0000000 --- a/analytics_sdk/lib/widget/analytics_debug_banner.dart +++ /dev/null @@ -1,158 +0,0 @@ -import 'dart:async'; - -import 'package:analytics_sdk/manager/sdk_context.dart'; -import 'package:analytics_sdk/utils/widget_bridge.dart'; -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; - -/// 埋点调试条 - 展示设备信息、埋点流程 7 步,便于排查某机型上报问题 -/// -/// release 包自动隐藏(无性能影响);debug 包由 WidgetBridge 内的 enableDebugBanner 控制。 -class AnalyticsDebugBanner extends StatelessWidget { - const AnalyticsDebugBanner({super.key}); - - @override - Widget build(BuildContext context) { - if (kReleaseMode || !WidgetBridge.enableDebugBanner) { - return const SizedBox.shrink(); - } - return const _AnalyticsDebugBannerContent(); - } -} - -class _AnalyticsDebugBannerContent extends StatefulWidget { - const _AnalyticsDebugBannerContent(); - - @override - State<_AnalyticsDebugBannerContent> createState() => - _AnalyticsDebugBannerContentState(); -} - -class _AnalyticsDebugBannerContentState - extends State<_AnalyticsDebugBannerContent> { - List> _steps = []; - Timer? _refreshTimer; - - @override - void initState() { - super.initState(); - _refreshSteps(); - _refreshTimer = Timer.periodic( - const Duration(seconds: 2), (_) => _refreshSteps()); - } - - @override - void dispose() { - _refreshTimer?.cancel(); - super.dispose(); - } - - void _refreshSteps() { - try { - if (mounted) { - setState(() { - _steps = WidgetBridge.getDebugSteps(); - }); - } - } catch (_) {} - } - - @override - Widget build(BuildContext context) { - - final brand = SdkContext.deviceBrand ?? ''; - final model = SdkContext.deviceModel ?? ''; - final deviceInfo = brand.isNotEmpty || model.isNotEmpty - ? '$brand $model'.trim() - : (kIsWeb - ? 'Web' - : defaultTargetPlatform == TargetPlatform.android - ? 'Android' - : defaultTargetPlatform == TargetPlatform.iOS - ? 'iOS' - : defaultTargetPlatform.name); - final deviceId = SdkContext.deviceId ?? ''; - final channel = SdkContext.channel ?? ''; - - return IgnorePointer( - child: Container( - width: double.infinity, - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - decoration: BoxDecoration( - color: Colors.black.withOpacity(0.55), - border: const Border( - bottom: BorderSide(color: Colors.white, width: 2)), - ), - child: SafeArea( - bottom: false, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - const Text( - '【埋点测试包】', - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.bold, - color: Colors.white, - shadows: [ - Shadow( - color: Colors.black, - offset: Offset(1, 1), - blurRadius: 2), - ], - ), - ), - const SizedBox(height: 6), - Text( - '设备: $deviceInfo', - style: const TextStyle(fontSize: 12, color: Colors.white), - ), - Text( - 'deviceId: ${deviceId.isEmpty ? "未获取" : "${deviceId.length > 16 ? deviceId.substring(0, 16) : deviceId}..."}', - style: const TextStyle(fontSize: 11, color: Colors.white70), - ), - Text( - '渠道: ${channel.isEmpty ? "未获取" : channel}', - style: const TextStyle(fontSize: 11, color: Colors.white70), - ), - const SizedBox(height: 8), - const Text( - '埋点流程(卡在某步=该机型问题)', - style: TextStyle( - fontSize: 11, - fontWeight: FontWeight.w600, - color: Colors.yellow), - ), - ..._steps.map((s) { - final status = s['status'] ?? ''; - final isOk = status.startsWith('✓'); - final isFail = status.startsWith('✗'); - var detail = s['detail'] ?? ''; - if (detail.toString().length > 35) { - detail = '${detail.toString().substring(0, 35)}...'; - } - final detailStr = - detail.isNotEmpty && detail != '-' ? ' ($detail)' : ''; - return Padding( - padding: const EdgeInsets.only(top: 2), - child: Text( - '${s['name']}: $status$detailStr', - style: TextStyle( - fontSize: 10, - color: isOk - ? Colors.greenAccent - : isFail - ? Colors.orange - : Colors.white70, - ), - ), - ); - }), - ], - ), - ), - ), - ); - } -} diff --git a/analytics_sdk/lib/widget/analytics_tab_bar_wrapper.dart b/analytics_sdk/lib/widget/analytics_tab_bar_wrapper.dart deleted file mode 100644 index 25e7faf..0000000 --- a/analytics_sdk/lib/widget/analytics_tab_bar_wrapper.dart +++ /dev/null @@ -1,165 +0,0 @@ -import 'package:analytics_sdk/entity/analytics_tab.dart'; -import 'package:analytics_sdk/entity/app_page_view_event.dart'; -import 'package:analytics_sdk/entity/navigation_event.dart'; -import 'package:analytics_sdk/manager/page_name_manager.dart'; -import 'package:analytics_sdk/observer/page_lifecycle_observer.dart'; -import 'package:analytics_sdk/utils/logger.dart'; -import 'package:analytics_sdk/utils/widget_bridge.dart'; -import 'package:analytics_sdk/widget/_tab_helpers.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/scheduler.dart'; - -/// TabBar 的 analytics 包装。 -/// -/// 监听 [TabController],Tab 切换完成时在下一帧渲染后自动上报: -/// - [AppPageViewEvent]:含来路页面 key(取自切换前的 [PageLifecycleObserver.currentPageKey]) -/// - [NavigationEvent]:用于导航行为报表 -/// -/// 同时调用 [PageLifecycleObserver.recordNavigation] 更新全局 currentPageKey -/// 及栈顶 effectivePageKey,确保后续 page_click 归属正确的子页面; -/// 即便从详情页 pop 返回,也能恢复到切换前的子 Tab key。 -/// -/// 不应直接使用;通过 [AnalyticsTabBarExtension.withAnalytics] 调用。 -class AnalyticsTabBarWrapper extends StatefulWidget - implements PreferredSizeWidget { - final TabBar child; - final List tabs; - - /// 仅供测试使用;null 时走 AnalyticsSdk.instance.track。 - @visibleForTesting - final void Function(dynamic event)? onTrack; - - const AnalyticsTabBarWrapper({ - super.key, - required this.child, - required this.tabs, - this.onTrack, - }); - - @override - Size get preferredSize => child.preferredSize; - - @override - State createState() => _AnalyticsTabBarWrapperState(); -} - -class _AnalyticsTabBarWrapperState extends State { - /// 当前监听的 TabController;在 [didChangeDependencies] / [didUpdateWidget] 中维护。 - TabController? _controller; - - /// 最近一次上报的 tab 索引,过滤 indexIsChanging 动画期间的重复触发。 - int _lastTrackedIndex = -1; - - @override - void didChangeDependencies() { - super.didChangeDependencies(); - try { - _attachController(_resolveController()); - } catch (e) { - Logger.analyticsSdk( - 'AnalyticsTabBarWrapper.didChangeDependencies 异常,已安全处理: $e'); - } - } - - @override - void didUpdateWidget(AnalyticsTabBarWrapper oldWidget) { - super.didUpdateWidget(oldWidget); - try { - final newController = _resolveController(); - if (newController != _controller) { - _attachController(newController); - _lastTrackedIndex = -1; // 新 controller 时重置,防止去重误判 - } - } catch (e) { - Logger.analyticsSdk( - 'AnalyticsTabBarWrapper.didUpdateWidget 异常,已安全处理: $e'); - } - } - - /// 优先取 widget.child.controller,回退到 [DefaultTabController]。 - TabController? _resolveController() { - final controller = widget.child.controller; - if (controller != null) return controller; - try { - return DefaultTabController.of(context); - } catch (_) { - return null; - } - } - - /// 切换监听目标:移除旧 controller 监听后绑定新 controller;controller 未变时直接返回。 - void _attachController(TabController? controller) { - if (controller == _controller) return; - _controller?.removeListener(_onControllerChange); - _controller = controller; - _controller?.addListener(_onControllerChange); - } - - /// TabController 索引变化回调。 - /// - /// 过滤动画帧(indexIsChanging)和重复索引后,通过 [SchedulerBinding.addPostFrameCallback] - /// 在渲染完成后上报 [AppPageViewEvent] 和 [NavigationEvent], - /// pageLoadTime 反映从点击到渲染完成的真实耗时。 - void _onControllerChange() { - try { - final ctrl = _controller; - if (ctrl == null || ctrl.indexIsChanging) return; - - final index = ctrl.index; - if (index == _lastTrackedIndex) return; - _lastTrackedIndex = index; - - if (widget.tabs.isEmpty) return; - - final tab = resolveTab(widget.tabs, index); - final pageKey = tab.key; - final pageName = resolveTabName(tab); - - PageNameMapper.addMapping(pageKey, pageName); - final referrerKey = PageLifecycleObserver.currentPageKey; - final referrerName = PageNameMapper.getPageName(referrerKey); - PageLifecycleObserver.recordNavigation(pageKey); - - final tapTime = DateTime.now(); - final onTrackSnapshot = widget.onTrack; - SchedulerBinding.instance.addPostFrameCallback((_) { - final pageLoadTime = DateTime.now().difference(tapTime).inMilliseconds; - try { - trackWith( - onTrackSnapshot, - AppPageViewEvent( - userType: WidgetBridge.getUserType(), - pageKey: pageKey, - pageName: pageName, - referrerPageKey: referrerKey, - referrerPageName: referrerName, - currentPageKey: pageKey, - currentPageName: pageName, - pageLoadTime: pageLoadTime, - pageEnterTs: tapTime.millisecondsSinceEpoch ~/ 1000, - )); - trackWith( - onTrackSnapshot, - NavigationEvent( - navigationKey: pageKey, - navigationName: pageName, - )); - } catch (e) { - Logger.analyticsSdk('TabBarWrapper.post-frame 上报异常,已安全处理: $e'); - } - }); - } catch (e) { - Logger.analyticsSdk('TabBarWrapper._onControllerChange 异常,已安全处理: $e'); - } - } - - @override - void dispose() { - _controller?.removeListener(_onControllerChange); - _controller = null; - super.dispose(); - } - - @override - Widget build(BuildContext context) => widget.child; -} diff --git a/analytics_sdk/lib/widget/global_click_wrapper.dart b/analytics_sdk/lib/widget/global_click_wrapper.dart deleted file mode 100644 index 37a9bb4..0000000 --- a/analytics_sdk/lib/widget/global_click_wrapper.dart +++ /dev/null @@ -1,393 +0,0 @@ -import 'package:analytics_sdk/config/sdk_config.dart'; -import 'package:analytics_sdk/entity/page_click_event.dart'; -import 'package:analytics_sdk/manager/session_behavior_manager.dart'; -import 'package:analytics_sdk/utils/logger.dart'; -import 'package:analytics_sdk/manager/page_name_manager.dart'; -import 'package:analytics_sdk/observer/page_lifecycle_observer.dart'; -import 'package:analytics_sdk/utils/widget_bridge.dart'; -import 'package:flutter/gestures.dart'; -import 'package:flutter/material.dart'; - -class GlobalClickWrapper extends StatefulWidget { - final Widget child; - - const GlobalClickWrapper({ - required this.child, - super.key, - }); - - @override - State createState() => _GlobalClickWrapperState(); -} - -class _GlobalClickWrapperState extends State { - DateTime? _lastClickTime; - Offset? _pointerDownPosition; - bool _pointerScrolled = false; - _ClickScrollSnapshot? _lastScrollSnapshot; - final Map _touchStarts = {}; - final Map _activeTouchPositions = {}; - double? _pinchStartDistance; - bool _pinchRecorded = false; - - static const double _swipeThresholdPx = 50; - static const double _pinchThresholdPx = 20; - static const double _longPressMoveTolerancePx = 10; - static const _longPressDuration = Duration(milliseconds: 500); - - @override - Widget build(BuildContext context) { - return NotificationListener( - onNotification: (notification) { - _recordScroll(notification); - return false; - }, - child: Listener( - onPointerDown: (PointerDownEvent event) { - _pointerDownPosition = event.position; - _pointerScrolled = false; - _recordPointerDown(event); - }, - onPointerMove: (PointerMoveEvent event) { - if (!_pointerScrolled && _pointerDownPosition != null) { - final dist = (event.position - _pointerDownPosition!).distance; - if (dist > kTouchSlop) { - _pointerScrolled = true; - } - } - _recordPointerMove(event); - }, - onPointerUp: (PointerUpEvent event) { - if (!_pointerScrolled) { - _handleTap(event); - } - _recordPointerUp(event); - _pointerDownPosition = null; - _pointerScrolled = false; - }, - onPointerCancel: (event) { - _recordPointerCancel(event); - _pointerDownPosition = null; - _pointerScrolled = false; - }, - // 透传所有事件,不影响原有交互 - behavior: HitTestBehavior.translucent, - child: widget.child, - ), - ); - } - - void _handleTap(PointerUpEvent event) { - try { - // 点击节流:限制点击事件上报频率,避免高频点击造成性能问题 - final now = DateTime.now(); - if (_lastClickTime != null && - now.difference(_lastClickTime!) < SdkConfig.clickThrottleDuration) { - return; - } - _lastClickTime = now; - - // 允许触摸(移动端)、鼠标(桌面/Web)、触控板(macOS);过滤触控笔等其他设备。 - final kind = event.kind; - if (kind != PointerDeviceKind.touch && - kind != PointerDeviceKind.mouse && - kind != PointerDeviceKind.trackpad) { - return; - } - - final pageKey = PageLifecycleObserver.currentPageKey; - - if (pageKey.isEmpty || pageKey == 'unknown_page') { - return; - } - - final pageName = PageNameMapper.getPageName(pageKey); - - // 保存事件位置信息,避免在异步回调中访问已失效的事件对象 - final clickPosition = event.position; - - // 异步获取 RenderBox,避免在 build 阶段调用 findRenderObject - WidgetsBinding.instance.addPostFrameCallback((_) { - try { - if (!mounted) return; - - final renderObject = context.findRenderObject(); - if (renderObject == null || renderObject is! RenderBox) return; - final renderBox = renderObject; - - final size = renderBox.size; - - final clickX = clickPosition.dx.round(); - final clickY = clickPosition.dy.round(); - final localPosition = renderBox.globalToLocal(clickPosition); - final clickViewportX = localPosition.dx.round(); - final clickViewportY = localPosition.dy.round(); - final viewportWidth = size.width.round(); - final viewportHeight = size.height.round(); - final percentX = viewportWidth > 0 - ? (clickViewportX / viewportWidth * 100).round().clamp(0, 100) - : 0; - final percentY = viewportHeight > 0 - ? (clickViewportY / viewportHeight * 100).round().clamp(0, 100) - : 0; - final scrollSnapshot = _scrollSnapshotFor( - pageKey: pageKey, - clickViewportX: clickViewportX, - clickViewportY: clickViewportY, - viewportWidth: viewportWidth, - viewportHeight: viewportHeight, - ); - - Logger.globalClick( - '上报点击成功 → $pageKey ($clickX,$clickY) [$percentX%,$percentY%]'); - - WidgetBridge.track(PageClickEvent.fromViewport( - pageKey: pageKey, - pageName: pageName, - clickPageX: clickX, - clickPageY: clickY, - clickViewportX: clickViewportX, - clickViewportY: clickViewportY, - viewportWidth: viewportWidth, - viewportHeight: viewportHeight, - clickDocumentX: scrollSnapshot?.clickDocumentX, - clickDocumentY: scrollSnapshot?.clickDocumentY, - pageWidth: scrollSnapshot?.pageWidth, - pageHeight: scrollSnapshot?.pageHeight, - scrollTop: scrollSnapshot?.scrollTop, - scrollLeft: scrollSnapshot?.scrollLeft, - )); - } catch (e) { - Logger.globalClick('点击事件处理异常,已安全处理: $e'); - } - }); - } catch (e) { - Logger.globalClick('_handleTap() 异常,已安全处理: $e'); - } - } - - void _recordScroll(ScrollNotification notification) { - try { - final pageKey = PageLifecycleObserver.currentPageKey; - if (pageKey.isEmpty || pageKey == 'unknown_page') return; - - final metrics = notification.metrics; - final scrollOffset = _roundedNonNegativeFinite(metrics.pixels); - final viewportExtent = - _roundedNonNegativeFinite(metrics.viewportDimension); - final contentExtent = _roundedNonNegativeFinite( - metrics.maxScrollExtent + metrics.viewportDimension); - if (scrollOffset == null || - viewportExtent == null || - contentExtent == null || - contentExtent <= 0) { - return; - } - - if (metrics.axis == Axis.vertical) { - SessionBehaviorManager.instance.recordScroll( - scrollTop: scrollOffset, - viewportHeight: viewportExtent, - contentHeight: contentExtent, - ); - _lastScrollSnapshot = _ClickScrollSnapshot( - pageKey: pageKey, - scrollTop: scrollOffset, - scrollLeft: 0, - pageWidth: -1, - pageHeight: contentExtent, - ); - } else if (metrics.axis == Axis.horizontal) { - _lastScrollSnapshot = _ClickScrollSnapshot( - pageKey: pageKey, - scrollTop: 0, - scrollLeft: scrollOffset, - pageWidth: contentExtent, - pageHeight: -1, - ); - } - } catch (e) { - Logger.globalClick('滚动快照记录异常,已安全处理: $e'); - } - } - - void _recordPointerDown(PointerDownEvent event) { - try { - if (event.kind != PointerDeviceKind.touch) return; - final now = DateTime.now(); - if (_activeTouchPositions.isEmpty) { - SessionBehaviorManager.instance.recordTouchStart(at: now); - } else { - _pointerScrolled = true; - } - _touchStarts[event.pointer] = _PointerTouchStart( - position: event.position, - startedAt: now, - ); - _activeTouchPositions[event.pointer] = event.position; - _updatePinchBaseline(); - } catch (e) { - Logger.globalClick('触摸开始行为记录异常,已安全处理: $e'); - } - } - - void _recordPointerMove(PointerMoveEvent event) { - try { - if (event.kind != PointerDeviceKind.touch) return; - if (!_activeTouchPositions.containsKey(event.pointer)) return; - _activeTouchPositions[event.pointer] = event.position; - if (_activeTouchPositions.length < 2) return; - final distance = _firstTwoTouchDistance(); - if (distance == null) return; - _pinchStartDistance ??= distance; - if (!_pinchRecorded && - (distance - _pinchStartDistance!).abs() >= _pinchThresholdPx) { - _pinchRecorded = true; - SessionBehaviorManager.instance.recordPinch(); - } - } catch (e) { - Logger.globalClick('触摸移动行为记录异常,已安全处理: $e'); - } - } - - void _recordPointerUp(PointerUpEvent event) { - try { - if (event.kind != PointerDeviceKind.touch) return; - final now = DateTime.now(); - final wasSingleTouch = _activeTouchPositions.length == 1; - final start = _touchStarts[event.pointer]; - if (start != null && wasSingleTouch) { - _recordFinishedSingleTouch( - start: start, - endPosition: event.position, - endedAt: now, - ); - } - _touchStarts.remove(event.pointer); - _activeTouchPositions.remove(event.pointer); - _updatePinchBaseline(); - } catch (e) { - Logger.globalClick('触摸结束行为记录异常,已安全处理: $e'); - } - } - - void _recordPointerCancel(PointerCancelEvent event) { - try { - if (event.kind != PointerDeviceKind.touch) return; - _touchStarts.remove(event.pointer); - _activeTouchPositions.remove(event.pointer); - _updatePinchBaseline(); - } catch (e) { - Logger.globalClick('触摸取消行为记录异常,已安全处理: $e'); - } - } - - void _recordFinishedSingleTouch({ - required _PointerTouchStart start, - required Offset endPosition, - required DateTime endedAt, - }) { - final distance = (endPosition - start.position).distance; - 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 (_activeTouchPositions.length < 2) { - _pinchStartDistance = null; - _pinchRecorded = false; - return; - } - _pinchStartDistance = _firstTwoTouchDistance(); - _pinchRecorded = false; - } - - double? _firstTwoTouchDistance() { - if (_activeTouchPositions.length < 2) return null; - final iterator = _activeTouchPositions.values.iterator; - iterator.moveNext(); - final first = iterator.current; - iterator.moveNext(); - final second = iterator.current; - return (first - second).distance; - } - - _ClickScrollSnapshot? _scrollSnapshotFor({ - required String pageKey, - required int clickViewportX, - required int clickViewportY, - required int viewportWidth, - required int viewportHeight, - }) { - final snapshot = _lastScrollSnapshot; - if (snapshot == null || snapshot.pageKey != pageKey) return null; - return snapshot.resolve( - clickViewportX: clickViewportX, - clickViewportY: clickViewportY, - viewportWidth: viewportWidth, - viewportHeight: viewportHeight, - ); - } - - int _nonNegative(int value) => value < 0 ? 0 : value; - - int? _roundedNonNegativeFinite(double value) { - if (!value.isFinite) return null; - return _nonNegative(value.round()); - } -} - -class _PointerTouchStart { - final Offset position; - final DateTime startedAt; - - const _PointerTouchStart({ - required this.position, - required this.startedAt, - }); -} - -class _ClickScrollSnapshot { - final String pageKey; - final int scrollTop; - final int scrollLeft; - final int pageWidth; - final int pageHeight; - final int clickDocumentX; - final int clickDocumentY; - - const _ClickScrollSnapshot({ - required this.pageKey, - required this.scrollTop, - required this.scrollLeft, - required this.pageWidth, - required this.pageHeight, - this.clickDocumentX = -1, - this.clickDocumentY = -1, - }); - - _ClickScrollSnapshot resolve({ - required int clickViewportX, - required int clickViewportY, - required int viewportWidth, - required int viewportHeight, - }) { - final resolvedPageWidth = pageWidth >= 0 ? pageWidth : viewportWidth; - final resolvedPageHeight = pageHeight >= 0 ? pageHeight : viewportHeight; - return _ClickScrollSnapshot( - pageKey: pageKey, - scrollTop: scrollTop, - scrollLeft: scrollLeft, - pageWidth: resolvedPageWidth, - pageHeight: resolvedPageHeight, - clickDocumentX: clickViewportX + scrollLeft, - clickDocumentY: clickViewportY + scrollTop, - ); - } -} diff --git a/analytics_sdk/pubspec.yaml b/analytics_sdk/pubspec.yaml deleted file mode 100644 index 05390aa..0000000 --- a/analytics_sdk/pubspec.yaml +++ /dev/null @@ -1,53 +0,0 @@ -name: analytics_sdk -description: "A new Flutter project." -version: 1.4.6 -homepage: - -environment: - sdk: '>=2.17.0 <4.0.0' - flutter: '>=3.0.5' - -dependencies: - flutter: - sdk: flutter - flutter_web_plugins: - sdk: flutter - - synchronized: '>=3.0.0+3 <3.2.0' - battery_plus: '>=2.1.4+1 <5.0.0' - connectivity_plus: '>=2.3.9 <5.0.0' - device_info_plus: '>=8.2.2 <10.0.0' - package_info_plus: '>=3.1.2 <5.0.0' - path_provider: '>=2.0.15 <2.2.0' - screen_brightness: 0.2.2+1 - screen_state: 4.1.1 - sensors_plus: '>=2.0.5 <5.0.0' - volume_controller: 2.0.8 - pointycastle: '>=3.7.3 <4.0.0' - crypto: '>=3.0.1 <4.0.0' - http: '>=0.13.5 <2.0.0' - archive: '>=3.1.11 <4.0.0' - -dev_dependencies: - flutter_test: - sdk: flutter - flutter_lints: ^2.0.1 - path_provider_platform_interface: 2.0.6 - -flutter: - plugin: - platforms: - android: - package: com.example.analytics_sdk - pluginClass: AnalyticsSdkPlugin - ios: - pluginClass: AnalyticsSdkPlugin - linux: - pluginClass: AnalyticsSdkPlugin - macos: - pluginClass: AnalyticsSdkPlugin - windows: - pluginClass: AnalyticsSdkPluginCApi - web: - pluginClass: AnalyticsSdkWeb - fileName: analytics_sdk_web.dart diff --git a/analytics_sdk/test/analytics_sdk_ad_dedup_test.dart b/analytics_sdk/test/analytics_sdk_ad_dedup_test.dart deleted file mode 100644 index 26075ba..0000000 --- a/analytics_sdk/test/analytics_sdk_ad_dedup_test.dart +++ /dev/null @@ -1,181 +0,0 @@ -import 'package:analytics_sdk/manager/ad_impression_manager.dart'; -import 'package:analytics_sdk/manager/session_manager.dart'; -import 'package:flutter_test/flutter_test.dart'; - -/// AdImpressionManager 去重逻辑回归测试 -/// -/// 覆盖 Bug 修复: -/// - _getUnreportedAdIds 异常时(返回 null)应降级为"继续上报(不去重)", -/// 而非被当作"全部已上报"而丢弃事件。 -/// 修复点:analytics_sdk.dart track() 中的 null/empty 判断分离。 -void main() { - const page = 'test_page'; - - setUp(() { - AdImpressionManager.instance.clear(); - SessionManager.instance.reset(); - }); - - tearDown(() { - AdImpressionManager.instance.clear(); - SessionManager.instance.reset(); - }); - - group('getUnreportedAdIds', () { - test('所有 adId 未上报时,返回完整列表', () { - final result = - AdImpressionManager.instance.getUnreportedAdIds('ad1,ad2,ad3', pageKey: page); - expect(result, ['ad1', 'ad2', 'ad3']); - }); - - test('所有 adId 已上报时,返回空列表', () { - AdImpressionManager.instance.markAsReportedBatch('ad1,ad2,ad3', pageKey: page); - final result = - AdImpressionManager.instance.getUnreportedAdIds('ad1,ad2,ad3', pageKey: page); - expect(result, isEmpty); - }); - - test('部分 adId 已上报时,只返回未上报的', () { - AdImpressionManager.instance.markAsReportedBatch('ad1,ad3', pageKey: page); - final result = - AdImpressionManager.instance.getUnreportedAdIds('ad1,ad2,ad3', pageKey: page); - expect(result, ['ad2']); - }); - - test('空字符串 adId 返回空列表', () { - final result = AdImpressionManager.instance.getUnreportedAdIds('', pageKey: page); - expect(result, isEmpty); - }); - - test('含空白项的 adId 被 trim 后正确处理', () { - AdImpressionManager.instance.markAsReportedBatch('ad1', pageKey: page); - final result = - AdImpressionManager.instance.getUnreportedAdIds(' ad1 , ad2 ', pageKey: page); - expect(result, ['ad2']); - }); - }); - - group('页面级去重:不同页面互不干扰', () { - test('同一 adId 在不同页面各自独立去重', () { - AdImpressionManager.instance.markAsReportedBatch('ad1', pageKey: 'page_a'); - // page_a 已上报 - expect( - AdImpressionManager.instance.getUnreportedAdIds('ad1', pageKey: 'page_a'), - isEmpty, - ); - // page_b 未上报 - expect( - AdImpressionManager.instance.getUnreportedAdIds('ad1', pageKey: 'page_b'), - equals(['ad1']), - ); - }); - - test('clearPage 只清除指定页面的去重记录', () { - AdImpressionManager.instance.markAsReportedBatch('ad1', pageKey: 'page_a'); - AdImpressionManager.instance.markAsReportedBatch('ad1', pageKey: 'page_b'); - - AdImpressionManager.instance.clearPage('page_a'); - - // page_a 已清除,可重新上报 - expect( - AdImpressionManager.instance.getUnreportedAdIds('ad1', pageKey: 'page_a'), - equals(['ad1']), - ); - // page_b 不受影响,仍然已上报 - expect( - AdImpressionManager.instance.getUnreportedAdIds('ad1', pageKey: 'page_b'), - isEmpty, - ); - }); - - test('离开页面再返回后同一广告可重新上报', () { - // 第一次进入 list_page,上报广告 - AdImpressionManager.instance.markAsReportedBatch('ad1,ad2,ad3', pageKey: 'list_page'); - expect( - AdImpressionManager.instance.getUnreportedAdIds('ad1,ad2,ad3', pageKey: 'list_page'), - isEmpty, - reason: '同一次页面访问内不重复上报', - ); - - // 离开 list_page(模拟页面退出清除去重) - AdImpressionManager.instance.clearPage('list_page'); - - // 再次进入 list_page,广告可重新上报 - expect( - AdImpressionManager.instance.getUnreportedAdIds('ad1,ad2,ad3', pageKey: 'list_page'), - equals(['ad1', 'ad2', 'ad3']), - reason: '页面退出后重新进入,广告可重新上报', - ); - }); - }); - - group('session 级去重:新 session 后同一 adId 可重新上报', () { - test('标记后 clear() 使 adId 重新出现在未上报列表', () { - AdImpressionManager.instance.markAsReportedBatch('adX', pageKey: page); - expect(AdImpressionManager.instance.getUnreportedAdIds('adX', pageKey: page), isEmpty); - - // SessionManager._createNewSession() 内部调用 AdImpressionManager.instance.clear() - SessionManager.instance.reset(); - SessionManager.instance.getSessionId(); - - expect( - AdImpressionManager.instance.getUnreportedAdIds('adX', pageKey: page), - equals(['adX']), - reason: '新 session 后 adX 应视为未上报', - ); - }); - - test('多 adId:新 session 后全部解除去重', () { - AdImpressionManager.instance.markAsReportedBatch('ad1,ad2,ad3', pageKey: page); - expect( - AdImpressionManager.instance.getUnreportedAdIds('ad1,ad2,ad3', pageKey: page), isEmpty); - - SessionManager.instance.reset(); - SessionManager.instance.getSessionId(); - - expect( - AdImpressionManager.instance.getUnreportedAdIds('ad1,ad2,ad3', pageKey: page), - equals(['ad1', 'ad2', 'ad3']), - ); - }); - - test('新 session 后重新标记,同 session 内再次去重', () { - // Session 1:标记 ad1 - SessionManager.instance.getSessionId(); - AdImpressionManager.instance.markAsReportedBatch('ad1', pageKey: page); - - // Session 2:ad1 解除去重,重新标记 - SessionManager.instance.reset(); - SessionManager.instance.getSessionId(); - expect(AdImpressionManager.instance.getUnreportedAdIds('ad1', pageKey: page), equals(['ad1'])); - AdImpressionManager.instance.markAsReportedBatch('ad1', pageKey: page); - - // Session 2 内 ad1 再次被去重 - expect( - AdImpressionManager.instance.getUnreportedAdIds('ad1', pageKey: page), - isEmpty, - reason: '同 session 2 内 ad1 已标记,不重复', - ); - }); - }); - - group('null 与 empty 语义区分(Bug 1 核心)', () { - /// 在 analytics_sdk.dart track() 里: - /// null = _getUnreportedAdIds 发生异常 → 降级:继续上报(不去重) - /// empty = 所有 adId 已上报 → 跳过整个事件 - /// - /// 修复前:null || empty 统一 return(事件丢失) - /// 修复后:只有 empty 才 return,null 降级继续上报 - /// - /// 此处通过 AdImpressionManager.getUnreportedAdIds 验证: - /// "全部已上报"场景返回 [](非 null),框架需区分处理。 - test('全部已上报时 getUnreportedAdIds 返回 [] 而非 null', () { - AdImpressionManager.instance.markAsReportedBatch('ad1', pageKey: page); - final result = AdImpressionManager.instance.getUnreportedAdIds('ad1', pageKey: page); - // 必须是 empty list,不能是 null - // analytics_sdk.dart 只应在结果为 empty(非 null)时跳过事件 - expect(result, isNotNull); - expect(result, isEmpty); - }); - }); -} diff --git a/analytics_sdk/test/analytics_sdk_decrypt_test.dart b/analytics_sdk/test/analytics_sdk_decrypt_test.dart deleted file mode 100644 index 7eed621..0000000 --- a/analytics_sdk/test/analytics_sdk_decrypt_test.dart +++ /dev/null @@ -1,104 +0,0 @@ -import 'dart:convert'; - -import 'package:analytics_sdk/analytics_sdk.dart'; -import 'package:analytics_sdk/config/sdk_config.dart'; -import 'package:analytics_sdk/utils/aes_gcm_util.dart'; -import 'package:flutter_test/flutter_test.dart'; - -/// GCM 解密固定测试 + validateEncryptedConfig 覆盖 -/// -/// 目的: -/// 1. 保护 SdkConfig.decryptKey + AesGcmUtil pipeline 的完整性。 -/// 2. 覆盖 validateEncryptedConfig() 的所有逻辑分支, -/// 确保上线前格式校验工具本身是可靠的。 -void main() { - const key = SdkConfig.decryptKey; - - group('AesGcmUtil GCM 加解密', () { - test('域名列表:加密后可正确解密还原', () { - final domains = ['https://api.example.com', 'https://api2.example.com']; - final ciphertext = AesGcmUtil.encrypt(key, domains); - final plaintext = AesGcmUtil.decryptResponseAuto(ciphertext); - final decoded = jsonDecode(plaintext); - expect(decoded, isA()); - expect(List.from(decoded), equals(domains)); - }); - - test('事件类型列表:加密后可正确解密还原', () { - final eventTypes = ['app_page_view', 'ad_impression', 'app_click']; - final ciphertext = AesGcmUtil.encrypt(key, eventTypes); - final plaintext = AesGcmUtil.decryptResponseAuto(ciphertext); - final decoded = jsonDecode(plaintext); - expect(decoded, isA()); - expect(List.from(decoded), equals(eventTypes)); - }); - - test('decryptResponseAuto 与 decryptToString 结果一致', () { - final data = ['https://api.example.com']; - final ciphertext = AesGcmUtil.encrypt(key, data); - final auto = AesGcmUtil.decryptResponseAuto(ciphertext); - final explicit = AesGcmUtil.decryptToString(key, ciphertext); - expect(auto, equals(explicit)); - }); - - test('错误密文抛出异常而非静默失败', () { - expect( - () => AesGcmUtil.decryptResponseAuto('not-a-valid-ciphertext'), - throwsException, - ); - }); - }); - - group('AnalyticsSdk.validateEncryptedConfig()', () { - test('域名列表加密串 → success=true, preview=第一个域名', () { - final domains = ['https://api.example.com', 'https://api2.example.com']; - final encrypted = AesGcmUtil.encrypt(key, domains); - final result = AnalyticsSdk.instance.validateEncryptedConfig(encrypted); - expect(result['success'], isTrue); - expect(result['preview'], equals('https://api.example.com')); - expect(result['error'], isNull); - }); - - test('事件类型列表加密串 → success=true, preview=第一个事件类型', () { - final eventTypes = ['app_page_view', 'ad_impression']; - final encrypted = AesGcmUtil.encrypt(key, eventTypes); - final result = AnalyticsSdk.instance.validateEncryptedConfig(encrypted); - expect(result['success'], isTrue); - expect(result['preview'], equals('app_page_view')); - expect(result['error'], isNull); - }); - - test('enabled_event_types 格式 → success=true', () { - final payload = {'enabled_event_types': ['app_click', 'ad_impression']}; - final encrypted = AesGcmUtil.encrypt(key, payload); - final result = AnalyticsSdk.instance.validateEncryptedConfig(encrypted); - expect(result['success'], isTrue); - expect(result['preview'], equals('app_click')); - }); - - test('解密成功但无法识别格式 → success=false, preview=前60字符', () { - // 加密一个 Map,其中没有已知 key - final unrecognized = {'unknown_key': 'some_value'}; - final encrypted = AesGcmUtil.encrypt(key, unrecognized); - final result = AnalyticsSdk.instance.validateEncryptedConfig(encrypted); - expect(result['success'], isFalse); - expect(result['preview'], isNotNull); - expect((result['preview'] as String).length, lessThanOrEqualTo(60)); - expect(result['error'], contains('0 个元素')); - }); - - test('无效密文 → success=false, error 包含"解密失败"', () { - final result = AnalyticsSdk.instance.validateEncryptedConfig('not-valid-ciphertext'); - expect(result['success'], isFalse); - expect(result['error'], contains('解密失败')); - expect(result['preview'], isNull); - }); - - test('空列表加密串 → success=false, error 包含"0 个元素"', () { - final encrypted = AesGcmUtil.encrypt(key, []); - final result = AnalyticsSdk.instance.validateEncryptedConfig(encrypted); - expect(result['success'], isFalse); - expect(result['error'], contains('0 个元素')); - }); - }); -} diff --git a/analytics_sdk/test/analytics_utils_fp_version_test.dart b/analytics_sdk/test/analytics_utils_fp_version_test.dart deleted file mode 100644 index e1241dd..0000000 --- a/analytics_sdk/test/analytics_utils_fp_version_test.dart +++ /dev/null @@ -1,39 +0,0 @@ -import 'package:analytics_sdk/entity/analytics_utils.dart'; -import 'package:analytics_sdk/utils/device_fingerprint_util.dart'; -import 'package:flutter_test/flutter_test.dart'; - -/// fp_version 公共字段测试 -/// -/// 确保 getDeviceCommonFields() 和 generateCommonFields() 均包含 -/// fp_version 字段,且值与 DeviceFingerprintUtil.kVersion 一致。 -void main() { - group('AnalyticsUtils fp_version', () { - setUp(() { - AnalyticsUtils.configure( - appId: 'test_app', - channel: 'test', - deviceFingerprint: 'fp_abc', - fingerprintVersion: DeviceFingerprintUtil.kVersion, - ); - }); - - test('getDeviceCommonFields() 包含 fp_version 字段', () { - final fields = AnalyticsUtils.getDeviceCommonFields(); - expect(fields.containsKey('fp_version'), isTrue); - expect(fields['fp_version'], equals(DeviceFingerprintUtil.kVersion)); - }); - - test('generateCommonFields() 包含 fp_version 字段', () { - final fields = AnalyticsUtils.generateCommonFields('test_event'); - expect(fields.containsKey('fp_version'), isTrue); - expect(fields['fp_version'], equals(DeviceFingerprintUtil.kVersion)); - }); - - test('fp_version 默认值为 kVersion', () { - // 不传 fingerprintVersion,依然应有默认值 - AnalyticsUtils.configure(appId: 'test2'); - final fields = AnalyticsUtils.getDeviceCommonFields(); - expect(fields['fp_version'], isNotEmpty); - }); - }); -} diff --git a/analytics_sdk/test/configure_null_test.dart b/analytics_sdk/test/configure_null_test.dart deleted file mode 100644 index 9a9b4fd..0000000 --- a/analytics_sdk/test/configure_null_test.dart +++ /dev/null @@ -1,39 +0,0 @@ -import 'package:analytics_sdk/entity/analytics_utils.dart'; -import 'package:flutter_test/flutter_test.dart'; - -void main() { - group('AnalyticsUtils.configure field semantics', () { - setUp(() { - AnalyticsUtils.configure( - appId: 'app1', - channel: 'ch1', - uid: 'user1', - device: 'Android', - deviceId: 'dev1', - ); - }); - - test('configure with null does NOT clear existing value', () { - AnalyticsUtils.configure(uid: null); - expect(AnalyticsUtils.uid, 'user1'); - }); - - test('configure with empty string DOES clear existing value', () { - AnalyticsUtils.configure(uid: ''); - expect(AnalyticsUtils.uid, ''); - }); - - test('setUid clears uid to empty string', () { - AnalyticsUtils.setUid(''); - expect(AnalyticsUtils.uid, ''); - }); - - test('reset clears all fields to null', () { - AnalyticsUtils.reset(); - expect(AnalyticsUtils.appId, isNull); - expect(AnalyticsUtils.uid, isNull); - expect(AnalyticsUtils.channel, isNull); - expect(AnalyticsUtils.deviceId, isNull); - }); - }); -} diff --git a/analytics_sdk/test/data_plus_sdk_method_channel_test.dart b/analytics_sdk/test/data_plus_sdk_method_channel_test.dart deleted file mode 100644 index 1f50843..0000000 --- a/analytics_sdk/test/data_plus_sdk_method_channel_test.dart +++ /dev/null @@ -1,5 +0,0 @@ -import 'package:flutter_test/flutter_test.dart'; - -void main() { - TestWidgetsFlutterBinding.ensureInitialized(); -} diff --git a/analytics_sdk/test/data_plus_sdk_test.dart b/analytics_sdk/test/data_plus_sdk_test.dart deleted file mode 100644 index ab73b3a..0000000 --- a/analytics_sdk/test/data_plus_sdk_test.dart +++ /dev/null @@ -1 +0,0 @@ -void main() {} diff --git a/analytics_sdk/test/domain_manager_cache_test.dart b/analytics_sdk/test/domain_manager_cache_test.dart deleted file mode 100644 index a9132f8..0000000 --- a/analytics_sdk/test/domain_manager_cache_test.dart +++ /dev/null @@ -1,94 +0,0 @@ -import 'dart:convert'; -import 'dart:io'; - -import 'package:analytics_sdk/config/sdk_config.dart'; -import 'package:analytics_sdk/manager/domain_manager.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter_test/flutter_test.dart'; - -/// DomainManager 磁盘缓存测试 -/// -/// 使用 MethodChannel mock 将 path_provider 重定向到系统临时目录, -/// 测试缓存读写逻辑而无需真实设备文件系统。 -void main() { - TestWidgetsFlutterBinding.ensureInitialized(); - - late Directory tempDir; - const channel = MethodChannel('plugins.flutter.io/path_provider'); - - setUp(() async { - tempDir = await Directory.systemTemp.createTemp('dm_cache_test_'); - // 将 path_provider 所有路径请求重定向到临时目录 - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, (MethodCall call) async { - return tempDir.path; - }); - // 重置单例缓存路径(确保每个 test 都从全新状态开始) - DomainManager.instance.reset(); - }); - - tearDown(() async { - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, null); - if (tempDir.existsSync()) { - await tempDir.delete(recursive: true); - } - }); - - group('DomainManager 磁盘缓存', () { - test('缓存文件不存在时 loadCachedDomains() 返回空列表', () async { - final result = await DomainManager.instance.loadCachedDomains(); - expect(result, isEmpty); - }); - - test('写入缓存后 loadCachedDomains() 返回正确域名列表', () async { - final domains = ['https://api.example.com', 'https://api2.example.com']; - // 直接写入缓存文件模拟之前 initWithDomains 写入的结果 - final cacheFile = - File('${tempDir.path}/${SdkConfig.domainCacheFileName}'); - await cacheFile.writeAsString(jsonEncode(domains)); - - final result = await DomainManager.instance.loadCachedDomains(); - expect(result, equals(domains)); - }); - - test('缓存文件 JSON 格式损坏时 loadCachedDomains() 返回空列表(不抛异常)', () async { - final cacheFile = - File('${tempDir.path}/${SdkConfig.domainCacheFileName}'); - await cacheFile.writeAsString('not valid json {{{'); - - expect( - () async => await DomainManager.instance.loadCachedDomains(), - returnsNormally, - ); - final result = await DomainManager.instance.loadCachedDomains(); - expect(result, isEmpty); - }); - - test('空字符串或空格不被解析为有效域名', () async { - final cacheFile = - File('${tempDir.path}/${SdkConfig.domainCacheFileName}'); - await cacheFile.writeAsString(jsonEncode([' ', '', 'https://ok.com'])); - - final result = await DomainManager.instance.loadCachedDomains(); - expect(result, equals(['https://ok.com'])); - }); - }); - - group('DomainManager.reset()', () { - test('reset() 后 fastestDomain 和 reportDomains 均为空', () async { - // 先通过缓存文件让 loadCachedDomains 有数据 - final cacheFile = - File('${tempDir.path}/${SdkConfig.domainCacheFileName}'); - await cacheFile.writeAsString(jsonEncode(['https://api.example.com'])); - - // reset() 只清内存状态,不删文件 - DomainManager.instance.reset(); - expect(DomainManager.instance.fastestDomain, isNull); - expect(DomainManager.instance.reportDomains, isEmpty); - - // 缓存文件应仍然存在 - expect(cacheFile.existsSync(), isTrue); - }); - }); -} diff --git a/analytics_sdk/test/event_processor_logic_test.dart b/analytics_sdk/test/event_processor_logic_test.dart deleted file mode 100644 index 2c74d02..0000000 --- a/analytics_sdk/test/event_processor_logic_test.dart +++ /dev/null @@ -1,128 +0,0 @@ -import 'dart:io'; - -import 'package:analytics_sdk/manager/event_processor.dart'; -import 'package:analytics_sdk/manager/session_manager.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter_test/flutter_test.dart'; - -void main() { - TestWidgetsFlutterBinding.ensureInitialized(); - - late Directory tempDir; - const channel = MethodChannel('plugins.flutter.io/path_provider'); - - setUp(() async { - tempDir = await Directory.systemTemp.createTemp('ep_logic_test_'); - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, (call) async { - if (call.method == 'getApplicationDocumentsDirectory') { - return tempDir.path; - } - return null; - }); - }); - - tearDown(() async { - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, null); - await tempDir.delete(recursive: true); - }); - - group('uploadBatch concurrent guard', () { - test('resetState clears _isUploading so next uploadBatch can proceed', () async { - final processor = EventProcessor(); - await processor.initPersistence(); - - processor.enqueueEvent({ - 'event': 'test_event', - 'event_id': 'eid1', - 'app_id': 'app1', - 'sid': 'sid1', - 'client_ts': 1711929600, - 'device_id': 'dev1', - }); - expect(processor.queueLength, 1); - - processor.resetState(); - - await processor.uploadBatch(); - expect(processor.queueLength, 1); - }); - }); - - group('queue removal correctness', () { - test('enqueueEvent during upload does not corrupt removal', () { - final processor = EventProcessor(); - - for (int i = 1; i <= 3; i++) { - processor.enqueueEvent({ - 'event': 'evt', - 'event_id': 'eid_$i', - 'app_id': 'app1', - 'sid': 'sid1', - 'client_ts': 1711929600, - 'device_id': 'dev1', - }); - } - expect(processor.queueLength, 3); - - processor.enqueueEvent({ - 'event': 'evt', - 'event_id': 'eid_4', - 'app_id': 'app1', - 'sid': 'sid1', - 'client_ts': 1711929600, - 'device_id': 'dev1', - }); - expect(processor.queueLength, 4); - }); - }); - - group('dispose cache buffer flush', () { - test('disposeAsync writes buffered events to cache file', () async { - final processor = EventProcessor(); - await processor.initPersistence(); - - // Enqueue 3 events (below _cacheBufferThreshold of 10, so they stay in buffer) - for (int i = 1; i <= 3; i++) { - processor.enqueueEvent({ - 'event': 'evt', - 'event_id': 'buf_$i', - 'app_id': 'app1', - 'sid': 'sid1', - 'client_ts': 1711929600, - 'device_id': 'dev1', - }); - } - - // Call disposeAsync — should flush buffer to disk - await processor.disposeAsync(); - - // Wait for any microtasks to complete - await Future.delayed(Duration(milliseconds: 100)); - - // Verify cache file has the 3 events - final cacheFile = File('${tempDir.path}/data_plus_events_cache.jsonl'); - expect(await cacheFile.exists(), isTrue, reason: 'cache file should exist after dispose'); - final lines = (await cacheFile.readAsLines()) - .where((l) => l.trim().isNotEmpty) - .toList(); - expect(lines.length, 3); - }); - }); - - group('SessionManager re-init', () { - test('reset + initialize creates a new session ID', () { - SessionManager.instance.reset(); - SessionManager.instance.initialize(); - final firstSid = SessionManager.instance.currentSessionId; - expect(firstSid, isNotNull); - - SessionManager.instance.reset(); - SessionManager.instance.initialize(); - final secondSid = SessionManager.instance.currentSessionId; - expect(secondSid, isNotNull); - expect(secondSid, isNot(equals(firstSid))); - }); - }); -} diff --git a/analytics_sdk/test/event_processor_tombstone_test.dart b/analytics_sdk/test/event_processor_tombstone_test.dart deleted file mode 100644 index 6da7c78..0000000 --- a/analytics_sdk/test/event_processor_tombstone_test.dart +++ /dev/null @@ -1,117 +0,0 @@ -import 'dart:convert'; -import 'dart:io'; - -import 'package:analytics_sdk/manager/event_processor.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter_test/flutter_test.dart'; - -/// EventProcessor tombstone 缓存测试 -/// -/// 验证: -/// 1. 上报成功后写 tombstone,不重写主文件 -/// 2. loadCachedEvents 用 tombstone 过滤已上报事件 -/// 3. tombstone 达到阈值时触发压缩 -void main() { - TestWidgetsFlutterBinding.ensureInitialized(); - - late Directory tempDir; - const channel = MethodChannel('plugins.flutter.io/path_provider'); - - setUp(() async { - tempDir = await Directory.systemTemp.createTemp('ep_tombstone_test_'); - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, (call) async { - if (call.method == 'getApplicationDocumentsDirectory') { - return tempDir.path; - } - return null; - }); - }); - - tearDown(() async { - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, null); - await tempDir.delete(recursive: true); - }); - - /// 写几条事件到主缓存文件 - Future writeCacheLines(Directory dir, List> events) async { - final file = File('${dir.path}/data_plus_events_cache.jsonl'); - final lines = events.map(jsonEncode).join('\n'); - await file.writeAsString('$lines\n'); - } - - /// 读 tombstone 文件内容 - Future> readTombstone(Directory dir) async { - final file = File('${dir.path}/data_plus_events_tombstone.txt'); - if (!await file.exists()) return {}; - final content = await file.readAsString(); - return content.split('\n').where((l) => l.isNotEmpty).toSet(); - } - - test('_removeEventsFromCache 追加写 tombstone,不重写主文件', () async { - final processor = EventProcessor(); - await processor.initPersistence(); - - final events = [ - {'event_id': 'id_1', 'event': 'page_view'}, - {'event_id': 'id_2', 'event': 'click'}, - {'event_id': 'id_3', 'event': 'purchase'}, - ]; - await writeCacheLines(tempDir, events); - - final cacheFile = File('${tempDir.path}/data_plus_events_cache.jsonl'); - final sizeBefore = await cacheFile.length(); - - // 上报成功,移除前两条 - await processor.removeEventsFromCacheForTest(events.sublist(0, 2)); - - // 主文件大小不变(没有重写) - expect(await cacheFile.length(), equals(sizeBefore)); - - // tombstone 包含两个 ID - final ids = await readTombstone(tempDir); - expect(ids, containsAll(['id_1', 'id_2'])); - expect(ids, isNot(contains('id_3'))); - }); - - test('loadCachedEvents 用 tombstone 过滤,只恢复未上报的事件', () async { - final processor = EventProcessor(); - await processor.initPersistence(); - - final events = [ - {'event_id': 'id_1', 'event': 'page_view'}, - {'event_id': 'id_2', 'event': 'click'}, - {'event_id': 'id_3', 'event': 'purchase'}, - ]; - await writeCacheLines(tempDir, events); - - // 预先写 tombstone,标记 id_1 已上报 - final tombstone = File('${tempDir.path}/data_plus_events_tombstone.txt'); - await tombstone.writeAsString('id_1\n'); - - await processor.loadCachedEvents(); - - // 只有 id_2 和 id_3 进入队列 - expect(processor.queueLength, equals(2)); - - // 两个文件都被删除 - expect(await File('${tempDir.path}/data_plus_events_cache.jsonl').exists(), isFalse); - expect(await tombstone.exists(), isFalse); - }); - - test('tombstone 为空时 loadCachedEvents 恢复全部事件', () async { - final processor = EventProcessor(); - await processor.initPersistence(); - - final events = [ - {'event_id': 'id_1', 'event': 'page_view'}, - {'event_id': 'id_2', 'event': 'click'}, - ]; - await writeCacheLines(tempDir, events); - - await processor.loadCachedEvents(); - - expect(processor.queueLength, equals(2)); - }); -} diff --git a/analytics_sdk/test/event_validator_regression_test.dart b/analytics_sdk/test/event_validator_regression_test.dart deleted file mode 100644 index 105a3ca..0000000 --- a/analytics_sdk/test/event_validator_regression_test.dart +++ /dev/null @@ -1,142 +0,0 @@ -import 'package:analytics_sdk/entity/analytics_utils.dart'; -import 'package:analytics_sdk/utils/event_validator.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter_test/flutter_test.dart'; - -/// EventValidator 回归测试 -/// -/// 覆盖三个历史 bug 修复路径: -/// 1. device 传入空字符串时,事件不应被丢弃 -/// 2. 逗号分隔字段清洗后为空时,字段应被移除而非保留空字符串 -/// 3. generateEventId() fallback 值符合 ^[A-Za-z0-9]{1,32}$ 正则 -void main() { - TestWidgetsFlutterBinding.ensureInitialized(); - - // 为 SessionManager(内部依赖 path_provider)提供 mock - const channel = MethodChannel('plugins.flutter.io/path_provider'); - setUp(() { - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, (call) async { - if (call.method == 'getApplicationDocumentsDirectory') { - return '/tmp/test_validator'; - } - return null; - }); - }); - - tearDown(() { - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, null); - }); - - // 构造一个基础合法事件,仅覆盖要测试的字段 - Map baseEvent({Map? overrides}) { - final base = { - 'event': 'test_event', - 'event_id': 'abc123', - 'app_id': 'test_app', - 'sid': 'sess_001', - 'client_ts': 1704067200, - 'device_id': 'device_001', - 'payload': {}, - }; - if (overrides != null) base.addAll(overrides); - return base; - } - - group('EventValidator 回归:device 空字符串', () { - test('device 为空字符串时,事件不被丢弃(应正常返回)', () { - final event = baseEvent(overrides: {'device': ''}); - final result = EventValidator.validate(event); - // 空字符串 device 应视为未提供,事件正常通过 - expect(result, isNotNull, reason: 'device 空字符串不应导致事件被丢弃'); - }); - - test('device 为合法值 android 时,自动纠正大小写', () { - final event = baseEvent(overrides: {'device': 'android'}); - final result = EventValidator.validate(event); - expect(result, isNotNull); - expect(result!['device'], equals('Android')); - }); - - test('device 为无法识别的值时,事件应被丢弃', () { - final event = baseEvent(overrides: {'device': 'Windows'}); - final result = EventValidator.validate(event); - expect(result, isNull, reason: '无法识别的 device 应丢弃事件'); - }); - }); - - group('EventValidator 回归:逗号分隔字段清洗后为空', () { - test('ad_id 为纯空格逗号时,清洗后为空,字段应被移除', () { - final event = baseEvent(overrides: { - 'payload': {'ad_id': ' , , '}, - }); - final result = EventValidator.validate(event); - expect(result, isNotNull); - expect( - (result!['payload'] as Map).containsKey('ad_id'), - isFalse, - reason: '清洗后为空的逗号分隔字段应被移除,而非保留空字符串', - ); - }); - - test('ad_id 有合法值时,正常保留并清洗', () { - final event = baseEvent(overrides: { - 'payload': {'ad_id': 'ad_001 , , ad_002'}, - }); - final result = EventValidator.validate(event); - expect(result, isNotNull); - expect( - (result!['payload'] as Map)['ad_id'], - equals('ad_001,ad_002'), - ); - }); - - test('novel_tag_key 清洗后为空时,字段被移除', () { - final event = baseEvent(overrides: { - 'payload': {'novel_tag_key': ' '}, - }); - final result = EventValidator.validate(event); - expect(result, isNotNull); - expect( - (result!['payload'] as Map).containsKey('novel_tag_key'), - isFalse, - ); - }); - }); - - group('generateEventId() 回归:fallback 值格式合规', () { - final idPattern = RegExp(r'^[A-Za-z0-9]{1,32}$'); - - test('正常路径生成的 event_id 符合 ^[A-Za-z0-9]{1,32}\$', () { - final id = AnalyticsUtils.generateEventId(['event', 'app', 'uid', 'ts']); - expect(idPattern.hasMatch(id), isTrue, - reason: 'MD5 生成的 event_id 应符合正则: $id'); - }); - - test('空列表生成的 event_id 符合正则', () { - final id = AnalyticsUtils.generateEventId([]); - expect(idPattern.hasMatch(id), isTrue, - reason: '空列表 fallback event_id 应符合正则: $id'); - }); - - test('生成的 event_id 长度不超过 32', () { - final id = AnalyticsUtils.generateEventId(['a', 'b', 'c']); - expect(id.length, lessThanOrEqualTo(32)); - }); - - test('fallback h\${abs} 格式:字母数字开头,无连字符或下划线', () { - // 验证 h 前缀 + 纯数字的形式符合正则 - final hashFallback = 'h${('abc').hashCode.abs()}'; - expect(idPattern.hasMatch(hashFallback), isTrue, - reason: 'h\${hashCode.abs()} 应符合正则: $hashFallback'); - }); - - test('fallback t\${milliseconds} 格式符合正则', () { - final tsFallback = 't${DateTime.now().millisecondsSinceEpoch}'; - // 13 位时间戳 + 't' 前缀 = 14 字符,≤ 32 - expect(idPattern.hasMatch(tsFallback), isTrue, - reason: 't\${ms} 应符合正则: $tsFallback'); - }); - }); -} diff --git a/analytics_sdk/test/event_validator_type_coercion_test.dart b/analytics_sdk/test/event_validator_type_coercion_test.dart deleted file mode 100644 index 2843cde..0000000 --- a/analytics_sdk/test/event_validator_type_coercion_test.dart +++ /dev/null @@ -1,59 +0,0 @@ -import 'package:analytics_sdk/utils/event_validator.dart'; -import 'package:analytics_sdk/manager/session_manager.dart'; -import 'package:flutter_test/flutter_test.dart'; - -void main() { - setUp(() { - SessionManager.instance.reset(); - SessionManager.instance.initialize(); - }); - - tearDown(() { - SessionManager.instance.reset(); - }); - - group('EventValidator type coercion', () { - Map makeEvent({dynamic uid, dynamic channel}) { - return { - 'event': 'test_event', - 'event_id': 'abc123', - 'app_id': 'app1', - 'sid': SessionManager.instance.getSessionId(), - 'client_ts': 1711929600, - 'device_id': 'dev1', - if (uid != null) 'uid': uid, - if (channel != null) 'channel': channel, - }; - } - - test('int uid is coerced to string', () { - final result = EventValidator.validate(makeEvent(uid: 12345)); - expect(result, isNotNull); - expect(result!['uid'], '12345'); - }); - - test('double uid is coerced to string', () { - final result = EventValidator.validate(makeEvent(uid: 123.0)); - expect(result, isNotNull); - expect(result!['uid'], '123.0'); - }); - - test('int channel is coerced to string', () { - final result = EventValidator.validate(makeEvent(channel: 100)); - expect(result, isNotNull); - expect(result!['channel'], '100'); - }); - - test('string uid is preserved', () { - final result = EventValidator.validate(makeEvent(uid: 'user_abc')); - expect(result, isNotNull); - expect(result!['uid'], 'user_abc'); - }); - - test('bool uid is coerced to string', () { - final result = EventValidator.validate(makeEvent(uid: true)); - expect(result, isNotNull); - expect(result!['uid'], 'true'); - }); - }); -} diff --git a/analytics_sdk/test/logout_user_test.dart b/analytics_sdk/test/logout_user_test.dart deleted file mode 100644 index 5f93e4f..0000000 --- a/analytics_sdk/test/logout_user_test.dart +++ /dev/null @@ -1,36 +0,0 @@ -import 'package:analytics_sdk/analytics_sdk.dart'; -import 'package:analytics_sdk/enum/user_type_enum.dart'; -import 'package:analytics_sdk/manager/user_manager.dart'; -import 'package:flutter_test/flutter_test.dart'; - -void main() { - setUp(() { - UserManager.instance.updateUserType(UserTypeEnum.normal.label); - }); - - group('logoutUser', () { - test('logoutUser resets uid and userType to normal', () { - AnalyticsSdk.setUserIdAndType( - userId: 'user123', - userTypeEnum: UserTypeEnum.vip, - ); - expect(UserManager.instance.userType, UserTypeEnum.vip.label); - - AnalyticsSdk.logoutUser(); - - expect(UserManager.instance.userType, UserTypeEnum.normal.label); - }); - - test('logoutUser is idempotent — calling twice has same result', () { - AnalyticsSdk.setUserIdAndType( - userId: 'user123', - userTypeEnum: UserTypeEnum.vip, - ); - - AnalyticsSdk.logoutUser(); - AnalyticsSdk.logoutUser(); - - expect(UserManager.instance.userType, UserTypeEnum.normal.label); - }); - }); -} diff --git a/analytics_sdk/test/page_name_mapper_test.dart b/analytics_sdk/test/page_name_mapper_test.dart deleted file mode 100644 index 69e8095..0000000 --- a/analytics_sdk/test/page_name_mapper_test.dart +++ /dev/null @@ -1,68 +0,0 @@ -import 'package:analytics_sdk/manager/page_name_manager.dart'; -import 'package:flutter_test/flutter_test.dart'; - -void main() { - group('PageNameMapper.normalizeKey', () { - test('普通字符串不变', () { - expect(PageNameMapper.normalizeKey('home'), 'home'); - }); - - test('去掉单个前导斜杠', () { - expect(PageNameMapper.normalizeKey('/home'), 'home'); - }); - - test('去掉前导斜杠后剩余部分保留', () { - expect(PageNameMapper.normalizeKey('/video_detail'), 'video_detail'); - }); - - test('纯 / 保留原值(避免 pageKey 变空字符串)', () { - expect(PageNameMapper.normalizeKey('/'), '/'); - }); - - test('空字符串不变', () { - expect(PageNameMapper.normalizeKey(''), ''); - }); - - test('双斜杠开头只去掉第一个', () { - expect(PageNameMapper.normalizeKey('//home'), '/home'); - }); - - test('斜杠在中间不受影响', () { - expect(PageNameMapper.normalizeKey('a/b'), 'a/b'); - }); - - test('斜杠在结尾不受影响', () { - expect(PageNameMapper.normalizeKey('home/'), 'home/'); - }); - }); - - group('PageNameMapper.getPageName', () { - setUp(() { - PageNameMapper.clearCustom(); - }); - - tearDown(() { - PageNameMapper.clearCustom(); - }); - - test('无映射时返回归一化后的 key', () { - expect(PageNameMapper.getPageName('/home'), 'home'); - }); - - test('有映射时返回映射名称(带斜杠注册)', () { - PageNameMapper.addMapping('/home', '首页'); - expect(PageNameMapper.getPageName('/home'), '首页'); - expect(PageNameMapper.getPageName('home'), '首页'); - }); - - test('有映射时返回映射名称(不带斜杠注册)', () { - PageNameMapper.addMapping('home', '首页'); - expect(PageNameMapper.getPageName('/home'), '首页'); - expect(PageNameMapper.getPageName('home'), '首页'); - }); - - test('纯 / 时 getPageName 返回 /', () { - expect(PageNameMapper.getPageName('/'), '/'); - }); - }); -} diff --git a/analytics_sdk/test/session_manager_regression_test.dart b/analytics_sdk/test/session_manager_regression_test.dart deleted file mode 100644 index a38fff4..0000000 --- a/analytics_sdk/test/session_manager_regression_test.dart +++ /dev/null @@ -1,189 +0,0 @@ -import 'package:analytics_sdk/manager/ad_impression_manager.dart'; -import 'package:analytics_sdk/manager/session_manager.dart'; -import 'package:flutter_test/flutter_test.dart'; - -/// SessionManager 回归测试 -/// -/// 覆盖 Bug 修复: -/// - _shouldCreateNewSession 是查询方法,不应在非超时分支清空 _backgroundTime。 -/// 修复前:getSessionId() 在后台期间被调用时(如推送通知触发 track()), -/// 非超时分支的 `_backgroundTime = null` 会提前清空后台时间, -/// 导致后续 onAppForeground() 无法判断后台时长,旧会话被错误保留。 -/// 修复点:session_manager.dart _shouldCreateNewSession 中只在超时分支清空。 -void main() { - const page = 'test_page'; - - setUp(() { - SessionManager.instance.reset(); - AdImpressionManager.instance.clear(); - }); - - tearDown(() { - SessionManager.instance.reset(); - AdImpressionManager.instance.clear(); - }); - - group('_shouldCreateNewSession 不应提前清空 _backgroundTime(Bug 2 核心)', () { - test('onAppBackground 后 backgroundTime 非 null', () { - SessionManager.instance.getSessionId(); // 确保已初始化 - SessionManager.instance.onAppBackground(); - expect( - SessionManager.instance.backgroundTimeForTest, - isNotNull, - reason: 'onAppBackground() 应设置 _backgroundTime', - ); - }); - - test('后台期间调用 getSessionId() 不应清空 backgroundTime', () { - SessionManager.instance.getSessionId(); // 初始化会话 - SessionManager.instance.onAppBackground(); - - // 模拟后台期间有推送通知触发了 track() → getSessionId() - // 后台时间极短(< 30 min),不应触发新会话,但也不应清空 _backgroundTime - SessionManager.instance.getSessionId(); - - expect( - SessionManager.instance.backgroundTimeForTest, - isNotNull, - reason: '后台期间调用 getSessionId() 不应清空 _backgroundTime,' - '否则后续 onAppForeground() 无法正确判断后台时长', - ); - }); - - test('onAppForeground 后 backgroundTime 被正确清空', () { - SessionManager.instance.getSessionId(); - SessionManager.instance.onAppBackground(); - SessionManager.instance.onAppForeground(); - expect( - SessionManager.instance.backgroundTimeForTest, - isNull, - reason: 'onAppForeground() 执行完毕后应清空 _backgroundTime', - ); - }); - - test('未调用 onAppBackground 时,getSessionId 不影响 backgroundTime(始终为 null)', () { - SessionManager.instance.getSessionId(); - expect(SessionManager.instance.backgroundTimeForTest, isNull); - SessionManager.instance.getSessionId(); - expect(SessionManager.instance.backgroundTimeForTest, isNull); - }); - }); - - group('新 session 时清空广告去重集(session 级去重)', () { - test('新 session 创建后 AdImpressionManager 被清空', () { - SessionManager.instance.getSessionId(); - AdImpressionManager.instance.markAsReportedBatch('ad1,ad2', pageKey: page); - - // 确认已标记 - expect(AdImpressionManager.instance.getUnreportedAdIds('ad1,ad2', pageKey: page), isEmpty); - - // 重置触发新 session - SessionManager.instance.reset(); - SessionManager.instance.getSessionId(); - - expect( - AdImpressionManager.instance.getUnreportedAdIds('ad1,ad2', pageKey: page), - equals(['ad1', 'ad2']), - reason: '新 session 后同一 adId 应视为未上报', - ); - }); - - test('同一 session 内标记后不再返回未上报', () { - SessionManager.instance.getSessionId(); - AdImpressionManager.instance.markAsReportedBatch('ad1', pageKey: page); - - // 没有新 session,ad1 应仍被去重 - expect( - AdImpressionManager.instance.getUnreportedAdIds('ad1', pageKey: page), - isEmpty, - reason: '同 session 内 ad1 不应重复上报', - ); - }); - - test('连续建立多个 session,每次都清空去重集', () { - const ids = 'ad1,ad2,ad3'; - - // Session 1 - SessionManager.instance.getSessionId(); - AdImpressionManager.instance.markAsReportedBatch(ids, pageKey: page); - expect(AdImpressionManager.instance.getUnreportedAdIds(ids, pageKey: page), isEmpty); - - // Session 2 - SessionManager.instance.reset(); - SessionManager.instance.getSessionId(); - expect( - AdImpressionManager.instance.getUnreportedAdIds(ids, pageKey: page), - equals(['ad1', 'ad2', 'ad3']), - reason: 'Session 2 开始时去重集应已清空', - ); - - // Session 2 内再次标记 - AdImpressionManager.instance.markAsReportedBatch(ids, pageKey: page); - expect(AdImpressionManager.instance.getUnreportedAdIds(ids, pageKey: page), isEmpty); - - // Session 3 - SessionManager.instance.reset(); - SessionManager.instance.getSessionId(); - expect( - AdImpressionManager.instance.getUnreportedAdIds(ids, pageKey: page), - equals(['ad1', 'ad2', 'ad3']), - reason: 'Session 3 开始时去重集应再次清空', - ); - }); - - test('端到端:上报 → 同 session 去重 → 新 session → 再次可报', () { - const adId = 'banner_001'; - - // Step 1: 首次出现 → 未上报 - SessionManager.instance.getSessionId(); - expect(AdImpressionManager.instance.getUnreportedAdIds(adId, pageKey: page), [adId]); - AdImpressionManager.instance.markAsReportedBatch(adId, pageKey: page); - - // Step 2: 同 session 内再次出现 → 去重 - expect( - AdImpressionManager.instance.getUnreportedAdIds(adId, pageKey: page), - isEmpty, - reason: '同 session 内不重复上报', - ); - - // Step 3: 新 session - SessionManager.instance.reset(); - SessionManager.instance.getSessionId(); - - // Step 4: 新 session 内首次出现 → 可上报 - expect( - AdImpressionManager.instance.getUnreportedAdIds(adId, pageKey: page), - equals([adId]), - reason: '新 session 内首次出现,应可上报', - ); - - // Step 5: 新 session 内标记后 → 再次去重 - AdImpressionManager.instance.markAsReportedBatch(adId, pageKey: page); - expect( - AdImpressionManager.instance.getUnreportedAdIds(adId, pageKey: page), - isEmpty, - reason: '新 session 内已标记,不重复上报', - ); - }); - }); - - group('会话基础行为', () { - test('未初始化时 getSessionId 返回非空 ID', () { - final id = SessionManager.instance.getSessionId(); - expect(id, isNotEmpty); - }); - - test('连续调用 getSessionId 返回相同 ID', () { - final id1 = SessionManager.instance.getSessionId(); - final id2 = SessionManager.instance.getSessionId(); - expect(id1, equals(id2)); - }); - - test('reset 后 getSessionId 创建新 ID', () { - final id1 = SessionManager.instance.getSessionId(); - SessionManager.instance.reset(); - final id2 = SessionManager.instance.getSessionId(); - expect(id1, isNot(equals(id2))); - }); - }); -} diff --git a/assets/images/holiday/1/home_0_0.png b/assets/images/holiday/1/home_0_0.png deleted file mode 100644 index dd42a3f..0000000 Binary files a/assets/images/holiday/1/home_0_0.png and /dev/null differ diff --git a/assets/images/holiday/1/home_0_1.png b/assets/images/holiday/1/home_0_1.png deleted file mode 100644 index 0e974fa..0000000 Binary files a/assets/images/holiday/1/home_0_1.png and /dev/null differ diff --git a/assets/images/holiday/1/home_1_0.png b/assets/images/holiday/1/home_1_0.png deleted file mode 100644 index ed90360..0000000 Binary files a/assets/images/holiday/1/home_1_0.png and /dev/null differ diff --git a/assets/images/holiday/1/home_1_1.png b/assets/images/holiday/1/home_1_1.png deleted file mode 100644 index 660263f..0000000 Binary files a/assets/images/holiday/1/home_1_1.png and /dev/null differ diff --git a/assets/images/holiday/1/home_2_0.png b/assets/images/holiday/1/home_2_0.png deleted file mode 100644 index f33266c..0000000 Binary files a/assets/images/holiday/1/home_2_0.png and /dev/null differ diff --git a/assets/images/holiday/1/home_2_1.png b/assets/images/holiday/1/home_2_1.png deleted file mode 100644 index cc57718..0000000 Binary files a/assets/images/holiday/1/home_2_1.png and /dev/null differ diff --git a/assets/images/holiday/1/home_3_0.png b/assets/images/holiday/1/home_3_0.png deleted file mode 100644 index 30c39a4..0000000 Binary files a/assets/images/holiday/1/home_3_0.png and /dev/null differ diff --git a/assets/images/holiday/1/home_3_1.png b/assets/images/holiday/1/home_3_1.png deleted file mode 100644 index 70f4eec..0000000 Binary files a/assets/images/holiday/1/home_3_1.png and /dev/null differ diff --git a/assets/images/holiday/1/home_4_0.png b/assets/images/holiday/1/home_4_0.png deleted file mode 100644 index 3b77a72..0000000 Binary files a/assets/images/holiday/1/home_4_0.png and /dev/null differ diff --git a/assets/images/holiday/1/home_4_1.png b/assets/images/holiday/1/home_4_1.png deleted file mode 100644 index 8c34fae..0000000 Binary files a/assets/images/holiday/1/home_4_1.png and /dev/null differ diff --git a/assets/images/holiday/1/home_5_0.png b/assets/images/holiday/1/home_5_0.png deleted file mode 100644 index b9f013b..0000000 Binary files a/assets/images/holiday/1/home_5_0.png and /dev/null differ diff --git a/assets/images/holiday/1/home_5_1.png b/assets/images/holiday/1/home_5_1.png deleted file mode 100644 index bc26e5b..0000000 Binary files a/assets/images/holiday/1/home_5_1.png and /dev/null differ diff --git a/assets/images/holiday/1/ic_launcher.webp b/assets/images/holiday/1/ic_launcher.webp deleted file mode 100644 index 3b00caa..0000000 Binary files a/assets/images/holiday/1/ic_launcher.webp and /dev/null differ diff --git a/assets/images/holiday/1/mine_agent_enter.webp b/assets/images/holiday/1/mine_agent_enter.webp deleted file mode 100644 index 064df1d..0000000 Binary files a/assets/images/holiday/1/mine_agent_enter.webp and /dev/null differ diff --git a/assets/images/holiday/1/mine_ai_enter.webp b/assets/images/holiday/1/mine_ai_enter.webp deleted file mode 100644 index 0916c7f..0000000 Binary files a/assets/images/holiday/1/mine_ai_enter.webp and /dev/null differ diff --git a/assets/images/holiday/1/mine_gold_enter.webp b/assets/images/holiday/1/mine_gold_enter.webp deleted file mode 100644 index 84f3681..0000000 Binary files a/assets/images/holiday/1/mine_gold_enter.webp and /dev/null differ diff --git a/assets/images/holiday/1/mine_vip_bg.webp b/assets/images/holiday/1/mine_vip_bg.webp deleted file mode 100644 index 14ca21b..0000000 Binary files a/assets/images/holiday/1/mine_vip_bg.webp and /dev/null differ diff --git a/assets/images/holiday/1/mine_vip_enter.webp b/assets/images/holiday/1/mine_vip_enter.webp deleted file mode 100644 index 691141e..0000000 Binary files a/assets/images/holiday/1/mine_vip_enter.webp and /dev/null differ diff --git a/assets/images/holiday/1/publish.png b/assets/images/holiday/1/publish.png deleted file mode 100644 index 5ee765a..0000000 Binary files a/assets/images/holiday/1/publish.png and /dev/null differ diff --git a/assets/images/holiday/5/home_0_0.png b/assets/images/holiday/5/home_0_0.png deleted file mode 100644 index a35b2a9..0000000 Binary files a/assets/images/holiday/5/home_0_0.png and /dev/null differ diff --git a/assets/images/holiday/5/home_0_1.png b/assets/images/holiday/5/home_0_1.png deleted file mode 100644 index a35b2a9..0000000 Binary files a/assets/images/holiday/5/home_0_1.png and /dev/null differ diff --git a/assets/images/holiday/5/home_1_0.png b/assets/images/holiday/5/home_1_0.png deleted file mode 100644 index e84480b..0000000 Binary files a/assets/images/holiday/5/home_1_0.png and /dev/null differ diff --git a/assets/images/holiday/5/home_1_1.png b/assets/images/holiday/5/home_1_1.png deleted file mode 100644 index e84480b..0000000 Binary files a/assets/images/holiday/5/home_1_1.png and /dev/null differ diff --git a/assets/images/holiday/5/home_2_0.png b/assets/images/holiday/5/home_2_0.png deleted file mode 100644 index 36497b9..0000000 Binary files a/assets/images/holiday/5/home_2_0.png and /dev/null differ diff --git a/assets/images/holiday/5/home_2_1.png b/assets/images/holiday/5/home_2_1.png deleted file mode 100644 index 36497b9..0000000 Binary files a/assets/images/holiday/5/home_2_1.png and /dev/null differ diff --git a/assets/images/holiday/5/home_3_0.png b/assets/images/holiday/5/home_3_0.png deleted file mode 100644 index 0fd24f7..0000000 Binary files a/assets/images/holiday/5/home_3_0.png and /dev/null differ diff --git a/assets/images/holiday/5/home_3_1.png b/assets/images/holiday/5/home_3_1.png deleted file mode 100644 index 0fd24f7..0000000 Binary files a/assets/images/holiday/5/home_3_1.png and /dev/null differ diff --git a/assets/images/holiday/5/home_4_0.png b/assets/images/holiday/5/home_4_0.png deleted file mode 100644 index 7f02243..0000000 Binary files a/assets/images/holiday/5/home_4_0.png and /dev/null differ diff --git a/assets/images/holiday/5/home_4_1.png b/assets/images/holiday/5/home_4_1.png deleted file mode 100644 index 7f02243..0000000 Binary files a/assets/images/holiday/5/home_4_1.png and /dev/null differ diff --git a/assets/images/holiday/5/home_5_0.png b/assets/images/holiday/5/home_5_0.png deleted file mode 100644 index c81384a..0000000 Binary files a/assets/images/holiday/5/home_5_0.png and /dev/null differ diff --git a/assets/images/holiday/5/home_5_1.png b/assets/images/holiday/5/home_5_1.png deleted file mode 100644 index c81384a..0000000 Binary files a/assets/images/holiday/5/home_5_1.png and /dev/null differ diff --git a/assets/images/holiday/5/ic_launcher.webp b/assets/images/holiday/5/ic_launcher.webp deleted file mode 100644 index 3b00caa..0000000 Binary files a/assets/images/holiday/5/ic_launcher.webp and /dev/null differ diff --git a/assets/images/holiday/5/icon_broadcast.png b/assets/images/holiday/5/icon_broadcast.png deleted file mode 100644 index 1a0d344..0000000 Binary files a/assets/images/holiday/5/icon_broadcast.png and /dev/null differ diff --git a/assets/images/holiday/5/main_bg.webp b/assets/images/holiday/5/main_bg.webp deleted file mode 100644 index 1f997c7..0000000 Binary files a/assets/images/holiday/5/main_bg.webp and /dev/null differ diff --git a/assets/images/holiday/5/mine_agent_enter.webp b/assets/images/holiday/5/mine_agent_enter.webp deleted file mode 100644 index 064df1d..0000000 Binary files a/assets/images/holiday/5/mine_agent_enter.webp and /dev/null differ diff --git a/assets/images/holiday/5/mine_ai_enter.webp b/assets/images/holiday/5/mine_ai_enter.webp deleted file mode 100644 index 0916c7f..0000000 Binary files a/assets/images/holiday/5/mine_ai_enter.webp and /dev/null differ diff --git a/assets/images/holiday/5/mine_bg.webp b/assets/images/holiday/5/mine_bg.webp deleted file mode 100644 index 2957445..0000000 Binary files a/assets/images/holiday/5/mine_bg.webp and /dev/null differ diff --git a/assets/images/holiday/5/mine_gold_enter.webp b/assets/images/holiday/5/mine_gold_enter.webp deleted file mode 100644 index 84f3681..0000000 Binary files a/assets/images/holiday/5/mine_gold_enter.webp and /dev/null differ diff --git a/assets/images/holiday/5/mine_vip_bg.webp b/assets/images/holiday/5/mine_vip_bg.webp deleted file mode 100644 index 14ca21b..0000000 Binary files a/assets/images/holiday/5/mine_vip_bg.webp and /dev/null differ diff --git a/assets/images/holiday/5/mine_vip_enter.webp b/assets/images/holiday/5/mine_vip_enter.webp deleted file mode 100644 index 691141e..0000000 Binary files a/assets/images/holiday/5/mine_vip_enter.webp and /dev/null differ diff --git a/assets/images/holiday/5/publish.png b/assets/images/holiday/5/publish.png deleted file mode 100644 index 55d7a0f..0000000 Binary files a/assets/images/holiday/5/publish.png and /dev/null differ diff --git a/assets/images/holiday/5/sign_icon.png b/assets/images/holiday/5/sign_icon.png deleted file mode 100644 index 2a1d163..0000000 Binary files a/assets/images/holiday/5/sign_icon.png and /dev/null differ diff --git a/lib/hj_utils/holiday_theme.dart b/lib/hj_utils/holiday_theme.dart deleted file mode 100644 index 25458c8..0000000 --- a/lib/hj_utils/holiday_theme.dart +++ /dev/null @@ -1,144 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:jydj/assets_tool/images.dart'; - -final holidayTheme = HolidayTheme._(); - -class HolidayTheme { - HolidayTheme._(); - - /// 节假日 → (主题编号, 桌面图标 index);春节不换图标,icon 为 0 - static const Map _festivalMap = { - '默认': (theme: 1, icon: 0), - '清明节': (theme: 2, icon: 16), - '端午节': (theme: 3, icon: 17), - '劳动节': (theme: 4, icon: 18), - '春节': (theme: 5, icon: 0), - }; - - int themeValue = 1; - int iconIndex = 0; - final HolidayThemeModel _model = HolidayThemeModel(); - - static HolidayThemeModel get theme => holidayTheme._model; - - /// 是否打开节假日 - bool get openHoliday => themeValue > 1; - - void setThemeValue(String festival) { - final cfg = _festivalMap[festival] ?? const (theme: 1, icon: 0); - themeValue = cfg.theme; - iconIndex = cfg.icon; - final m = _model; - m.current = themeValue; - - m.appIcon = _p('ic_launcher.webp'); - m.searchBgImg = _p('search_bg.webp'); - m.addPublish = _p('publish.png'); - m.vipBg = _p('mine_vip_bg.webp'); - m.coin = _p('mine_gold_enter.webp'); - m.agent = _p('mine_agent_enter.webp'); - m.vipEnter = _p('mine_vip_enter.webp'); - m.aiEnter = _p('mine_ai_enter.webp'); - - m.homeNor = _p('home_0_0.png'); - m.homeSel = _p('home_0_1.png'); - m.shortNor = _p('home_1_0.png'); - m.shortSel = _p('home_1_1.png'); - m.darkNor = _p('home_2_0.png'); - m.darkSel = _p('home_2_1.png'); - m.aiNor = _p('home_3_0.png'); - m.aiSel = _p('home_3_1.png'); - m.communityNor = _p('home_4_0.png'); - m.communitySel = _p('home_4_1.png'); - m.mineNor = _p('home_5_0.png'); - m.mineSel = _p('home_5_1.png'); - - // 默认三块背景都用 main_bg,仅 case 3/5 单独覆盖 - final mainBg = _p('main_bg.webp'); - m.mainBg = mainBg; - m.findBg = mainBg; - m.mineBg = mainBg; - - // 节日特有图标默认清空,避免切换主题后残留 - m.signIcon = ''; - m.broadcastIcon = ''; - - switch (themeValue) { - case 1: - m.tabSelColor = const Color(0xffFFC000); - m.tabNorColor = const Color(0x59FFFFFF); - m.searchBgColor = Colors.black.withValues(alpha: 0.08); - m.searchTxColor = const Color(0xff999999); - break; - case 2: - m.tabSelColor = const Color(0xff00BBB8); - m.tabNorColor = const Color(0xff5D6573); - m.searchBgColor = const Color(0xff41D0AB); - m.searchTxColor = Colors.white; - break; - case 3: - m.tabSelColor = const Color(0xff07738D); - m.tabNorColor = const Color(0xff5D6573); - m.searchBgColor = const Color(0xff8EC58E); - m.searchTxColor = Colors.white; - m.findBg = _p('find_bg.webp'); - m.mineBg = _p('mine_bg.webp'); - break; - case 4: - m.tabSelColor = const Color(0xffBE0209); - m.tabNorColor = const Color(0xff6C6C6C); - m.searchBgColor = const Color(0xffD7221D); - m.searchTxColor = Colors.white; - break; - case 5: - m.tabSelColor = const Color(0xffFFC000); - m.tabNorColor = const Color(0xff989898); - m.searchBgColor = Colors.black.withValues(alpha: 0.08); - m.searchTxColor = Colors.white; - m.mineBg = _p('mine_bg.webp'); - m.signIcon = _p('sign_icon.png'); - m.broadcastIcon = _p('icon_broadcast.png'); - break; - } - } - - String _p(String name) => '$themeValue/$name'.holidayPath; -} - -class HolidayThemeModel { - int current = 1; - - String appIcon = ''; - String searchBgImg = ''; - String addPublish = ''; - String vipBg = ''; - String coin = ''; - String agent = ''; - String vipEnter = ''; - String aiEnter = ''; - - String homeNor = ''; - String homeSel = ''; - String shortNor = ''; - String shortSel = ''; - String darkNor = ''; - String darkSel = ''; - String aiNor = ''; - String aiSel = ''; - String communityNor = ''; - String communitySel = ''; - String mineNor = ''; - String mineSel = ''; - - String mainBg = ''; - String findBg = ''; - String mineBg = ''; - - String signIcon = ''; - String broadcastIcon = ''; - - Color tabNorColor = const Color(0x59FFFFFF); - Color tabSelColor = const Color(0xffFFC000); - Color searchBgColor = Colors.black; - Color searchTxColor = const Color(0xff999999); -} diff --git a/lib/im_manager/api/im_api_service.dart b/lib/im_manager/api/im_api_service.dart deleted file mode 100644 index aa9ccba..0000000 --- a/lib/im_manager/api/im_api_service.dart +++ /dev/null @@ -1,76 +0,0 @@ -import 'package:jydj/tools_base/net/base_resp_bean.dart'; -import 'package:jydj/tools_base/net/http_manager.dart'; - -import '../im_ad/im_ad_item.dart'; -import '../model/im_user_model.dart'; - -class IMApiService { - ///重新获取 用户 imToken - static Future getImToken() async { - final result = await httpManager.fetchResponseByPOST('/im/token', - param: {}, jsonTransformation: (json) => ImTokenInfo.fromJson(json)); - return result.data; - } - - ///获取 用户 im userid - static Future getImUserInfo({int? uid, int? imUserId}) async { - var data = {'userId': uid, 'imUserId': imUserId}; - data.removeWhere((key, value) => value == null); - final result = await httpManager.fetchResponseByPOST('/im/user-id', - param: data, jsonTransformation: (json) => ImUserModel.fromJson(json)); - - return result.data; - } - - ///添加好友 - static Future ensureFriend(int? uid) async { - final result = await httpManager.fetchResponseByPOST( - '/im/friend/ensure', - param: {'peerId': uid}, - jsonTransformation: (json) => ImUserModel.fromJson(json), - ); - - return result.data; - } - - ///发送im消息 - static Future sendGroupMessage(int? groupId, String? content, String? image) async { - var data = {'groupId': groupId, 'content': content, 'image': image}; - data.removeWhere((key, value) => value == null); - final result = await httpManager.fetchResponseByPOST('/immessage/send', param: data); - return result; - } - - static Future getImAdList(List positions) async { - var data = {'positions': positions}; - final result = await httpManager.fetchResponseByPOST( - '/im/ad', - param: data, - jsonTransformation: (json) => ImAdResp.fromJson(json), - ); - return result.data; - } - - ///发送私信消息(后端转发到 IM,同时扣费) - ///[takeUid] 接收用户uid [content] 消息内容 [linkUrl] 附带链接 [imgUrl] 图片链接列表 - static Future sendPriLetter({ - required int takeUid, - String? content, - String? linkUrl, - List? imgUrl, - }) async { - var data = { - 'takeUid': takeUid, - 'content': content, - 'linkUrl': linkUrl, - 'imgUrl': imgUrl, - }; - data.removeWhere((key, value) => value == null || (value is List && value.isEmpty)); - final result = await httpManager.fetchResponseByPOST( - '/im/message/send', - param: data, - jsonTransformation: (json) => ImUserWallet.fromJson(json), - ); - return result; - } -} diff --git a/lib/im_manager/chat_detail_page/Im_ad_view.dart b/lib/im_manager/chat_detail_page/Im_ad_view.dart deleted file mode 100644 index bee829f..0000000 --- a/lib/im_manager/chat_detail_page/Im_ad_view.dart +++ /dev/null @@ -1,283 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:jydj/assets_tool/images.dart'; -import 'package:jydj/im_manager/im_ad/im_ad_manager.dart'; -import 'package:jydj/tools_base/banner/ads_item.dart'; -import 'package:jydj/hj_utils/screen.dart'; -import 'package:provider/provider.dart'; - -import '../../hj_model/splash/ads_model.dart'; -import '../../tools_base/banner/ads_banner.dart'; -import '../../tools_base/widget/card_swiper/src/swiper.dart'; -import '../../tools_base/widget/net_image_widget.dart'; -import '../../track_event_manager/track_manager.dart'; -import '../im_ad/im_ad_item.dart'; -import '../im_ad/im_ad_provider.dart'; -import '../im_notification.dart'; - -class ImAdView extends StatefulWidget { - final String position; - final EdgeInsetsGeometry? padding; - final ImAdItem? adItem; - - ImAdView({super.key, required this.position, this.padding, this.adItem}); - - @override - State createState() { - return _ImAdViewState(); - } -} - -class _ImAdViewState extends State { - bool _closeFloatAd = false; - - List get adImList { - return ImAdProvider().adByPosition(widget.position)?.list ?? []; - } - - List get advList { - return adImList.map((e) => e.covertAd).toList(); - } - - @override - Widget build(BuildContext context) { - return Consumer( - builder: (context, provider, child) { - if (widget.position == ImAdManager.kPosChatFloatingGifBall) { - return _buildFloatStyle(); - } else if (widget.position == ImAdManager.kPosListBanner) { - return _buildBannerStyle(); - } else if (widget.position == ImAdManager.kPosListFeedNative) { - return _buildInsertCellStyle(); - } else if (widget.position == ImAdManager.kPosChatNotificationBar) { - return _buildDetailTopStyle(); - } else { - return SizedBox(); - } - }, - ); - } - - Widget _buildDetailTopStyle() { - if (_closeFloatAd) return SizedBox(); - final List items = widget.adItem != null ? [widget.adItem!] : adImList; - if (items.isEmpty) return SizedBox(); - return Container( - height: 68, - color: Color(0x1AE5365C), - padding: widget.padding ?? EdgeInsets.symmetric(horizontal: 12), - child: Row( - children: [ - Expanded( - child: Swiper( - scrollDirection: Axis.vertical, - axisDirection: AxisDirection.down, - autoplay: items.length > 1, - autoplayDelay: 3000, - loop: items.length > 1, - itemCount: items.length, - itemBuilder: (context, index) { - final adImItem = items[index]; - return AdsItem( - adInfo: adImItem.covertAd, - exposureModel: AdExposureModel(pageType: TrackEventPageType.imChatDetail, adType: AdType.smallIcon), - child: _buildDetailTopAdRow(adImItem), - ); - }, - ), - ), - 2.sizeBoxW, - InkWell( - onTap: () { - _closeFloatAd = true; - setState(() {}); - }, - child: Container( - padding: EdgeInsets.fromLTRB(6, 4, 4, 6), - child: Image.asset("ad_close.png".commonImgPath, width: 10), - ), - ), - ], - ), - ); - } - - Widget _buildDetailTopAdRow(ImAdItem adImItem) { - return Row( - children: [ - NetworkImageLoader( - imageUrl: adImItem.cover ?? "", - width: 48, - height: 48, - borderRadius: 48, - ), - 12.sizeBoxW, - Text( - adImItem.title ?? "", - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.w500, - color: Colors.white.withValues(alpha: .9), - ), - ), - 6.sizeBoxW, - Container( - padding: EdgeInsets.symmetric(horizontal: 6, vertical: 1), - decoration: BoxDecoration( - color: Colors.white.withValues(alpha: .1), - borderRadius: BorderRadius.circular(6), - ), - child: Text( - "广告", - style: TextStyle(fontSize: 10, color: Colors.white.withValues(alpha: .9)), - ), - ), - Spacer(), - Container( - width: 48, - height: 24, - alignment: Alignment.center, - decoration: BoxDecoration( - color: Color(0xffFFC000), - borderRadius: BorderRadius.circular(24), - ), - child: Text( - "领取", - style: TextStyle(fontSize: 12, color: Color(0xFFFFFFFF)), - ), - ), - ], - ); - } - - Widget _buildInsertCellStyle() { - if (widget.adItem == null) return SizedBox(); - bool isShowRed = ImAdProvider().isUnreadAd(widget.adItem!.id); - return AdsItem( - adInfo: widget.adItem!.covertAd, - exposureModel: AdExposureModel(pageType: TrackEventPageType.imChatDetail, adType: AdType.smallIcon), - onTap: () { - ImAdProvider().notifyClickAd(widget.adItem!.id); - ImMsgProvider().setUnReadStatusFromAd(); - }, - child: Container( - height: 68, - color: Color(0x1AE5365C), - padding: widget.padding ?? EdgeInsets.symmetric(horizontal: 12, vertical: 10), - child: Row( - children: [ - NetworkImageLoader( - imageUrl: widget.adItem?.cover ?? "", - width: 48, - height: 48, - borderRadius: 48, - ), - 12.sizeBoxW, - Expanded( - child: Column( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Text( - widget.adItem?.title ?? "", - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.w500, - color: Colors.white, - ), - ), - if (isShowRed) - Container( - width: 10, - height: 10, - margin: EdgeInsets.only(bottom: 6, left: 4), - decoration: BoxDecoration( - color: Color(0xffF52C56), - borderRadius: BorderRadius.circular(10), - ), - ), - Spacer(), - Text( - "广告", - style: TextStyle(fontSize: 12, color: Colors.white.withValues(alpha: .55)), - ), - ], - ), - Text( - "", - maxLines: 1, - style: TextStyle(fontSize: 14, color: Color(0x8CFFFFFF)), - ), - ], - ), - ), - ], - ), - ), - ); - } - - Widget _buildBannerStyle() { - if (advList.isEmpty) return SizedBox(); - return Container( - padding: widget.padding, - child: ClipRRect( - borderRadius: BorderRadius.circular(8), - child: AspectRatio( - aspectRatio: 720 / 150, - child: AdsBanner( - advList, - exposureModel: AdExposureModel(pageType: TrackEventPageType.imChatCenter, adType: AdType.banner), - ), - ), - ), - ); - } - - Widget _buildFloatStyle() { - if (_closeFloatAd || advList.isEmpty) return SizedBox(); - return SizedBox( - height: 60, - width: 60, - child: Stack( - alignment: Alignment.bottomLeft, - children: [ - SizedBox( - height: 60, - width: 60, - child: Swiper( - index: 0, - autoplay: (adImList.length > 1), - autoplayDelay: 3000, - loop: adImList.length == 1 ? false : true, - itemBuilder: (BuildContext context, int index) { - ImAdItem adItem = adImList[index]; - return AdsItem( - adInfo: adItem.covertAd, - showType: AdShowType.img, - borderRadius: 70, - exposureModel: AdExposureModel(pageType: TrackEventPageType.imChatDetail, adType: AdType.smallIcon), - ); - }, - itemCount: adImList.length, - ), - ), - Positioned( - top: 0, - right: 0, - child: InkWell( - onTap: () { - _closeFloatAd = true; - setState(() {}); - }, - child: Image.asset("ad_close.png".commonImgPath, width: 15), - ), - ), - ], - ), - ); - } -} diff --git a/lib/im_manager/chat_detail_page/chat_detail_logic.dart b/lib/im_manager/chat_detail_page/chat_detail_logic.dart deleted file mode 100644 index e162175..0000000 --- a/lib/im_manager/chat_detail_page/chat_detail_logic.dart +++ /dev/null @@ -1,382 +0,0 @@ -import 'dart:async'; -import 'dart:convert'; -import 'dart:math'; - -import 'package:flutter/material.dart'; -import 'package:flutter_mm_core/im_core.dart'; -import 'package:get/get.dart'; -import 'package:jydj/hj_model/user/wallet_model.dart'; -import 'package:jydj/tools_base/debug_log.dart'; -import 'package:jydj/tools_base/file_upload/file_upload_tool.dart'; -import 'package:jydj/tools_base/file_upload/upload_result_model.dart'; -import 'package:jydj/tools_base/global_store/store.dart'; -import 'package:jydj/im_manager/api/im_api_service.dart'; -import 'package:jydj/im_manager/chat_detail_page/widget/input_sheet_view.dart'; -import 'package:jydj/im_manager/im_manager.dart'; -import 'package:jydj/routers/jump_router.dart'; -import 'package:jydj/tools_base/toast.dart'; -import 'package:pull_to_refresh/pull_to_refresh.dart'; - -import '../im_notification.dart'; -import '../model/im_message_resp.dart'; -import '../model/im_user_model.dart'; - -enum IMConnectStatus { - isLoading, - success, - fail, -} - -class ChatDetailLogic extends GetxController with WidgetsBindingObserver { - final int sessionId; - final String? peerAvatar; - - ChatDetailLogic(this.sessionId, {this.peerAvatar}); - - /// 聊天页可见性(跨端契约 A5): - /// - 前台 + 页面在栈顶 → true(收到对端消息自动 markRead) - /// - 后台 / paused / inactive → false(不 markRead,未读累积) - bool _isPageVisible = true; - - /// SDK 单聊会话主键格式:`${otherId}_${chatType}`(1v1 chatType=0) - String get _primaryId => '${sessionId}_0'; - - ModelUserInfo? imPeerModel; // IM服务用户信息 - ImUserModel? peerUserModel; // 本地业务用户信息 - IMConnectStatus imStatus = IMConnectStatus.isLoading; - - List? msgList; - - // pullYsRefresh 内部创建并释放,这里仅持有引用用于翻页状态回调 - RefreshController? refreshController; - final ScrollController scrollController = ScrollController(); - TextEditingController textEditController = TextEditingController(); - Rx selectedImagePath = Rx(null); - RxBool isSendingMsg = false.obs; - late StreamSubscription _subscription; - bool _hasNext = true; // 是否还有更早的历史消息 - bool _isLoadingData = false; // 翻页加载中,防重复触发 - - WalletModel? get wallet => globalStore.wallet; - - int get sendMsgPrice => globalStore.meInfo?.sendMsgPrice ?? 0; - - @override - void didChangeAppLifecycleState(AppLifecycleState state) { - final wasVisible = _isPageVisible; - _isPageVisible = state == AppLifecycleState.resumed; - if (!wasVisible && _isPageVisible) { - // 后台 → 前台:补一次 markRead 同步累积的未读到服务端 - _refreshConversationAndMarkRead(); - } - } - - @override - void onReady() { - globalStore.refreshWallet(); - initImEvn(); - _subscription = imEventBus.on(_receiveMsg); - scrollController.addListener(_scrollListener); - super.onReady(); - } - - // 滚动到距顶部半屏内时提前预加载更早消息,避免拉到边缘才触发的生硬感 - void _scrollListener() { - if (!scrollController.hasClients) return; - final maxExtent = scrollController.position.maxScrollExtent; - final gapToTop = maxExtent - scrollController.offset; - if (maxExtent > 30 && _hasNext && gapToTop < Get.height / 2) { - loadMoreData(); - } - } - - void initImEvn() async { - var isConnect = await ImManager().connectServer(); - if (isConnect == false) { - imStatus = IMConnectStatus.fail; - update(); - return; - } - await _loadPeerImUserId(); - await _loadMsgData(); - _refreshConversationAndMarkRead(); - imStatus = IMConnectStatus.success; - update(); - } - - Future _loadPeerImUserId() async { - // 拉对方资料(imToken sdk 走 ImCore.getUserInfoById) - try { - imPeerModel = await ImCore.getUserInfoById(sessionId); - peerUserModel = await IMApiService.getImUserInfo(imUserId: sessionId); - } catch (e) { - debugLog('IM- loadPeerInfo:$e'); - } - } - - void _fillMessageAvatar(IMMessageModel model) { - if (model.fromSelf == true) { - model.senderNickName = globalStore.meInfo?.name; - model.senderAvatar = globalStore.meInfo?.portrait; - } else { - model.receiverNickName = imPeerModel?.name; - model.receiverAvatar = peerAvatar; - } - } - - void refreshData() { - _loadMsgData(); - } - - void loadMoreData() { - if (msgList?.isNotEmpty == true) { - _loadMsgData(beforeTimestamp: msgList!.last.createdAt); - } else { - refreshController?.loadComplete(); - } - } - - Future _loadMsgData({int? beforeTimestamp, int size = 20}) async { - if (_isLoadingData) return; - _isLoadingData = true; - try { - List retList = await ImCore.loadMessages( - sessionId.toString(), - beforeTimestamp: beforeTimestamp, - limit: size, - ); - debugLog(jsonEncode(retList - .map((m) => { - 'chatMessageType': m.chatMessageType, - 'senderID': m.senderID, - 'receiverID': m.receiverID, - 'content': m.content, - 'extra': m.extra, - 'sequence': m.sequence, - 'isGroup': m.isGroup, - 'timestamp': m.timestamp, - 'readingStatus': m.readingStatus, - 'recall': m.recall, - 'url': m.url, - 'localPath': m.localPath, - 'size': m.size, - 'width': m.width, - 'height': m.height, - 'length': m.length, - 'thumbURL': m.thumbURL, - 'messageState': m.messageState, - 'messageId': m.messageId, - 'localId': m.localId, - 'quoteMsgId': m.quoteMsgId, - 'quoteMsgState': m.quoteMsgState, - }) - .toList())); - msgList ??= []; - if (beforeTimestamp == null) { - msgList?.clear(); - } - var convertList = retList.map((e) => IMMessageModel.convertModel(e)).toList(); - for (var model in convertList) { - _fillMessageAvatar(model); - } - msgList?.addAll(convertList); - if (retList.length == size) { - _hasNext = true; - refreshController?.loadComplete(); - } else { - _hasNext = false; - refreshController?.loadNoData(); - } - } catch (e) { - msgList ??= []; - debugLog(e); - refreshController?.loadComplete(); - } - refreshController?.refreshCompleted(); - _isLoadingData = false; - update(); - } - - Future readySendMsg({bool? isSend}) async { - if (isSend == true && (textEditController.text.isNotEmpty || selectedImagePath.value?.isNotEmpty == true)) { - // 点击发送按钮有内容,直接发送内容, 否则弹出输入框 - sendMsgEvent(); - } else { - final result = await Get.bottomSheet( - InputSheetView( - hint: sendMsgPrice > 0 ? "私信需要消费$sendMsgPrice金币" : "请输入内容", - textEditController: textEditController, - selectedImgPath: selectedImagePath.value, - imgCallback: (value) { - selectedImagePath.value = value; - }, - ), - backgroundColor: Colors.transparent, - barrierColor: Colors.transparent, - persistent: false, - isScrollControlled: true, - enableDrag: false, - settings: RouteSettings(name: 'im_chat_input'), - useRootNavigator: true, - ); - update(); - if (result == true) { - sendMsgEvent(); - } - } - } - - /// 发送消息(HTTP)。[directImagePath] 非空时为"选图即发":只发该图片,不读输入框/不动预览 - /// 发送成功后消息由服务端回推,经 [_receiveMsg] 上屏(不做本地乐观插入) - Future sendMsgEvent({String? directImagePath}) async { - final bool isDirectImage = directImagePath?.isNotEmpty == true; - final String content = isDirectImage ? '' : textEditController.text; - final String? localImg = isDirectImage ? directImagePath : selectedImagePath.value; - - if (content.isEmpty && localImg?.isNotEmpty != true) { - showToast("请输入消息内容"); - return; - } - if (sendMsgPrice > (wallet?.amount ?? 0)) { - showToast("余额不足, 请先充值"); - pushToWalletPage(tabPosition: 1); - return; - } - if (isSendingMsg.value) return; - isSendingMsg.value = true; - String? sendImagePath; - if (localImg?.isNotEmpty == true) { - try { - ImageUploadResultModel? imgResult = await FileUploadTool().uploadImage(localImg!); - sendImagePath = imgResult?.coverImg ?? ""; - if (sendImagePath.isEmpty) { - isSendingMsg.value = false; - showToast("图片上传失败!"); - return; - } - } catch (e) { - debugLog(e); - isSendingMsg.value = false; - showToast("图片上传失败!"); - return; - } - } - if (peerUserModel?.userId == null) { - showToast("消息发送失败!"); - isSendingMsg.value = false; - return; - } - try { - final bool isImageType = sendImagePath?.isNotEmpty == true; - final String textContent = content; - final resp = await IMApiService.sendPriLetter( - takeUid: peerUserModel!.userId!, - content: isImageType ? null : textContent, - imgUrl: isImageType ? [sendImagePath!] : null, - ); - if (resp.isSuccess) { - if (!isDirectImage) { - textEditController.text = ""; - selectedImagePath.value = null; - } - globalStore.refreshWallet(); // 扣费后刷新余额 - if (textContent.isNotEmpty && isImageType) { - // 发送图片再发文本 - await IMApiService.sendPriLetter( - takeUid: peerUserModel!.userId!, - content: textContent, - ); - } - } else { - showToast(resp.toast.isNotEmpty ? resp.toast : "消息发送失败!"); - } - } catch (e) { - debugLog('sendMsg: $e'); - showToast("消息发送失败!"); - } - isSendingMsg.value = false; - update(); - } - - void _receiveMsg(IMMsgEvent event) { - debugLog("IM-消息 --"); - if (event.msgModel == null) return; - IMMessageModel msgModel = event.msgModel!; - if (msgModel.receiverId == sessionId.toString() || msgModel.senderId == sessionId.toString()) { - debugLog("当前聊天页面 发送/收到消息"); - if (event.model!.messageState == 2) { - bool isExist = false; - if (msgModel.receiverId == sessionId.toString()) { - // 自己发的消息回推:防重(收到连续两次回调的情况) - for (int i = 0; i < min((msgList ?? []).length, 6); i++) { - if (msgList![i].messageId == msgModel.messageId) { - isExist = true; - } - } - } - if (!isExist) { - msgList ??= []; - _fillMessageAvatar(msgModel); - msgList!.insert(0, msgModel); - } - } else if (event.model!.messageState == 1) { - // 失败 - } else {} - if (_isPageVisible) { - // A4:聊天页可见时收到对端消息自动 markRead,未读不累积 - // A5:可见性门禁;后台/inactive 不主动 markRead,等回前台补一次 - final seq = msgModel.seq ?? 0; - if (seq > 0) { - unawaited(_markConversationRead(seq: msgModel.seq ?? 0)); - } - } - } - update(); - } - - // 发送会话通知,消息已读,取消会话红点 - Future _markConversationRead({required int seq}) async { - try { - await ImCore.setConversationRead(_primaryId); - } catch (e) { - debugLog('_markConversationRead local: $e'); - } - if (seq <= 0) return; - try { - await ImCore.sendConversationRead(receiverId: sessionId, seq: seq); - } catch (e) { - debugLog('_markConversationRead server: $e'); - } - } - - Future _refreshConversationAndMarkRead() async { - final c = await _fetchCurSession(); - if (c == null) return; - await _markConversationRead(seq: c.seq); - } - - Future _fetchCurSession() async { - try { - ModelConversationListResult sessionRet = await ImCore.getConversations( - includeArchivedCount: false, - includeTotalUnreadCount: false, - ); - for (final target in sessionRet.conversations) { - if (target.targetId == sessionId.toString()) { - return target; - } - } - } catch (e) { - debugLog('_fetchCurSession server: $e'); - } - return null; - } - - @override - void onClose() { - _subscription.cancel(); - scrollController.removeListener(_scrollListener); - scrollController.dispose(); - super.onClose(); - } -} diff --git a/lib/im_manager/chat_detail_page/chat_detail_page.dart b/lib/im_manager/chat_detail_page/chat_detail_page.dart deleted file mode 100644 index 26b629e..0000000 --- a/lib/im_manager/chat_detail_page/chat_detail_page.dart +++ /dev/null @@ -1,100 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:get/get.dart'; -import 'package:jydj/tools_base/loading/loading_center_widget.dart'; -import 'package:jydj/tools_base/refresh/pull_refresh.dart'; -import 'package:jydj/im_manager/chat_detail_page/Im_ad_view.dart'; -import 'package:jydj/im_manager/chat_detail_page/widget/input_foot_view.dart'; -import 'package:jydj/im_manager/im_ad/im_ad_manager.dart'; - -import '../model/im_message_resp.dart'; -import 'chat_detail_logic.dart'; -import 'widget/chat_item_cell.dart'; - -class ChatDetailPage extends StatelessWidget { - final int imUserId; // IM 用户 id - final String? userName; - final int? uid; // 业务用户 uid - final String? peerAvatar; // 对方头像 - - const ChatDetailPage({ - required this.imUserId, - required this.userName, - this.uid, - this.peerAvatar, - super.key, - }); - - @override - Widget build(BuildContext context) { - return GetBuilder( - init: ChatDetailLogic(imUserId, peerAvatar: peerAvatar), - tag: "$imUserId", - builder: (logic) { - final peerUid = logic.peerUserModel?.userId ?? logic.peerUserModel?.peerId ?? uid; - return Scaffold( - appBar: AppBar(title: Text(userName ?? "")), - body: Stack( - children: [ - if (logic.imStatus == IMConnectStatus.isLoading) - LoadingCenterWidget() - else if (logic.imStatus == IMConnectStatus.fail) - CErrorWidget( - errorMsg: "连接用户失败", - retryOnTap: () => logic.initImEvn(), - ) - else - _buildContent(logic, peerUid), - Positioned( - right: 12, - bottom: 103, - child: ImAdView(position: ImAdManager.kPosChatFloatingGifBall), - ), - ], - ), - ); - }, - ); - } - - Widget _buildContent(ChatDetailLogic logic, int? peerUid) { - return Column( - children: [ - ImAdView(position: ImAdManager.kPosChatNotificationBar), - Expanded( - child: pullYsRefresh( - onInit: (ctr) => logic.refreshController = ctr, - enablePullDown: false, - enablePullUp: false, // 翻页靠滚动预加载,不用上拉 footer(避免顶部空一块) - child: ListView.builder( - controller: logic.scrollController, - itemCount: logic.msgList?.length ?? 0, - reverse: true, - padding: EdgeInsets.only(bottom: 12, top: 12), - itemBuilder: (context, index) { - IMMessageModel model = logic.msgList![index]; - return ChatItemCell(model: model, peerUid: peerUid); - }, - ), - ), - ), - Obx( - () => InputFootView( - imagePath: logic.selectedImagePath.value, - isSendStatus: logic.isSendingMsg.value, - placeholder: logic.sendMsgPrice > 0 ? "私信需要消费${logic.sendMsgPrice}金币" : "请输入内容", - imgCallback: (value) { - logic.selectedImagePath.value = value; - }, - onImageSend: (path) { - logic.sendMsgEvent(directImagePath: path); - }, - begainCommentHandler: (value) { - logic.readySendMsg(isSend: value); - }, - textEditController: logic.textEditController, - ), - ), - ], - ); - } -} diff --git a/lib/im_manager/chat_detail_page/chat_session_list_logic.dart b/lib/im_manager/chat_detail_page/chat_session_list_logic.dart deleted file mode 100644 index 1b9959a..0000000 --- a/lib/im_manager/chat_detail_page/chat_session_list_logic.dart +++ /dev/null @@ -1,94 +0,0 @@ -import 'dart:async'; - -import 'package:flutter_mm_core/im_core.dart'; -import 'package:get/get.dart'; -import 'package:jydj/tools_base/debug_log.dart'; -import 'package:jydj/im_manager/im_ad/im_ad_manager.dart'; -import 'package:jydj/im_manager/im_ad/im_ad_provider.dart'; -import 'package:jydj/tools_base/toast.dart'; - -import '../im_manager.dart'; -import '../model/im_message_resp.dart'; -import 'chat_detail_logic.dart'; - -class ChatSessionListLogic extends GetxController { - ChatSessionListLogic(); - - List? imSessionList; - IMConnectStatus imStatus = IMConnectStatus.isLoading; - - @override - void onInit() { - ImCore.addConversationChangedListener(_listerIMConversation); - _loadData(); - super.onInit(); - } - - void onInitData() { - imStatus = IMConnectStatus.isLoading; - imSessionList = null; - update(); - _loadData(); - } - - Future _loadData() async { - try { - var isConnect = await ImManager().connectServer(); - if (isConnect == false) { - imStatus = IMConnectStatus.fail; - showToast('消息连接失败,请稍后再试'); - } else { - imStatus = IMConnectStatus.success; - ModelConversationListResult sessionRet = await ImCore.getConversations( - includeArchivedCount: false, - includeTotalUnreadCount: false, - ); - imSessionList = IMSessionModel.covertData(sessionRet); - ImAdManager.insertAd(imSessionList, ImAdProvider().getAdList(ImAdManager.kPosListFeedNative)); - } - } catch (e) { - debugLog(e); - } - imSessionList ??= []; - update(); - } - - void _listerIMConversation() { - debugLog("_listerIMConversation 变化"); - unawaited(_loadData()); - } - - IMSessionModel? fetchSameSession(IMSessionModel info) { - for (IMSessionModel model in (imSessionList ?? [])) { - if (model.imUserId == info.imUserId) { - return model; - } - } - return null; - } - - void skipToChatDetail(IMSessionModel sessionInfo) async { - await jumpToChatDetail(sessionInfo); - IMSessionModel? model = fetchSameSession(sessionInfo); - if (model != null) { - model.unreadCount = 0; - update(); - _markConversationRead(sessionId: model.imUserId ?? 0); - } - } - - // 发送会话通知,消息已读,取消会话红点 - Future _markConversationRead({required int sessionId}) async { - try { - await ImCore.setConversationRead("${sessionId}_0"); - } catch (e) { - debugLog('iM- read1: $e'); - } - } - - @override - void onClose() { - ImCore.removeConversationChangedListener(_listerIMConversation); - super.onClose(); - } -} diff --git a/lib/im_manager/chat_detail_page/chat_session_list_view.dart b/lib/im_manager/chat_detail_page/chat_session_list_view.dart deleted file mode 100644 index 6f74cc3..0000000 --- a/lib/im_manager/chat_detail_page/chat_session_list_view.dart +++ /dev/null @@ -1,135 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:get/get.dart'; -import 'package:jydj/tools_base/loading/loading_center_widget.dart'; -import 'package:jydj/tools_base/widget/net_image_widget.dart'; -import 'package:jydj/im_manager/chat_detail_page/Im_ad_view.dart'; -import 'package:jydj/hj_utils/screen.dart'; - -import '../im_ad/im_ad_manager.dart'; -import '../model/im_message_resp.dart'; -import 'chat_detail_logic.dart'; -import 'chat_session_list_logic.dart'; - -class ChatSessionListView extends StatelessWidget { - final bool isSliverStyle; - - const ChatSessionListView({ - this.isSliverStyle = true, - super.key, - }); - - @override - Widget build(BuildContext context) { - return GetBuilder( - init: ChatSessionListLogic(), - builder: (logic) { - if (logic.imSessionList == null) { - return SliverToBoxAdapter( - child: SizedBox( - height: 300, - child: LoadingCenterWidget(), - ), - ); - } else if (logic.imStatus == IMConnectStatus.fail) { - return SliverToBoxAdapter( - child: CErrorWidget( - errorMsg: "消息连接失败", - retryOnTap: () => logic.onInitData(), - ), - ); - } else if (logic.imSessionList!.isEmpty) { - return SliverToBoxAdapter( - child: SizedBox( - height: 300, - child: CErrorWidget(), - ), - ); - } else { - return SliverList.builder( - itemCount: logic.imSessionList?.length ?? 0, - itemBuilder: (context, index) { - IMSessionModel model = logic.imSessionList![index]; - if (model.isAdStyle) { - return ImAdView( - position: ImAdManager.kPosListFeedNative, - adItem: model.adItem, - ); - } else { - return _buildItemInfo(logic, model); - } - }, - ); - } - }, - ); - } - - Widget _buildItemInfo(ChatSessionListLogic logic, IMSessionModel sessionInfo) { - return GestureDetector( - onTap: () => logic.skipToChatDetail(sessionInfo), - child: Container( - height: 68, - color: Colors.transparent, - padding: EdgeInsets.symmetric(horizontal: 12, vertical: 10), - child: Row( - children: [ - NetworkImageLoader( - imageUrl: sessionInfo.avatar ?? "", - width: 48, - height: 48, - borderRadius: 48, - ), - 12.sizeBoxW, - Expanded( - child: Column( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Text( - sessionInfo.nickName ?? "", - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.w500, - color: Colors.white, - ), - ), - if (sessionInfo.hasUnRead) - Container( - width: 10, - height: 10, - margin: EdgeInsets.only(bottom: 6, left: 4), - decoration: BoxDecoration( - color: Color(0xffF52C56), - borderRadius: BorderRadius.circular(10), - ), - ), - Spacer(), - Text( - sessionInfo.updatedYMD ?? "", - style: TextStyle( - fontSize: 12, - color:Color(0xff525252), - ), - ), - ], - ), - Text( - sessionInfo.messageType == 2 ? "[图片]" : sessionInfo.lastText ?? "", - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle( - fontSize: 14, - color: Color(0xff525252), - ), - ), - ], - ), - ), - ], - ), - ), - ); - } -} diff --git a/lib/im_manager/chat_detail_page/widget/chat_item_cell.dart b/lib/im_manager/chat_detail_page/widget/chat_item_cell.dart deleted file mode 100644 index 15e1f89..0000000 --- a/lib/im_manager/chat_detail_page/widget/chat_item_cell.dart +++ /dev/null @@ -1,234 +0,0 @@ -import 'package:flutter/cupertino.dart'; -import 'package:flutter/material.dart'; -import 'package:jydj/tools_base/global_store/store.dart'; -import 'package:jydj/tools_base/widget/net_image_widget.dart'; -import 'package:jydj/tools_base/widget/image_browser_page.dart'; -import 'package:jydj/routers/jump_router.dart'; - -import '../../model/im_message_resp.dart'; - -class ChatItemCell extends StatefulWidget { - final IMMessageModel? model; - final VoidCallback? onResend; - final int? peerUid; - - ChatItemCell({Key? key, this.model, this.onResend, this.peerUid}) : super(key: key); - - @override - State createState() { - return _ChatItemCellState(); - } -} - -class _ChatItemCellState extends State { - bool get isImg => widget.model?.image?.isNotEmpty == true; - - bool get isMe => widget.model?.fromSelf == true; - - double imageWidth = 173; - - void _showImageScan(String imageUrl) { - if (imageUrl.isNotEmpty) { - ImageBrowserPage.open([imageUrl]); - } - } - - @override - Widget build(BuildContext context) { - if (isMe) { - return _buildRightStyle(); - } else { - return _buildLeftStyle(); - } - } - - Widget _buildLeftStyle() { - return Container( - padding: EdgeInsets.fromLTRB(0, 12, 0, 6), - child: Column( - children: [ - Container( - padding: EdgeInsets.fromLTRB(16, 0, 50, 0), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - GestureDetector( - onTap: () => _openProfile(widget.peerUid), - child: SizedBox( - width: 40, - height: 40, - child: NetworkImageLoader( - imageUrl: widget.model?.receiverAvatar ?? "", - width: 40, - height: 40, - borderRadius: 20, - ), - ), - ), - SizedBox(width: 12), - Flexible( - child: Column( - mainAxisSize: MainAxisSize.max, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (isImg) - InkWell( - onTap: () { - _showImageScan(widget.model?.image ?? ""); - }, - child: Container( - margin: EdgeInsets.only(top: 5), - alignment: Alignment.centerLeft, - constraints: BoxConstraints(maxWidth: 173), - child: _buildImageWidget(isLeft: true), - ), - ) - else - Container( - padding: EdgeInsets.symmetric(horizontal: 14, vertical: 10), - margin: EdgeInsets.only(top: 5), - decoration: BoxDecoration( - color: Colors.white.withValues(alpha: .05), - borderRadius: BorderRadius.only( - bottomLeft: Radius.circular(12), - bottomRight: Radius.circular(12), - topRight: Radius.circular(12), - topLeft: Radius.circular(2)), - ), - child: Text( - widget.model?.content ?? "", - style: TextStyle( - color: Colors.white, - fontSize: 12, - ), - ), - ), - ], - ), - ), - ], - ), - ), - ], - ), - ); - } - - Widget _buildRightStyle() { - return Container( - padding: EdgeInsets.fromLTRB(0, 12, 0, 6), - child: Column( - children: [ - Container( - padding: EdgeInsets.fromLTRB(50, 0, 16, 0), - child: Row( - crossAxisAlignment: CrossAxisAlignment.center, - mainAxisAlignment: MainAxisAlignment.end, - children: [ - _buildSendStatus(), - Flexible( - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - if (isImg) - InkWell( - onTap: () { - _showImageScan(widget.model?.image ?? ""); - }, - child: Container( - margin: EdgeInsets.only(top: 5), - alignment: Alignment.centerRight, - constraints: BoxConstraints(maxWidth: 173), - child: _buildImageWidget(isLeft: false), - ), - ) - else - Container( - padding: EdgeInsets.symmetric(horizontal: 14, vertical: 10), - margin: EdgeInsets.only(top: 5), - decoration: BoxDecoration( - color: Color(0xff1DC194), - borderRadius: BorderRadius.only( - bottomLeft: Radius.circular(12), - bottomRight: Radius.circular(12), - topLeft: Radius.circular(12), - topRight: Radius.circular(4)), - ), - child: Text( - widget.model?.content ?? "", - style: TextStyle( - color: Color(0xFF000000), - fontSize: 12, - ), - ), - ), - ], - ), - ), - SizedBox(width: 12), - GestureDetector( - onTap: () => _openProfile(globalStore.meInfo?.uid), - child: SizedBox( - width: 40, - height: 40, - child: NetworkImageLoader( - imageUrl: widget.model?.senderAvatar ?? "", - width: 40, - height: 40, - borderRadius: 20, - ), - ), - ), - ], - ), - ), - ], - ), - ); - } - - // 发送状态指示: 0发送中(菊花) 1失败(红色叹号,点击重发) 其余正常不展示 - Widget _buildSendStatus() { - final state = widget.model?.messageState; - if (state == 0) { - return Padding( - padding: EdgeInsets.only(right: 8), - child: CupertinoActivityIndicator(radius: 8, color: Color(0xff999999)), - ); - } - if (state == 1) { - return GestureDetector( - onTap: widget.onResend, - child: Padding( - padding: EdgeInsets.only(right: 8), - child: Icon(Icons.error, color: Color(0xffFF004F), size: 22), - ), - ); - } - return const SizedBox.shrink(); - } - - void _openProfile(int? uid) { - if (uid != null && uid > 0) { - pushToPersonCenter(uid); - } - } - - Widget _buildImageWidget({bool isLeft = false}) { - return Stack( - children: [ - NetworkImageLoader( - imageUrl: widget.model?.image ?? "", - width: imageWidth, - imgBorderRadius: BorderRadius.only( - bottomLeft: Radius.circular(12), - bottomRight: Radius.circular(12), - topRight: Radius.circular(isLeft ? 12 : 0), - topLeft: Radius.circular(isLeft ? 0 : 12), - ), - ), - ], - ); - } -} diff --git a/lib/im_manager/chat_detail_page/widget/input_foot_view.dart b/lib/im_manager/chat_detail_page/widget/input_foot_view.dart deleted file mode 100644 index 8222797..0000000 --- a/lib/im_manager/chat_detail_page/widget/input_foot_view.dart +++ /dev/null @@ -1,157 +0,0 @@ -import 'dart:io'; - -import 'package:flutter/cupertino.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_screenutil/flutter_screenutil.dart'; -import 'package:jydj/assets_tool/images.dart'; -import 'package:jydj/tools_base/widget/group_text_filed.dart'; -import 'package:jydj/hj_utils/screen.dart'; -import 'package:image_pickers/image_pickers.dart'; - -class InputFootView extends StatefulWidget { - final Function(bool) begainCommentHandler; // true 发送 - final bool isSendStatus; - final TextEditingController textEditController; - final String? imagePath; - final Function(String?)? imgCallback; - final Function(String path)? onImageSend; // 选图即发:选完直接发送,不走底部预览 - final String? placeholder; - const InputFootView({ - super.key, - required this.begainCommentHandler, - this.isSendStatus = false, - required this.textEditController, - this.imagePath, - this.imgCallback, - this.onImageSend, - this.placeholder, - }); - - @override - State createState() => _InputFootViewState(); -} - -class _InputFootViewState extends State { - bool isImageSelectIng = false; - - void choseImg() async { - isImageSelectIng = true; - final listMedia = await ImagePickers.pickerPaths( - uiConfig: UIConfig(uiThemeColor: Colors.white), - galleryMode: GalleryMode.image, - selectCount: 1, - showCamera: false, - ); - if (listMedia.isNotEmpty) { - final ret = listMedia.map((e) => e.path ?? '').toList(); - String selectedImgPath = ret.first; - if (selectedImgPath.isNotEmpty) { - // 选完图直接发送,不在底部预览 - widget.onImageSend?.call(selectedImgPath); - } - } - setState(() {}); - Future.delayed(Duration(milliseconds: 500), () { - isImageSelectIng = false; - }); - } - - @override - Widget build(BuildContext context) { - return SafeArea( - bottom: true, - child: Container( - color: const Color(0xff000000), - child: Container( - height: 58, - width: double.infinity, - margin: EdgeInsets.symmetric(vertical: 12.h, horizontal: 18.w), - child: Row( - children: [ - Expanded( - child: GestureDetector( - onTap: () => widget.begainCommentHandler(false), - child: GroupTextFiled( - controller: widget.textEditController, - height: 32, - enabled: false, - padding: const EdgeInsets.symmetric(horizontal: 20), - textStyle: TextStyle(color: Colors.white, fontSize: 14.sp), - placeholder: widget.placeholder ?? '请输入消息', - placeholderTextStyle: - const TextStyle(color: Color(0xFF525252), fontSize: 12, fontWeight: FontWeight.w400), - decoration: BoxDecoration( - color: const Color(0x1AFFFFFF), - borderRadius: BorderRadius.circular(90), - ), - ), - ), - ), - Container( - margin: const EdgeInsets.only(left: 12), - child: Stack( - children: [ - SizedBox( - height: 24, - width: 24, - child: GestureDetector( - onTap: choseImg, - child: widget.imagePath?.isNotEmpty == true - ? ClipRRect( - borderRadius: BorderRadius.circular(4), - child: Image.file( - File(widget.imagePath!), - width: 24, - height: 24, - fit: BoxFit.cover, - ), - ) - : Image.asset("icon_pic.webp".commentPath), - ), - ), - if (widget.imagePath?.isNotEmpty == true) - Positioned( - top: 0, - right: 0, - child: GestureDetector( - onTap: () { - widget.imgCallback?.call(null); - setState(() {}); - }, - child: Image.asset( - "close_grey.png".commonImgPath, - width: 12, - height: 12, - ), - ), - ), - ], - ), - ), - 10.sizeBoxW, - _buildSendButton(), - ], - ), - ), - ), - ); - } - - Widget _buildSendButton() { - return GestureDetector( - onTap: () { - widget.begainCommentHandler(true); - }, - child: SizedBox( - width: 28, - height: 28, - child: widget.isSendStatus - ? const CupertinoActivityIndicator( - color: Colors.redAccent, - radius: 12, - ) - : Image.asset('ic_send.webp'.commentPath, width: 28, height: 28), - ), - ); - } -} diff --git a/lib/im_manager/chat_detail_page/widget/input_sheet_view.dart b/lib/im_manager/chat_detail_page/widget/input_sheet_view.dart deleted file mode 100644 index d2820f1..0000000 --- a/lib/im_manager/chat_detail_page/widget/input_sheet_view.dart +++ /dev/null @@ -1,166 +0,0 @@ -import 'dart:io'; - -import 'package:flutter/material.dart'; -import 'package:flutter_screenutil/flutter_screenutil.dart'; -import 'package:get/get.dart'; -import 'package:jydj/assets_tool/images.dart'; -import 'package:jydj/tools_base/widget/group_text_filed.dart'; -import 'package:jydj/hj_utils/screen.dart'; -import 'package:image_pickers/image_pickers.dart'; - -class InputSheetView extends StatefulWidget { - final String? hint; - final TextEditingController textEditController; - final String? selectedImgPath; - final Function(String? imgPath)? imgCallback; - - const InputSheetView({ - super.key, - this.hint, - required this.textEditController, - this.selectedImgPath, - this.imgCallback, - }); - - @override - State createState() => _InputSheetViewState(); -} - -class _InputSheetViewState extends State with WidgetsBindingObserver { - final focus = FocusNode(); - - TextEditingController get textEditController => widget.textEditController; - bool hasShowKeyboard = false; - bool isImageSelectIng = false; - String? selectedImgPath; - - @override - void initState() { - super.initState(); - selectedImgPath = widget.selectedImgPath; - textEditController.addListener(() { - if (mounted) setState(() {}); - }); - WidgetsBinding.instance.addPostFrameCallback((timeStamp) { - focus.requestFocus(); - }); - WidgetsBinding.instance.addObserver(this); - } - - void choseImg() async { - isImageSelectIng = true; - final listMedia = await ImagePickers.pickerPaths( - uiConfig: UIConfig(uiThemeColor: Colors.white), - galleryMode: GalleryMode.image, - selectCount: 1, - showCamera: false, - ); - if (listMedia.isNotEmpty) { - final ret = listMedia.map((e) => e.path ?? '').toList(); - selectedImgPath = ret.first; - widget.imgCallback?.call(selectedImgPath!); - } - setState(() {}); - Future.delayed(Duration(milliseconds: 500), () { - isImageSelectIng = false; - }); - } - - @override - Widget build(BuildContext context) { - // 固定输入栏高度,避免 bottomSheet 占满屏幕产生蒙层 - return Material( - color: const Color(0xff000000), - child: Container( - height: 58, - width: double.infinity, - margin: EdgeInsets.symmetric(vertical: 12.h, horizontal: 18.w), - child: Row( - children: [ - Expanded( - child: GestureDetector( - onTap: () { - focus.requestFocus(); - }, - child: GroupTextFiled( - focusNode: focus, - controller: widget.textEditController, - height: 32, - padding: const EdgeInsets.symmetric(horizontal: 20), - textStyle: TextStyle(color: Colors.white, fontSize: 14.sp), - placeholder: widget.hint ?? '请输入消息', - placeholderTextStyle: - const TextStyle(color: Color(0xFF525252), fontSize: 12, fontWeight: FontWeight.w400), - decoration: BoxDecoration( - color: const Color(0x1AFFFFFF), - borderRadius: BorderRadius.circular(90), - ), - ), - ), - ), - Container( - margin: const EdgeInsets.only(left: 12), - child: Stack( - children: [ - SizedBox( - height: 24, - width: 24, - child: GestureDetector( - onTap: choseImg, - child: selectedImgPath?.isNotEmpty == true - ? ClipRRect( - borderRadius: BorderRadius.circular(4), - child: Image.file( - File(selectedImgPath!), - width: 24, - height: 24, - fit: BoxFit.cover, - ), - ) - : Image.asset("icon_pic.webp".commentPath), - ), - ), - if (selectedImgPath?.isNotEmpty == true) - Positioned( - top: 0, - right: 0, - child: GestureDetector( - onTap: () { - widget.imgCallback?.call(null); - setState(() {}); - }, - child: Image.asset( - "close_grey.png".commonImgPath, - width: 12, - height: 12, - ), - ), - ), - ], - ), - ), - 10.sizeBoxW, - _buildSendButton(), - ], - ), - ), - ); - } - - Widget _buildSendButton() { - return GestureDetector( - onTap: () { - focus.unfocus(); - Get.back(result: true); - }, - child: Image.asset('ic_send.webp'.commentPath, width: 28, height: 28), - ); - } - - @override - void dispose() { - focus.unfocus(); - WidgetsBinding.instance.removeObserver(this); - super.dispose(); - } -} diff --git a/lib/im_manager/im_ad/im_ad_item.dart b/lib/im_manager/im_ad/im_ad_item.dart deleted file mode 100644 index 581e820..0000000 --- a/lib/im_manager/im_ad/im_ad_item.dart +++ /dev/null @@ -1,111 +0,0 @@ -import '../../hj_model/splash/ads_model.dart'; - -/// `POST /im/ad` 返回 list 元素:跟 `AdBean` 字段近似,但 `position` 是 -/// 字符串 key(IM_LIST_BANNER / IM_LIST_FEED_NATIVE / IM_CHAT_NOTIFICATION_BAR -/// / IM_CHAT_FLOATING_GIF_BALL)。 -/// -/// 不复用 `AdsInfoModel.position`(int) 是因为它跟其它广告接口耦合, -/// 强行改类型会污染非 IM 场景,故用独立 `imPosition`(String)。 -class ImAdItem { - String? id; - String? title; - String? cover; - String? href; - String? position; - String? positionName; - int? sortCode; - - ImAdItem({ - this.id, - this.title, - this.cover, - this.href, - this.position, - this.positionName, - this.sortCode, - }); - - AdsInfoModel get covertAd { - AdsInfoModel infoModel = AdsInfoModel(); - infoModel.id = id; - infoModel.title = title; - infoModel.cover = cover; - infoModel.href = href; - infoModel.imPosition = position; - infoModel.positionName = positionName; - infoModel.sortCode = sortCode; - return infoModel; - } - - ImAdItem.fromJson(Map json) { - id = json['id']?.toString(); - title = json['title']?.toString(); - cover = json['cover']?.toString(); - href = json['href']?.toString(); - position = json['position']?.toString(); - positionName = json['positionName']?.toString(); - final sc = json['sortCode']; - if (sc is int) { - sortCode = sc; - } else if (sc != null) { - sortCode = int.tryParse(sc.toString()); - } - } - - Map toJson() => { - 'id': id, - 'title': title, - 'cover': cover, - 'href': href, - 'position': position, - 'positionName': positionName, - 'sortCode': sortCode, - }; -} - -/// `POST /im/ad` 返回的广告分组(按 position 维度聚合) -/// 后端把 position 从 int 改成了 String key(IM_LIST_BANNER 等) -class ImAdGroup { - String? position; - String? positionName; - List? list; - - ImAdGroup({this.position, this.positionName, this.list}); - - ImAdGroup.fromJson(Map json) { - position = json['position']?.toString(); - positionName = json['positionName']?.toString(); - if (json['list'] is List) { - list = (json['list'] as List) - .whereType() - .map((e) => ImAdItem.fromJson(Map.from(e))) - .toList(); - } - } - - Map toJson() => { - 'position': position, - 'positionName': positionName, - 'list': list?.map((e) => e.toJson()).toList(), - }; -} - -/// `POST /im/ad` 顶层 data 结构:`{ groups: [...] }` -class ImAdResp { - List? groups; - - ImAdResp({this.groups}); - - ImAdResp.fromJson(Map json) { - if (json['groups'] is List) { - groups = (json['groups'] as List) - .whereType() - .map((e) => ImAdGroup.fromJson(Map.from(e))) - .toList(); - } - } - - Map toJson() => { - 'groups': groups?.map((e) => e.toJson()).toList(), - }; -} diff --git a/lib/im_manager/im_ad/im_ad_manager.dart b/lib/im_manager/im_ad/im_ad_manager.dart deleted file mode 100644 index a66db42..0000000 --- a/lib/im_manager/im_ad/im_ad_manager.dart +++ /dev/null @@ -1,155 +0,0 @@ -import 'dart:convert'; - -import 'package:flutter_mm_core/im_core/model/model_message.dart'; -import 'package:jydj/tools_base/debug_log.dart'; - -import '../api/im_api_service.dart'; -import '../im_notification.dart'; -import '../model/im_message_resp.dart'; -import 'im_ad_item.dart'; -import 'im_ad_provider.dart'; - -/// 全局 IM 广告状态:position(String) → 当前最新 ImAdItem -/// - 启动时主动 Pull 4 个 IM_xxx 一次性灌入;后端透传 AD_NOTIFY 再增量刷 -/// - 各页面(消息列表 / 聊天页)通过 Consumer 订阅指定 position -/// - 关闭逻辑由 widget 本地状态控制(对齐 H5:离开重进会重新展示) - -class ImAdManager { - /// 后端约定的 4 个 IM 广告位标识 - static const String kPosListBanner = 'IM_LIST_BANNER'; //会话列表顶部广告 - static const String kPosListFeedNative = 'IM_LIST_FEED_NATIVE'; //会话列表拆入广告 - static const String kPosChatNotificationBar = 'IM_CHAT_NOTIFICATION_BAR'; //聊天页面顶部广告 - static const String kPosChatFloatingGifBall = 'IM_CHAT_FLOATING_GIF_BALL'; //聊天页面悬浮广告 - - /// 启动 Pull / 主动刷新时传给后端的全集 - static const List kAllPositions = [ - kPosListBanner, - kPosListFeedNative, - kPosChatNotificationBar, - kPosChatFloatingGifBall, - ]; - - static const String _typeAdNotify = 'AD_NOTIFY'; - - /// traceId 去重 —— 后端可能重复推同一条通知; - /// 用最近 200 条 LRU 大小避免无限增长 - static final Set _seenTraceIds = {}; - static const int _maxTraceIds = 200; - - static void onRecv(ModelMessage m) async { - if (!m.isPassthrough) return; - final extraRaw = m.extra; - if (extraRaw == null || extraRaw.trim().isEmpty) return; - - Map? payload; - try { - final decoded = jsonDecode(extraRaw); - if (decoded is Map) { - payload = Map.from(decoded); - } - } catch (e) { - debugLog('Im-ad_extra parse failed: $e raw=$extraRaw'); - return; - } - if (payload == null) return; - - final type = payload['type']?.toString() ?? ''; - final traceId = payload['traceId']?.toString() ?? ''; - if (traceId.isNotEmpty) { - if (_seenTraceIds.contains(traceId)) return; - _seenTraceIds.add(traceId); - if (_seenTraceIds.length > _maxTraceIds) { - _seenTraceIds.remove(_seenTraceIds.first); - } - } - - if (type == _typeAdNotify) { - try { - final positionsRaw = payload['positions']; - final List positions = []; - if (positionsRaw is List) { - for (final p in positionsRaw) { - final s = p?.toString().trim() ?? ''; - if (s.isNotEmpty) positions.add(s); - } - } - if (positions.isEmpty) return; - // 收到 IM 刷新广告数据,清理已读广告数据 - await ImAdProvider().cleanReadData(); - //根据位置刷新对应位置的广告数据 - await pullImAdsOnce(pos: positions); - ImMsgProvider().unReadStatusUpdateByAds(); - } catch (e) { - debugLog(e); - } - } - } - - /// 启动/重新登录后主动 Pull 一次全部 IM 广告位(4 个),灌进 imAdModel - /// 后续后端透传 AD_NOTIFY 时再按 extra.positions 增量刷 - static Future pullImAdsOnce({List pos = kAllPositions}) async { - try { - ImAdResp resp = await IMApiService.getImAdList(pos); - ImAdProvider().applyResp(resp); - debugLog('IM-pullImAdsOnce ok groups=${resp.groups?.length ?? 0}'); - } catch (e) { - debugLog('IM-pullImAdsOnce failed:$e'); - } - } - - //数据列表插入ad - static void insertAd( - List? modelArr, - List advList, { - int adGap = 3, // 每隔 adGap 个会话插入广告 - }) { - if (advList.isEmpty || modelArr == null) { - return; - } - if (modelArr.isEmpty) { - IMSessionModel adModel = IMSessionModel(); - adModel.adItem = advList.first; - modelArr.add(adModel); - return; - } - int countGap = 0; - int adIndex = 0; - modelArr.removeWhere((element) => element.isAdStyle); - // 列表首位插入广告 - IMSessionModel firstAdModel = IMSessionModel(); - firstAdModel.adItem = advList[adIndex % advList.length]; - adIndex++; - modelArr.insert(0, firstAdModel); - for (int i = 0; i < modelArr.length; i++) { - IMSessionModel model = modelArr[i]; - if (countGap >= adGap) { - countGap = 0; - if (model.isAdStyle) { - adIndex++; - } else { - if (adIndex < advList.length) { - IMSessionModel adModel = IMSessionModel(); - adModel.adItem = advList[adIndex % advList.length]; - adIndex++; - modelArr.insert(i, adModel); - } - } - } else { - if (model.isAdStyle) { - countGap = 0; - } else { - countGap++; - } - } - if (countGap == adGap && i == modelArr.length - 1) { - if (adIndex < advList.length) { - IMSessionModel adModel = IMSessionModel(); - adModel.adItem = advList[adIndex % advList.length]; - adIndex++; - modelArr.add(adModel); - } - break; - } - } - } -} diff --git a/lib/im_manager/im_ad/im_ad_provider.dart b/lib/im_manager/im_ad/im_ad_provider.dart deleted file mode 100644 index f479098..0000000 --- a/lib/im_manager/im_ad/im_ad_provider.dart +++ /dev/null @@ -1,101 +0,0 @@ -import 'package:flutter/foundation.dart'; -import 'package:jydj/hj_utils/light_model.dart'; - -import 'im_ad_item.dart'; - -/// 全局 IM 广告状态:position(String) → 当前最新 ImAdItem -/// - 启动时主动 Pull 4 个 IM_xxx 一次性灌入;后端透传 AD_NOTIFY 再增量刷 -/// - 各页面(消息列表 / 聊天页)通过 Consumer 订阅指定 position -/// - 关闭逻辑由 widget 本地状态控制(对齐 H5:离开重进会重新展示) - -class ImAdProvider with ChangeNotifier { - // 单例 - static final ImAdProvider _ins = ImAdProvider._(); - - factory ImAdProvider() => _ins; - final String _adIdsLocalKey = "_adIdsLocalKey"; - - ImAdProvider._(); - - final List _groups = []; - - ImAdGroup? adByPosition(String position) { - for (var model in _groups) { - if (model.position == position && model.list?.isNotEmpty == true) { - return model; - } - } - return null; - } - - /// 取该位号第一条 - ImAdItem? getFirstAd(String position) => getAdList(position).isEmpty ? null : getAdList(position).first; - - /// 取该位号全集(banner 轮播用) - List getAdList(String position) => adByPosition(position)?.list ?? const []; - - bool hasAd(String position) => getAdList(position).isNotEmpty; - - /// 每次 applyResp(含一次有效推送)+1;widget 用它区分"这是哪一波推送", - /// 关闭后下一波又能弹出来。 - int _version = 0; - - int get version => _version; - - /// handler 拿到 /im/ad 返回后调一次。 - void applyResp(ImAdResp data) { - if (data.groups?.isNotEmpty != true) return; - - for (ImAdGroup newGroup in data.groups!) { - ImAdGroup? existAdGroup; - for (ImAdGroup model in _groups) { - if (model.position == newGroup.position) { - existAdGroup = model; - } - } - if (newGroup.list?.isNotEmpty == true) { - // 按 sortCode 升序;空 sortCode 排最后 - newGroup.list!.sort((a, b) => (a.sortCode ?? 1 << 30).compareTo(b.sortCode ?? 1 << 30)); - } - if (existAdGroup != null) { - if (newGroup.list?.isNotEmpty == true) { - existAdGroup.list = newGroup.list; - } - } else { - _groups.add(newGroup); - } - } - _version++; - notifyListeners(); - } - - Future loadLocalAdIds() async { - _clickedAdIds = await lightKV.getStringList(_adIdsLocalKey) ?? []; - } - - Future _saveLocalAdIds() async { - await lightKV.setStringList(_adIdsLocalKey, _clickedAdIds); - } - - // 未点击的广告显示红点 - List _clickedAdIds = []; - - void notifyClickAd(String? adId) { - if (adId == null) return; - if (!_clickedAdIds.contains(adId)) { - _clickedAdIds.add(adId); - _saveLocalAdIds(); - } - notifyListeners(); - } - - bool isUnreadAd(String? adId) { - if (adId == null) return false; - return !_clickedAdIds.contains(adId); - } - - Future cleanReadData() async { - _clickedAdIds.clear(); - await _saveLocalAdIds(); - } -} diff --git a/lib/im_manager/im_manager.dart b/lib/im_manager/im_manager.dart deleted file mode 100644 index 5af2c81..0000000 --- a/lib/im_manager/im_manager.dart +++ /dev/null @@ -1,278 +0,0 @@ -import 'dart:async'; - -import 'package:flutter_mm_core/im_core.dart'; -import 'package:get/get.dart'; -import 'package:jydj/tools_base/debug_log.dart'; -import 'package:jydj/tools_base/global_store/store.dart'; -import 'package:jydj/tools_base/loading/loading_alert_widget.dart'; -import 'package:jydj/tools_base/toast.dart'; - -import 'api/im_api_service.dart'; -import 'chat_detail_page/chat_detail_page.dart'; -import 'im_ad/im_ad_manager.dart'; -import 'im_notification.dart'; -import 'model/im_message_resp.dart'; -import 'model/im_user_model.dart'; - -class ImManager { - // 单例 - static final ImManager _ins = ImManager._(); - - factory ImManager() => _ins; - - String socketURL = ''; - String dynamicConfigDomain = ''; - - bool _isConnectSuccess = false; // true 连接成功 - bool _isConnectIng = false; //true: 正在连接中 - Future? _connectServerFuture; - Future? _refreshTokenFuture; - - ImManager._(); - - String get _imToken => globalStore.meInfo?.imToken ?? ""; - - int? get imUserId => globalStore.meInfo?.imUserId; - bool _initFinish = false; - - void initImSDK() { - if (socketURL.isEmpty || dynamicConfigDomain.isEmpty) { - debugLog("IM-initImSDK 参数配置有问题"); - return; - } - if (_initFinish) return; - _initFinish = true; - ImCore.initSDK( - appKey: '', - appSecret: '', - socketURL: socketURL, - dynamicConfigDomain: dynamicConfigDomain, - ); - - ImCore.configureLog( - showGeneralLog: true, - showHttpLog: true, - showWebSocketLog: true, - showHeartbeatLog: false, - showDatabaseLog: true, - showMediaLog: true, - ); - - debugLog('IM-SDK 初始化完成'); - _addChatListener(); - } - - // token失效或者断开连接,需要重新连接 - Future connectServer() { - if (_isConnectSuccess) return Future.value(true); - if (_refreshTokenFuture != null) return _refreshTokenFuture!; - if (_connectServerFuture != null) return _connectServerFuture!; - _connectServerFuture = _connectServer().whenComplete(() { - _connectServerFuture = null; - }); - return _connectServerFuture!; - } - - Future _connectServer() async { - try { - if (_refreshTokenFuture != null) return _refreshTokenFuture!; - if (_isConnectSuccess) return true; - if (_isConnectIng) return _isConnectSuccess; - _isConnectIng = true; - _isConnectSuccess = false; - if (imUserId == null) { - debugLog('imUserId 为空'); - ImUserModel? model = await IMApiService.getImUserInfo(uid: globalStore.meInfo?.uid); - globalStore.meInfo?.imUserId = int.tryParse(model?.imUserId ?? ""); - } - if (_imToken.isEmpty) { - debugLog('IM- IM-token 获取失败'); - } else { - bool ok = await _loginWithToken(_imToken); - if (ok) { - await ImAdManager.pullImAdsOnce(); - _onIMConversation(); - } - } - } catch (e) { - debugLog('IM-登录失败:$e'); - } finally { - _isConnectIng = false; - } - return _isConnectSuccess; - } - - Future _loginWithToken(String token) async { - final ok = await ImCore.linkImRequest(token: token).timeout(const Duration(seconds: 15)); - if (!ok) { - debugLog('IM-登录失败'); - return false; - } - await ImCore.whenReady.timeout(const Duration(seconds: 15)); - _isConnectSuccess = true; - debugLog('IM-登录成功'); - return true; - } - - Future _refreshTokenAndReconnect() { - if (_refreshTokenFuture != null) return _refreshTokenFuture!; - _refreshTokenFuture = _refreshTokenAndReconnectInternal().whenComplete(() { - _refreshTokenFuture = null; - }); - return _refreshTokenFuture!; - } - - Future _refreshTokenAndReconnectInternal() async { - try { - _isConnectSuccess = false; - debugLog('IM-token 已失效,开始重新获取'); - ImTokenInfo? model = await IMApiService.getImToken(); - final token = model?.imToken ?? ''; - if (token.isEmpty) { - debugLog('IM-token 重新获取失败'); - return false; - } - - globalStore.meInfo?.imToken = token; - if (model?.imUserId != null) { - globalStore.meInfo?.imUserId = model?.imUserId; - } - - final ok = await _loginWithToken(token); - debugLog(ok ? 'IM-token 刷新并重连成功' : 'IM-token 刷新后重连失败'); - return ok; - } catch (e) { - debugLog('IM-token 刷新并重连失败:$e'); - return false; - } - } - - void signOut() async { - try { - _isConnectSuccess = false; - await ImCore.loginOutRequest(); - debugLog('IM-退出成功'); - } catch (e) { - debugLog('IM-退出失败:$e'); - } - } - - /// 账号切换/重新登录:先登出旧账号 IM(重置当前用户运行态与本地会话), - /// 再用新账号(globalStore.meInfo 已更新)重新连接,避免会话列表残留旧数据。 - Future reloginOnAccountSwitch() async { - try { - _isConnectSuccess = false; - _isConnectIng = false; - _connectServerFuture = null; - _refreshTokenFuture = null; - await ImCore.loginOutRequest(); - debugLog('IM-切换账号已登出旧会话'); - } catch (e) { - debugLog('IM-切换账号登出失败:$e'); - } - await connectServer(); - } - - void _addChatListener() { - removeAllListener(); - ImCore.addRecvNewMessageListener(_onMessageReceived); - ImCore.addSendFailedListener(_onSendFailed); - ImCore.addMessageAckFailedListener(_onMessageAckFailed); - ImCore.addConversationChangedListener(_onIMConversation); - ImCore.addTokenExpiredListener(_onTokenExpired); - ImCore.onTokenExpired = _onTokenExpired; - } - - void _onIMConversation() async { - ModelConversationListResult sessionRet = await ImCore.getConversations(includeArchivedCount: false); - ImMsgProvider().setUnReadStatus(sessionRet); - } - - void _onTokenExpired() { - unawaited(_refreshTokenAndReconnect()); - } - - void _onMessageReceived(ModelMessage message) { - debugLog('IM-消息收到:${message.content},来自:${message.senderID}'); - if (message.isPassthrough) { - // 全局透传消息(chatMessageType=400)监听:用于接收后端 AD_NOTIFY 等业务信号。 - // 透传不入库、不计未读、不进会话;只在线推。 - // 解析 / 去重 / 拉广告逻辑统一在 ImAdManager 里。 - ImAdManager.onRecv(message); - } else { - imEventBus.emit(IMMsgEvent(message, true)); - } - } - - void _onSendFailed(ImCoreException exception, ModelMessage? message) { - imEventBus.emit(IMMsgEvent(message, false)); - debugLog('IM-消息发送失败:${exception.message}'); - if (exception.message.contains("当前 IM 未连接")) { - _isConnectSuccess = false; - connectServer(); - } - } - - void _onMessageAckFailed(ImCoreException exception, ModelMessage? message) { - debugLog('IM-消息 ACK 失败:${exception.message}'); - _isConnectSuccess = false; - connectServer(); - } - - Future sendMessage(ModelCustomMessage message) async { - try { - final ok = await ImCore.sendMessage(msg: message); - if (ok) { - debugLog('已请求发送消息'); - return true; - } else { - debugLog('发送消息失败'); - return false; - } - } catch (e) { - debugLog('发送消息失败:$e'); - return false; - } - } - - void removeAllListener() { - ImCore.removeRecvNewMessageListener(_onMessageReceived); - ImCore.removeSendFailedListener(_onSendFailed); - ImCore.removeMessageAckFailedListener(_onMessageAckFailed); - ImCore.removeConversationChangedListener(_onIMConversation); - ImCore.removeTokenExpiredListener(_onTokenExpired); - } -} - -Future jumpToChatDetail(IMSessionModel model) async { - // 优先用 Session.imUserId(SDK conversation.targetId,已经是 IM userId) - // 没有再回退到 ensureFriend 转换 - int peerImUserId = model.imUserId ?? 0; - if (peerImUserId <= 0) { - final peerId = model.peerId ?? 0; - if (peerId <= 0) return; - try { - LoadingAlertWidget.show(); - final result = await IMApiService.ensureFriend(peerId); - peerImUserId = int.tryParse(result.peerImUserId ?? '') ?? 0; - LoadingAlertWidget.cancel(); - } catch (_) { - LoadingAlertWidget.cancel(); - showToast('无法打开聊天,请稍后再试'); - return; - } - } - if (peerImUserId <= 0) { - showToast('无法打开聊天,请稍后再试'); - return; - } - return Get.to( - ChatDetailPage( - imUserId: peerImUserId, - userName: model.nickName, - uid: model.peerId, - peerAvatar: model.avatar, - ), - preventDuplicates: false, - ); -} diff --git a/lib/im_manager/im_notification.dart b/lib/im_manager/im_notification.dart deleted file mode 100644 index ea9acba..0000000 --- a/lib/im_manager/im_notification.dart +++ /dev/null @@ -1,99 +0,0 @@ -import 'dart:async'; - -import 'package:event_bus/event_bus.dart'; -import 'package:flutter/cupertino.dart'; -import 'package:flutter_mm_core/im_core/model/model_conversation.dart'; -import 'package:flutter_mm_core/im_core/model/model_message.dart'; - -import 'im_ad/im_ad_item.dart'; -import 'im_ad/im_ad_manager.dart'; -import 'im_ad/im_ad_provider.dart'; -import 'model/im_message_resp.dart'; - -typedef EventCallback = void Function(T event); - -class ImMsgProvider with ChangeNotifier { - static final ImMsgProvider _ins = ImMsgProvider._(); - - factory ImMsgProvider() => _ins; - - ImMsgProvider._(); - int _unReadCount = 0; - bool get hasUnreadCount => _unReadCount > 0; - - ModelConversationListResult? _curConversation; - - void setUnReadStatus(ModelConversationListResult resp) { - _curConversation = resp; - _unReadCount = resp.totalUnreadCount; - int adUnReadCount = 0; - int adMaxCount = resp.conversations.length ~/ 3 + 1; - List adList = ImAdProvider().getAdList(ImAdManager.kPosListFeedNative); - for (int i = 0; i < adMaxCount && i < adList.length; i++) { - ImAdItem adItem = adList[i]; - if (ImAdProvider().isUnreadAd(adItem.id)) { - adUnReadCount++; - } - } - _unReadCount += adUnReadCount; - notifyListeners(); - } - - //广告数据刷新,我的消息红点状态也要同步更新 - void unReadStatusUpdateByAds() { - setUnReadStatus(_curConversation ?? ModelConversationListResult()); - } - - void setUnReadStatusFromAd() { - setUnReadStatus(_curConversation ?? ModelConversationListResult()); - } -} - -class IMMsgEvent { - final ModelMessage? model; - final bool isSuccess; - - IMMessageModel? get msgModel { - if (model == null) return null; - IMMessageModel convtModel = IMMessageModel.convertModel(model!); - return convtModel; - } - - IMMsgEvent(this.model, this.isSuccess); -} - -class ImEventBus { - //保存单例 - static final ImEventBus _instance = ImEventBus._internal(); - - //工厂构造函数 - factory ImEventBus() => _instance; - - //初始化eventBus - late EventBus _eventBus; - - ImEventBus._internal() { - // 初始化 - _eventBus = EventBus(); - } - - /// 开启eventbus订阅 - StreamSubscription on(EventCallback callback) { - StreamSubscription stream = _eventBus.on().listen((event) { - callback(event); - }); - return stream; - } - - /// 发送消息 - void emit(event) { - _eventBus.fire(event); - } - - /// 移除stream - void off(StreamSubscription steam) { - steam.cancel(); - } -} - -var imEventBus = ImEventBus._instance; diff --git a/lib/im_manager/model/im_message_resp.dart b/lib/im_manager/model/im_message_resp.dart deleted file mode 100644 index 921ee2d..0000000 --- a/lib/im_manager/model/im_message_resp.dart +++ /dev/null @@ -1,203 +0,0 @@ -import 'package:flutter_mm_core/im_core/model/model_conversation.dart'; -import 'package:flutter_mm_core/im_core/model/model_message.dart'; -import 'package:flutter_mm_core/im_core/model/model_user_info.dart'; -import 'package:jydj/tools_base/global_store/store.dart'; -import '../im_ad/im_ad_item.dart'; - -/// 会话 -class IMSessionModel { - int? peerId; - String? avatar; - String? nickName; - String? lastText; - String? updatedAt; - int? messageType; - int seq = 0; - // 对方在线状态,来自 IM SDK 的 ModelConversation.onlineInfo.online - bool? online; - - // SDK 内部会话主键(${otherId}_${isGroup}),用于 setConversationRead 等 API - String? primaryId; - - // SDK 的 IM userId(区分业务 peerId);私信详情用它做精确路由 - int? imUserId; - int? unreadCount; // 未读数 - ImAdItem? adItem; - bool get isAdStyle => adItem != null; - bool get hasUnRead => (unreadCount ?? 0) > 0; - String? get updatedYMD { - if (updatedAt?.split(" ").isNotEmpty == true) { - return updatedAt?.split(" ").first; - } - if (updatedAt?.split("T").isNotEmpty == true) { - return updatedAt?.split("T").first; - } - return updatedAt; - } - - ModelConversation? originModel; - IMSessionModel({this.peerId, this.nickName, this.avatar}); - - static List covertData(ModelConversationListResult conList) { - List sessionList = []; - for (int i = 0; i < (conList.conversations.length); i++) { - ModelConversation conversation = conList.conversations[i]; - ModelUserInfo? userInfo = conversation.userInfo; - IMSessionModel sessionInfo = IMSessionModel(); - sessionInfo.peerId = userInfo?.id ?? int.tryParse(conversation.targetId ?? ''); - sessionInfo.avatar = userInfo?.imgUrl; - sessionInfo.nickName = userInfo?.name; - sessionInfo.lastText = conversation.content; - sessionInfo.online = conversation.onlineInfo?.online; - sessionInfo.primaryId = conversation.primaryId; - sessionInfo.unreadCount = conversation.unreadCount; - sessionInfo.messageType = conversation.messageType; - sessionInfo.imUserId = int.tryParse(conversation.targetId ?? ''); - sessionInfo.seq = conversation.seq; - sessionInfo.originModel = conversation; - if (conversation.time > 0) { - var dateTimeStr = _conversationTimeToIsoString(conversation.time); - sessionInfo.updatedAt = dateTimeStr; - } - sessionList.add(sessionInfo); - } - return sessionList; - } - - static String _conversationTimeToIsoString(int rawTime) { - if (rawTime <= 0) return ''; - final milliseconds = rawTime >= 1000000000000 ? rawTime : rawTime * 1000; - return DateTime.fromMillisecondsSinceEpoch(milliseconds).toString(); - } -} - -class IMMessageResp { - bool? enabled; - int? userId; - int? imUserId; - int? peerId; - int? peerImUserId; - - List? messages; - - IMMessageResp(); - - IMMessageResp.fromJson(Map json) { - enabled = json['enabled']; - userId = json['userId']; - imUserId = json['imUserId']; - peerId = json['peerId']; - peerImUserId = json['peerImUserId']; - - if (json['messages'] is List) { - messages = []; - json['messages'].forEach((v) { - messages!.add(IMMessageModel.fromJson(v)); - }); - } - } -} - -class IMMessageModel { - String? messageId; - String? senderId; - String? receiverId; - String? content; - dynamic messageType; - int? messageState; // 发送状态 0发送中 1失败 2成功 (MessageSendStatus) - String? localId; // 客户端本地ID,发送中/失败状态更新用 - int? seq; - int? createdAt; - int? timestamp; - bool? fromSelf; - String? senderNickName; - String? senderAvatar; - String? receiverNickName; - String? receiverAvatar; - - /// 业务扩展字段,服务端透传原始字符串;内容不要求是 JSON。 - String? extra; - String? url; //文件地址 - String? localPath; //下载后的本地路径 - ModelMessage? originMsgModel; - IMMessageModel(); - - IMMessageModel.fromJson(Map json) { - receiverAvatar = json['receiverAvatar']; - receiverNickName = json['receiverNickName']; - senderAvatar = json['senderAvatar']; - senderNickName = json['senderNickName']; - messageId = json['messageId']; - senderId = json['senderId']?.toString(); - receiverId = json['receiverId']?.toString(); - content = json['content']; - messageType = json['messageType']; - seq = json['seq']; - createdAt = json['createdAt']; - fromSelf = json['fromSelf']; - } - - /// 图片消息有两种: - /// - chatMessageType=2 (SDK 原生 IMAGE):用 m.url / m.localPath(完整 CDN 链接) - /// - chatMessageType=100 (CUSTOMIZED):双层解析 m.extra 拿 url - static IMMessageModel convertModel(ModelMessage model) { - IMMessageModel msgModel = IMMessageModel(); - msgModel.originMsgModel = model; - msgModel.messageId = model.messageId; - msgModel.content = model.content; - msgModel.senderId = model.senderID; - msgModel.receiverId = model.receiverID; - msgModel.createdAt = model.timestamp; - msgModel.timestamp = model.timestamp; - msgModel.seq = model.sequence; - msgModel.messageType = model.chatMessageType; - msgModel.messageState = model.messageState; - msgModel.localId = model.localId; - if (model.senderID == globalStore.meInfo?.imUserId?.toString()) { - msgModel.fromSelf = true; - } - final type = model.chatMessageType ?? 0; - if (type == 2) { - // SDK 原生 IMAGE - if ((model.url ?? '').isNotEmpty) { - msgModel.url = model.url; - } else if ((model.localPath ?? '').isNotEmpty) { - msgModel.localPath = model.localPath; - } - } - msgModel.extra = model.extra; - return msgModel; - } - - String? get image { - if (url?.isNotEmpty == true) { - return url; - } - if (localPath?.isNotEmpty == true) { - return localPath; - } - return null; - } -} - -class IMSendFileInfo { - double? height; - int? type; // 1 图片, 2文件 - String? url; - - IMSendFileInfo({this.height, this.type, this.url}); - - IMSendFileInfo.fromJson(Map json) { - height = json['height']; - type = json['type']; - url = json['url'] ?? ''; - } - - Map toJson() { - final Map data = {}; - data['height'] = height; - data['type'] = type; - data['url'] = url; - return data; - } -} diff --git a/lib/im_manager/model/im_user_model.dart b/lib/im_manager/model/im_user_model.dart deleted file mode 100644 index d039c75..0000000 --- a/lib/im_manager/model/im_user_model.dart +++ /dev/null @@ -1,74 +0,0 @@ -class ImUserModel { - bool? enabled; - int? userId; - String? imUserId; - String? imToken; - int? peerId; // 好友 id - String? peerImUserId; // 好友 IM id - bool? friendAdded; // true:添加好友成功 - ImUserModel({this.enabled, this.userId, this.imUserId, this.imToken}); - - ImUserModel.fromJson(Map json) { - enabled = json['enabled']; - userId = json['userId']; - imUserId = json['imUserId']?.toString(); - imToken = json['imToken']?.toString() ?? json['token']?.toString(); - peerId = json['peerId']; - peerImUserId = json['peerImUserId']?.toString(); - friendAdded = json['friendAdded']; - } - - Map toJson() { - final Map data = {}; - data['imUserId'] = imUserId; - data['imToken'] = imToken; - data['userId'] = userId; - data['enabled'] = enabled; - return data; - } -} - -class ImTokenInfo { - bool? enabled; - String? imToken; - String? token; - int? userId; - int? imUserId; - bool? registered; - String? dynamicConfigDomain; - String? socketURL; - String? sysType; - String? os; - String? osType; - - ImTokenInfo.fromJson(Map json) { - enabled = json['enabled']; - imToken = json['imToken']; - userId = json['userId']; - imUserId = json['imUserId']; - registered = json['registered']; - dynamicConfigDomain = json['dynamicConfigDomain']; - socketURL = json['socketURL']; - sysType = json['sysType']; - os = json['os']; - osType = json['osType']; - } -} - -class ImUserWallet { - int? price; - int? deductAmount; - int? deductIncome; - int? amount; - int? income; - int? balance; - - ImUserWallet.fromJson(Map json) { - price = json['price']; - deductAmount = json['deductAmount']; - deductIncome = json['deductIncome']; - amount = json['amount']; - income = json['income']; - balance = json['balance']; - } -} diff --git a/lib/track_event_manager/track_manager.dart b/lib/track_event_manager/track_manager.dart deleted file mode 100644 index ed4e5a9..0000000 --- a/lib/track_event_manager/track_manager.dart +++ /dev/null @@ -1,445 +0,0 @@ -import 'package:analytics_sdk/analytics_sdk.dart'; -import 'package:analytics_sdk/manager/page_name_manager.dart'; -import 'package:analytics_sdk/entity/ad_click_event.dart'; -import 'package:analytics_sdk/entity/ad_impression_event.dart'; -import 'package:analytics_sdk/entity/comic_event.dart'; -import 'package:analytics_sdk/entity/keyword_click_event.dart'; -import 'package:analytics_sdk/entity/keyword_search_event.dart'; -import 'package:analytics_sdk/entity/novel_event.dart'; -import 'package:analytics_sdk/entity/video_event.dart'; -import 'package:analytics_sdk/enum/click_item_type_enum.dart'; -import 'package:analytics_sdk/enum/read_behavior_enum.dart'; -import 'package:analytics_sdk/enum/video_content_type_enum.dart'; -import 'package:analytics_sdk/enum/video_event_enum.dart'; -import 'package:get/get.dart'; -import 'package:jydj/tools_base/debug_log.dart'; -import 'package:video_player/video_player.dart'; - -import '../hj_model/cartoon_media_info.dart'; -import '../hj_model/media_content.dart'; -import '../hj_model/splash/ads_model.dart'; -import '../hj_model/video_model.dart'; - -//页面枚举 -enum TrackEventPageType { - start('start', '启动页'), // 启动页广告 - home('home', '首页'), // 首页公告弹窗 - acgHome('acg_home', 'acg首页'), // 首页acg - live('live', '直播列表'), // 直播顶部广告 - liveDetail('live_detail', '直播详情页'), // 直播详情页广告 - commPost('comm_post', '社区首页'), // 顶部公共广告 - commImg('comm_img', '社区图集'), // 顶部公共广告 - commNovel('comm_novel', '社区小说'), // 顶部公共广告 - commGroup('comm_group', '社区群聊'), // 顶部公共广告 - darkweb('darkweb', '暗网'), // 直播顶部广告 - mine('mine', '我的'), // 我的页面广告 - search('search', '搜索页'), // 搜索页广告 - videoPlay('video_play', '视频播放页'), // 视频播放页广告 - topicDetail('topic_detail', '专题详情页'), // 专题详情顶部广告 - appList('app_list', '应用推荐'), // 应用等 - postDetail('post_detail', '社区详情页'), // 应用等 - imChatCenter('im_chat_center', '私信中心'), // IM 私信会话列表 - imChatDetail('im_chat_detail', '私信详情'); // IM 私信聊天页 - - final String pagekey; - final String pageName; - - const TrackEventPageType(this.pagekey, this.pageName); -} - -//搜索枚举 -enum TrackEventSearchType { - video('video', 'video'), // 视频 - comic('comic', '漫画'), // 漫画 - acg('acg', 'acg'), // acg - user('user', '用户'), // 用户 - image('image', 'image'), // image - post('post', '帖子'); // 帖子 - - final String clickTypeKey; - final String clickTypeName; - - const TrackEventSearchType(this.clickTypeKey, this.clickTypeName); -} - -/// 广告埋点类型(对应 payload 的 ad_type 字段) -enum AdType { - smallIcon('small_icon'), - verticalIcon('vertical_icon'), - moreIcon('more_icon'), - bigBanner('big_banner'), - smallBanner('small_banner'), - banner('banner'), - feed('feed'), - splash('splash'), - dialog('dialog'); - - /// 上报字符串值 - final String value; - - const AdType(this.value); - - /// 字符串反查枚举,找不到返回 null - static AdType? fromValue(String? value) => values.firstWhereOrNull((e) => e.value == value); -} - -class AdExposureModel { - final TrackEventPageType? pageType; //页面位置信息 - AdType? adType; //广告类型,banner 类组件会就地改写(如 ..adType = AdType.bigBanner) - final int? tabIndex; //tabIndex - - AdExposureModel({ - this.pageType, - this.adType, - this.tabIndex, - }); -} - -/// 广告曝光统一管理(业务级) -class AdExposureManager { - static final Set _exposedKeys = {}; - - /// 返回 true = 允许曝光 - static bool tryExpose(String key) => _exposedKeys.add(key); -} - -//广告曝光,只记录一次曝光 -mixin AdExposureMixin { - void adExposure({ - required AdsInfoModel adInfo, - AdExposureModel? exposureModel, - }) { - final pageKey = exposureModel?.pageType?.pagekey ?? ''; - final slotKey = adInfo.position?.toString() ?? ''; - final adId = adInfo.id?.toString() ?? ''; - - // 业务唯一曝光 Key,最终裁判在这里 - final exposureKey = '$pageKey|$slotKey|$adId|tab${exposureModel?.tabIndex ?? 0}'; - if (!AdExposureManager.tryExpose(exposureKey)) return; - - AnalyticsSdk.instance.track(AdImpressionEvent( - pageKey: pageKey, - pageName: exposureModel?.pageType?.pageName ?? '', - adSlotKey: slotKey, - adId: adId, - creativeId: adId, - adType: exposureModel?.adType?.value ?? '', // 传广告类型字符串,不传默认空字符串 - )); - } -} - -final adClickManager = AdClickManager(); - -//广告点击 -class AdClickManager { - // 单例 - static final AdClickManager _ins = AdClickManager._(); - - factory AdClickManager() => _ins; - - AdClickManager._(); - - //广告点击事件 - void adClicks({ - required AdsInfoModel? adInfo, - required Map payload, - TrackEventPageType? page, - }) { - if (adInfo == null) return; - try { - final adId = adInfo.id?.toString() ?? ''; - AnalyticsSdk.instance.track(AdClickEvent( - pageKey: page?.pagekey ?? '', - pageName: page?.pageName ?? '', - adSlotKey: adInfo.position?.toString() ?? '', - adId: adId, - creativeId: adId, - adType: payload['ad_type'] ?? '', - )); - } catch (e) { - debugLog('adClicks', e.toString()); - } - } -} - -final searchClickManager = SearchClickManager(); - -class SearchClickManager { - // 单例 - static final SearchClickManager _ins = SearchClickManager._(); - - factory SearchClickManager() => _ins; - - SearchClickManager._(); - - //搜索埋点 - void searchKeyword({ - required String? keyword, //搜索关键词 - required int? count, //搜索结果数量 - required String? traceId, //搜索引擎的trace_id。没有对接搜索推荐引擎的上报空字符串。 - required String? searchId, //搜索引擎列表的video_id(视频),novel_id(小说),comic_id(漫画), 可视窗口有多个,使用英文逗号分隔。 - }) { - AnalyticsSdk.instance.track(KeywordSearchEvent( - keyword: keyword ?? '', - searchResultCount: count ?? 0, - searchTraceId: traceId ?? '', // 未接搜索引擎时传空字符串 - searchId: searchId ?? '', // 未接搜索引擎时传空字符串 - )); - } - - //搜索点击事件 - void searchClicks({ - required String? keyword, - required TrackEventSearchType clickType, - required String? id, - required int? index, - required String? traceId, //搜索引擎的trace_id。没有对接搜索推荐引擎的上报空字符串。 - }) { - AnalyticsSdk.instance.track(KeywordClickEvent( - keyword: keyword ?? "", - clickItemId: id ?? '', - clickItemType: ClickItemTypeEnum(clickType.clickTypeKey, clickType.clickTypeName), - clickPosition: (index ?? 0) + 1, - searchTraceId: traceId ?? '', // 未接搜索引擎时传空字符串 - )); - } -} - -class TrackVideoManager { - //上次上报播放完成的秒级时间戳,用于防重 - static int _lastComplete = 0; - - //埋点字段不收空值,统一兜默认值 - static String _or(String? value, String fallback) => (value?.isEmpty ?? true) ? fallback : value!; - - //视频事件 - static void send( - VideoEventEnum event, - VideoModel? model, - VideoPlayerController? ctr, { - bool isPreVideo = false, - }) { - if (isPreVideo) return; //预览视频的不上报 - if (ctr == null) return; //播放器为空不上报 - final duration = ctr.value.duration.inSeconds; - if (duration == 0) return; //时长为0不上报,避免除0异常 - final position = ctr.value.position.inSeconds; - final progress = position * 100 ~/ duration; - - if (event == VideoEventEnum.VIDEO_COMPLETE) { - if (progress < 90) { - //播放进度不足90%, 转为播放事件上报 - event = VideoEventEnum.VIDEO_PLAY; - } else { - // 自动播放完,业务监听会多次处理, 视频播放完成事件防重 - final now = DateTime.now().millisecondsSinceEpoch ~/ 1000; - if (now - _lastComplete < 2) { - debugLog("video complete 重复处理"); - return; - } - _lastComplete = now; - } - } - - //新增SDK埋点 - AnalyticsSdk.instance.track(VideoEvent( - videoId: model?.id ?? '', - videoTitle: model?.title ?? '', - videoTypeId: _or(model?.videoTypeId, 'default'), - videoTypeName: _or(model?.videoTypeName, '默认分类'), - videoTagKey: _or(model?.tags?.map((e) => e.id).join(','), 'default'), - videoTagName: _or(model?.tags?.map((e) => e.name).join(','), '默认标签'), - videoDuration: duration, - playDuration: position, - playProgress: progress, - videoBehavior: event, - videoContentType: model?.contentType == 'video' ? VideoContentTypeEnum.video : VideoContentTypeEnum.shortVideo, - recommendTraceId: '', - // 未接推荐引擎传 '' - mediaId: model?.lsjId ?? '', - )); - } - - //漫画行为 展示, 下一页, 上一页,读完 - static void comicEvent( - ReadBehaviorEnum behavior, //行为 - { - CartoonMediaInfo? model, - int? readProgress = 0, //章节百分比 - int? pageNo = 1, - MediaContent? mediaContent, - }) { - AnalyticsSdk.instance.track(ComicEvent( - comicId: model?.id ?? '', - comicTitle: model?.title ?? '', - comicTypeId: _or(model?.mId, 'default'), - comicTypeName: _or(model?.moduleName, '默认分类'), - comicTagKey: _or(model?.tagIds, 'default'), - comicTagName: _or(model?.tagNames, '默认标签'), - readProgress: readProgress ?? 0, - pageNo: pageNo ?? 0, - comicBehavior: behavior, - mediaId: model?.lsjId ?? '', - chapterId: _or(mediaContent?.id, '1'), - chapterName: _or(mediaContent?.name, '第一章'), - )); - } - - //小说行为 展示, 下一页, 上一页,读完 - static void novelEvent( - ReadBehaviorEnum behavior, { - CartoonMediaInfo? model, //小说模型 - int? readProgress = 0, //章节百分比 - int? pageNo = 1, - String? chapterId, - String? chapterName, - }) { - AnalyticsSdk.instance.track(NovelEvent( - novelId: model?.id ?? '', - novelTitle: model?.title ?? '', - novelTypeId: _or(model?.mId, 'default'), - novelTypeName: _or(model?.moduleName, '默认分类'), - novelTagKey: _or(model?.tagIds, 'default'), - novelTagName: _or(model?.tagNames, '默认标签'), - readProgress: readProgress ?? 0, - pageNo: pageNo ?? 0, - novelBehavior: behavior, - mediaId: model?.lsjId ?? '', - chapterId: _or(chapterId, '1'), - chapterName: _or(chapterName, '第一章'), - )); - } -} - -/// 页面 pageKey -> 中文名映射,app 启动时注册一次(main.dart) -/// key 就是 Get.to 自动生成的路由名(去掉斜杠后的页面类名),改类名要同步改这里 -void addMappings() { - PageNameMapper.addMappings({ - 'AiHomePage': 'AI首页', - 'AiSquarePage': 'AI广场', - 'CartoonDetailPage': '漫画详情页', - 'CartoonReadPage': '漫画阅读页', - 'CartoonRecommendPage': '漫画推荐页', - 'CartoonSectionListPage': '漫画专题更多页', - 'CartoonSectionSubPage': '漫画专题列表页', - 'CartoonSubGridPage': '漫画海角页', - 'CartoonSubModulePage': '漫画亚模块页', - 'NovelDetailPage': '小说详情页', - 'TextNovelReadPage': '文字小说阅读页', - 'VoiceNovelReadPage': '有声小说阅读页', - 'CommunityModulePage': '社区模块页', - 'CommunityTabPage': '社区Tab页', - 'SourceSubModulePage': '社区资源亚模块页', - 'CommunityMainPage': '社区主页', - 'TagDetailSubView': '标签详情页', - 'CommunityTagDetailPage': '社区标签详情页', - 'CommunityImagePage': '社区图片详情页', - 'PostDetailPage': '社区帖子详情页', - 'ChoseTopicPage': '发布选择话题页', - 'PublishPage': '帖子发布页', - 'PublishRulePage': '发布规则页', - 'PicsDetailPage': '色图详情页', - 'HomeTabSectionPage': '首页Tab专题页', - 'HomeTabSortPage': '首页Tab分类页', - 'SearchACGResultPage': '搜索ACG结果页', - 'SearchResultMainPage': '搜索结果主页面', - 'SearchVideoResultPage': '搜索视频结果页', - 'SectionAllPage': '原创达人', - 'SpecialTopicsDetailPage': '视频专题详情页', - 'CartoonTagPage': '漫画标签页', - 'VideoTagPage': '视频标签页', - 'DramaTagPage': '短剧标签页', //DramaTagDetailPage.routeName,改名要同步 - 'DarkHomePage': '暗网首页', - 'HomeMainPage': '视频主页', - 'LiveDetailPage': '直播详情页', - 'LiveMainPage': '直播主页', - 'LiveSubPage': '直播子页面', - 'MainPage': '主页面', - 'AiPaintPage': 'AI绘画页', - 'AiItvPage': 'AI图生视频页', - 'AINovelSubPage': 'AI小说首页', - 'AiNovelDetailPage': 'AI小说详情页', - 'AIChangeFacePage': 'AI换脸页', - 'AIFaceChangeSubPage': 'AI换脸子页', - 'AIStripSubPage': 'AI脱衣首页', - 'AIGrilFriendPage': 'AI女友页', - 'AiH5Page': 'AI H5页', - 'AIMainRecordPage': 'AI生成记录主页', - 'AiMainItvPage': 'AI图生视频主页', - 'AIModDetailPage': 'AI换脸模版页', - 'AiRecordPage': 'AI生成记录子页', - 'AdvancePage': '高级设置页', - 'ExchangeVipPage': '兑换会员页', - 'IntegralExchangePage': '积分兑换页', - 'OnlinePayPage': '在线支付页', - 'MineCollectPage': '我的收藏页', - 'MineBuyPage': '我的购买页', - 'MineHistoryPage': '历史记录页', - 'MineLikePage': '我的喜欢页', - 'MineAccountIdentityPage': '账号凭证页', - 'RecordsPage': '明细综合页', - 'WithdrawalPage': '提现页', - 'MessageCenterPage': '消息中心页', - 'ChatDetailPage': '私信详情页', - 'MineDrivePage': '加群开车页', - 'MineFollowSubPage': '关注子页面', - 'MineFollowingPage': '我的关注页', - 'MinePublishPostPage': '创作中心', - 'BankCardHomePage': '银行卡管理页', - 'BindPhonePage': '绑定手机号页', - 'InviteBindPage': '绑定邀请码', - 'MineExchangeCodePage': '兑换码页', - 'MineFindAccountPage': '找回账号', - 'MinePasswordPage': '手势密码', - 'MineScanLoginPage': '扫码登录', - 'MineSettingPage': '设置中心', - 'MineSettingProfilePage': '设置资料页', - 'SettingProfilePage': '编辑资料', - 'SettingAvatarPage': '设置头像', - 'SettingNickNamePage': '设置昵称', - 'MineSharePage': '分享邀请', - 'MineShareRecordPage': '邀请记录', - 'VideoCachePage': '下载缓存', - 'MineChargeCoinPage': '金币充值', - 'MineChargeVipPage': '会员充值', - 'MineFeedbackPage': '意见反馈', - 'MineHelpPage': '帮助反馈', - 'MineQAPage': '常见问题', - 'LouFengAdPage': '应用推荐', - 'MineIncomePage': '分享邀请', - 'RecommendAppPage': '推荐应用页', - 'MoreFreeVideoPage': '更多免费视频', - 'ShareDataListPage': '推广数据列表', - 'ShareDetailsPage': '推广数据', - 'WelfareHomePage': '应用推荐首页', - 'SignDailyPage': '每日签到', - 'HomeMinePage': '我的', - 'PreSalePage': '预售详情页', - 'ShortVideoMainPage': '抖音首页', - 'ShortVideoPage': '抖音', - 'SplashPage': '启动页', - 'RankMainPage': '排行榜主页', - 'RankSubPage': '排行榜子页', - 'UserCenterPage': '用户首页', - 'GroupChatPage': '群聊页', - 'GroupChatDetailPage': '群聊详情页', - 'SimpleVideoPlayerPage': '简单视频播放页', - 'VideoFullPage': '全屏视频播放', - 'VideoPage': '视频播放页', - 'H5Page': 'H5页面', - 'SearchMainPage': '搜索页', - 'VideoAllTypePage': '视频片库', - 'AcgGameRecommendPage': 'ACG游戏推荐页', - 'AcgGameDetailPage': 'ACG游戏详情页', - 'CartoonSubDetailPage': '漫画子详情页', - 'MessageCenterSubPage': '消息中心子页', - 'MinePublishSubPage': '创作中心子页', - 'Home': '主页Tab', - 'Community': '社区Tab', - 'ShortVideo': '抖音Tab', - 'AI': 'AI Tab', - 'Mine': '我的Tab', - 'Pic': ' 色图Tab', - 'Novel': '小说Tab', - }); -}