From 92816047c66ae7e389846d63a822eb43e8bc42f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=B0=A2=E5=AE=87=E5=AE=81?= Date: Thu, 8 Oct 2026 20:10:10 +0700 Subject: [PATCH] =?UTF-8?q?=E5=88=A0=E9=99=A4=E5=9F=8B=E7=82=B9=EF=BC=8Cim?= =?UTF-8?q?=E5=92=8C=E5=AE=A2=E6=9C=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- analytics_sdk/.gitignore | 29 - analytics_sdk/LICENSE | 1 - analytics_sdk/README.md | 1158 ------------ analytics_sdk/SDK_USAGE_GUIDE.md | 1027 ----------- analytics_sdk/analysis_options.yaml | 4 - analytics_sdk/android/.gitignore | 9 - analytics_sdk/android/build.gradle | 64 - .../gradle/wrapper/gradle-wrapper.properties | 7 - analytics_sdk/android/settings.gradle | 1 - .../android/src/main/AndroidManifest.xml | 3 - .../analytics_sdk/AnalyticsSdkPlugin.kt | 491 ----- .../analytics_sdk/ClientIdentifierResolver.kt | 27 - .../ClientIdentifierResolverTest.kt | 80 - .../data_plus_sdk/DataPlusSdkPluginTest.kt | 27 - analytics_sdk/ios/.gitignore | 38 - analytics_sdk/ios/Assets/.gitkeep | 0 .../ios/Classes/DataPlusSdkPlugin.swift | 320 ---- analytics_sdk/ios/analytics_sdk.podspec | 23 - analytics_sdk/lib/analytics_sdk.dart | 1589 ----------------- analytics_sdk/lib/analytics_sdk_web.dart | 12 - analytics_sdk/lib/config/api_config.dart | 84 - analytics_sdk/lib/config/sdk_config.dart | 334 ---- analytics_sdk/lib/const/event_type.dart | 189 -- analytics_sdk/lib/entity/ad_click_event.dart | 79 - .../lib/entity/ad_impression_event.dart | 97 - analytics_sdk/lib/entity/ad_sdk_events.dart | 459 ----- .../lib/entity/advertising_event.dart | 57 - analytics_sdk/lib/entity/analytics_tab.dart | 24 - analytics_sdk/lib/entity/analytics_utils.dart | 5 - .../lib/entity/app_install_event.dart | 46 - .../lib/entity/app_lifecycle_event.dart | 189 -- .../lib/entity/app_page_item_event.dart | 217 --- .../lib/entity/app_page_view_event.dart | 181 -- .../lib/entity/app_session_summary_event.dart | 128 -- analytics_sdk/lib/entity/comic_event.dart | 130 -- .../lib/entity/keyword_click_event.dart | 71 - .../lib/entity/keyword_search_event.dart | 65 - .../lib/entity/navigation_event.dart | 67 - .../entity/network_state_change_event.dart | 89 - analytics_sdk/lib/entity/novel_event.dart | 130 -- .../lib/entity/page_click_event.dart | 284 --- .../entity/recommend_list_click_event.dart | 87 - .../lib/entity/recommend_list_view_event.dart | 88 - analytics_sdk/lib/entity/sdk_init_event.dart | 32 - analytics_sdk/lib/entity/video_event.dart | 132 -- analytics_sdk/lib/enum/ad_type_enum.dart | 40 - analytics_sdk/lib/enum/auth_type_enum.dart | 25 - analytics_sdk/lib/enum/banner_event_enum.dart | 11 - .../lib/enum/click_item_type_enum.dart | 34 - .../lib/enum/consume_reason_enum.dart | 25 - analytics_sdk/lib/enum/currency_enum.dart | 19 - analytics_sdk/lib/enum/device_enum.dart | 11 - analytics_sdk/lib/enum/order_type_enum.dart | 19 - analytics_sdk/lib/enum/page_route_enum.dart | 174 -- analytics_sdk/lib/enum/pay_type_enum.dart | 25 - .../lib/enum/read_behavior_enum.dart | 35 - .../lib/enum/recommend_content_type_enum.dart | 28 - analytics_sdk/lib/enum/user_type_enum.dart | 28 - .../lib/enum/video_content_type_enum.dart | 30 - analytics_sdk/lib/enum/video_event_enum.dart | 38 - .../extension/tab_analytics_extension.dart | 51 - .../lib/manager/ad_impression_manager.dart | 105 -- analytics_sdk/lib/manager/domain_manager.dart | 204 --- .../lib/manager/event_persistence.dart | 7 - .../lib/manager/event_persistence_native.dart | 398 ----- .../lib/manager/event_persistence_web.dart | 35 - .../lib/manager/event_processor.dart | 552 ------ .../manager/event_type_config_manager.dart | 168 -- .../manager/network_state_fields_stub.dart | 3 - .../lib/manager/network_state_fields_web.dart | 65 - .../lib/manager/page_click_auto_fields.dart | 2 - .../manager/page_click_auto_fields_stub.dart | 33 - .../manager/page_click_auto_fields_web.dart | 77 - .../lib/manager/page_name_manager.dart | 177 -- .../page_observer_registration_guard.dart | 41 - .../lib/manager/page_view_auto_fields.dart | 2 - .../manager/page_view_auto_fields_stub.dart | 1 - .../manager/page_view_auto_fields_web.dart | 27 - .../lib/manager/sdk_activation_store.dart | 21 - analytics_sdk/lib/manager/sdk_context.dart | 439 ----- .../lib/manager/sdk_init_payload_builder.dart | 239 --- .../lib/manager/sdk_init_platform_fields.dart | 3 - .../sdk_init_platform_fields_native.dart | 61 - .../manager/sdk_init_platform_fields_web.dart | 540 ------ .../lib/manager/session_behavior_manager.dart | 292 --- .../lib/manager/session_behavior_monitor.dart | 2 - .../session_behavior_monitor_stub.dart | 10 - .../manager/session_behavior_monitor_web.dart | 195 -- .../lib/manager/session_manager.dart | 316 ---- .../lib/manager/session_metrics_manager.dart | 300 ---- .../lib/manager/session_sensor_proximity.dart | 2 - .../session_sensor_proximity_native.dart | 17 - .../session_sensor_proximity_stub.dart | 1 - .../lib/manager/session_sensor_sampler.dart | 467 ----- analytics_sdk/lib/manager/user_manager.dart | 35 - .../lib/observer/app_lifecycle_observer.dart | 160 -- .../app_lifecycle_web_unload_stub.dart | 8 - .../app_lifecycle_web_unload_web.dart | 88 - .../lib/observer/page_lifecycle_observer.dart | 458 ----- analytics_sdk/lib/utils/aes_gcm_util.dart | 346 ---- .../lib/utils/device_fingerprint_native.dart | 52 - .../lib/utils/device_fingerprint_util.dart | 216 --- .../lib/utils/device_fingerprint_web.dart | 78 - analytics_sdk/lib/utils/device_id_native.dart | 29 - analytics_sdk/lib/utils/device_id_util.dart | 13 - analytics_sdk/lib/utils/device_id_web.dart | 18 - .../lib/utils/device_info_native.dart | 60 - analytics_sdk/lib/utils/device_info_util.dart | 36 - analytics_sdk/lib/utils/device_info_web.dart | 86 - .../lib/utils/environment_info_native.dart | 119 -- .../lib/utils/environment_info_util.dart | 291 --- .../lib/utils/environment_info_web.dart | 181 -- analytics_sdk/lib/utils/event_validator.dart | 592 ------ .../lib/utils/js_interop_compat.dart | 2 - .../lib/utils/js_interop_legacy.dart | 23 - .../lib/utils/js_interop_modern.dart | 57 - analytics_sdk/lib/utils/logger.dart | 218 --- .../lib/utils/network_type_resolver.dart | 57 - analytics_sdk/lib/utils/platform_storage.dart | 14 - .../lib/utils/platform_storage_native.dart | 38 - .../lib/utils/platform_storage_web.dart | 14 - .../lib/utils/runtime_observation_native.dart | 164 -- .../lib/utils/runtime_observation_stub.dart | 16 - analytics_sdk/lib/utils/uuid_util.dart | 15 - analytics_sdk/lib/utils/widget_bridge.dart | 34 - analytics_sdk/lib/widget/_tab_helpers.dart | 20 - .../widget/analytics_bottom_nav_wrapper.dart | 163 -- .../lib/widget/analytics_debug_banner.dart | 158 -- .../lib/widget/analytics_tab_bar_wrapper.dart | 165 -- .../lib/widget/global_click_wrapper.dart | 393 ---- analytics_sdk/pubspec.yaml | 53 - .../test/analytics_sdk_ad_dedup_test.dart | 181 -- .../test/analytics_sdk_decrypt_test.dart | 104 -- .../test/analytics_utils_fp_version_test.dart | 39 - analytics_sdk/test/configure_null_test.dart | 39 - .../data_plus_sdk_method_channel_test.dart | 5 - analytics_sdk/test/data_plus_sdk_test.dart | 1 - .../test/domain_manager_cache_test.dart | 94 - .../test/event_processor_logic_test.dart | 128 -- .../test/event_processor_tombstone_test.dart | 117 -- .../test/event_validator_regression_test.dart | 142 -- .../event_validator_type_coercion_test.dart | 59 - analytics_sdk/test/logout_user_test.dart | 36 - analytics_sdk/test/page_name_mapper_test.dart | 68 - .../test/session_manager_regression_test.dart | 189 -- assets/images/holiday/1/home_0_0.png | Bin 5200 -> 0 bytes assets/images/holiday/1/home_0_1.png | Bin 1427 -> 0 bytes assets/images/holiday/1/home_1_0.png | Bin 4188 -> 0 bytes assets/images/holiday/1/home_1_1.png | Bin 1265 -> 0 bytes assets/images/holiday/1/home_2_0.png | Bin 2652 -> 0 bytes assets/images/holiday/1/home_2_1.png | Bin 945 -> 0 bytes assets/images/holiday/1/home_3_0.png | Bin 4376 -> 0 bytes assets/images/holiday/1/home_3_1.png | Bin 1262 -> 0 bytes assets/images/holiday/1/home_4_0.png | Bin 4435 -> 0 bytes assets/images/holiday/1/home_4_1.png | Bin 1431 -> 0 bytes assets/images/holiday/1/home_5_0.png | Bin 5224 -> 0 bytes assets/images/holiday/1/home_5_1.png | Bin 1868 -> 0 bytes assets/images/holiday/1/ic_launcher.webp | Bin 50994 -> 0 bytes assets/images/holiday/1/mine_agent_enter.webp | Bin 4030 -> 0 bytes assets/images/holiday/1/mine_ai_enter.webp | Bin 12496 -> 0 bytes assets/images/holiday/1/mine_gold_enter.webp | Bin 7452 -> 0 bytes assets/images/holiday/1/mine_vip_bg.webp | Bin 13114 -> 0 bytes assets/images/holiday/1/mine_vip_enter.webp | Bin 13724 -> 0 bytes assets/images/holiday/1/publish.png | Bin 1342 -> 0 bytes assets/images/holiday/5/home_0_0.png | Bin 6668 -> 0 bytes assets/images/holiday/5/home_0_1.png | Bin 6668 -> 0 bytes assets/images/holiday/5/home_1_0.png | Bin 6254 -> 0 bytes assets/images/holiday/5/home_1_1.png | Bin 6254 -> 0 bytes assets/images/holiday/5/home_2_0.png | Bin 6445 -> 0 bytes assets/images/holiday/5/home_2_1.png | Bin 6445 -> 0 bytes assets/images/holiday/5/home_3_0.png | Bin 7151 -> 0 bytes assets/images/holiday/5/home_3_1.png | Bin 7151 -> 0 bytes assets/images/holiday/5/home_4_0.png | Bin 5630 -> 0 bytes assets/images/holiday/5/home_4_1.png | Bin 5630 -> 0 bytes assets/images/holiday/5/home_5_0.png | Bin 5965 -> 0 bytes assets/images/holiday/5/home_5_1.png | Bin 5965 -> 0 bytes assets/images/holiday/5/ic_launcher.webp | Bin 50994 -> 0 bytes assets/images/holiday/5/icon_broadcast.png | Bin 1148 -> 0 bytes assets/images/holiday/5/main_bg.webp | Bin 284056 -> 0 bytes assets/images/holiday/5/mine_agent_enter.webp | Bin 4030 -> 0 bytes assets/images/holiday/5/mine_ai_enter.webp | Bin 12496 -> 0 bytes assets/images/holiday/5/mine_bg.webp | Bin 50866 -> 0 bytes assets/images/holiday/5/mine_gold_enter.webp | Bin 7452 -> 0 bytes assets/images/holiday/5/mine_vip_bg.webp | Bin 13114 -> 0 bytes assets/images/holiday/5/mine_vip_enter.webp | Bin 13724 -> 0 bytes assets/images/holiday/5/publish.png | Bin 628 -> 0 bytes assets/images/holiday/5/sign_icon.png | Bin 6452 -> 0 bytes lib/hj_utils/holiday_theme.dart | 144 -- lib/im_manager/api/im_api_service.dart | 76 - .../chat_detail_page/Im_ad_view.dart | 283 --- .../chat_detail_page/chat_detail_logic.dart | 382 ---- .../chat_detail_page/chat_detail_page.dart | 100 -- .../chat_session_list_logic.dart | 94 - .../chat_session_list_view.dart | 135 -- .../widget/chat_item_cell.dart | 234 --- .../widget/input_foot_view.dart | 157 -- .../widget/input_sheet_view.dart | 166 -- lib/im_manager/im_ad/im_ad_item.dart | 111 -- lib/im_manager/im_ad/im_ad_manager.dart | 155 -- lib/im_manager/im_ad/im_ad_provider.dart | 101 -- lib/im_manager/im_manager.dart | 278 --- lib/im_manager/im_notification.dart | 99 - lib/im_manager/model/im_message_resp.dart | 203 --- lib/im_manager/model/im_user_model.dart | 74 - lib/track_event_manager/track_manager.dart | 445 ----- 205 files changed, 22478 deletions(-) delete mode 100644 analytics_sdk/.gitignore delete mode 100644 analytics_sdk/LICENSE delete mode 100644 analytics_sdk/README.md delete mode 100644 analytics_sdk/SDK_USAGE_GUIDE.md delete mode 100644 analytics_sdk/analysis_options.yaml delete mode 100644 analytics_sdk/android/.gitignore delete mode 100644 analytics_sdk/android/build.gradle delete mode 100644 analytics_sdk/android/gradle/wrapper/gradle-wrapper.properties delete mode 100644 analytics_sdk/android/settings.gradle delete mode 100644 analytics_sdk/android/src/main/AndroidManifest.xml delete mode 100644 analytics_sdk/android/src/main/kotlin/com/example/analytics_sdk/AnalyticsSdkPlugin.kt delete mode 100644 analytics_sdk/android/src/main/kotlin/com/example/analytics_sdk/ClientIdentifierResolver.kt delete mode 100644 analytics_sdk/android/src/test/kotlin/com/example/analytics_sdk/ClientIdentifierResolverTest.kt delete mode 100644 analytics_sdk/android/src/test/kotlin/com/example/data_plus_sdk/DataPlusSdkPluginTest.kt delete mode 100644 analytics_sdk/ios/.gitignore delete mode 100644 analytics_sdk/ios/Assets/.gitkeep delete mode 100644 analytics_sdk/ios/Classes/DataPlusSdkPlugin.swift delete mode 100644 analytics_sdk/ios/analytics_sdk.podspec delete mode 100644 analytics_sdk/lib/analytics_sdk.dart delete mode 100644 analytics_sdk/lib/analytics_sdk_web.dart delete mode 100644 analytics_sdk/lib/config/api_config.dart delete mode 100644 analytics_sdk/lib/config/sdk_config.dart delete mode 100644 analytics_sdk/lib/const/event_type.dart delete mode 100644 analytics_sdk/lib/entity/ad_click_event.dart delete mode 100644 analytics_sdk/lib/entity/ad_impression_event.dart delete mode 100644 analytics_sdk/lib/entity/ad_sdk_events.dart delete mode 100644 analytics_sdk/lib/entity/advertising_event.dart delete mode 100644 analytics_sdk/lib/entity/analytics_tab.dart delete mode 100644 analytics_sdk/lib/entity/analytics_utils.dart delete mode 100644 analytics_sdk/lib/entity/app_install_event.dart delete mode 100644 analytics_sdk/lib/entity/app_lifecycle_event.dart delete mode 100644 analytics_sdk/lib/entity/app_page_item_event.dart delete mode 100644 analytics_sdk/lib/entity/app_page_view_event.dart delete mode 100644 analytics_sdk/lib/entity/app_session_summary_event.dart delete mode 100644 analytics_sdk/lib/entity/comic_event.dart delete mode 100644 analytics_sdk/lib/entity/keyword_click_event.dart delete mode 100644 analytics_sdk/lib/entity/keyword_search_event.dart delete mode 100644 analytics_sdk/lib/entity/navigation_event.dart delete mode 100644 analytics_sdk/lib/entity/network_state_change_event.dart delete mode 100644 analytics_sdk/lib/entity/novel_event.dart delete mode 100644 analytics_sdk/lib/entity/page_click_event.dart delete mode 100644 analytics_sdk/lib/entity/recommend_list_click_event.dart delete mode 100644 analytics_sdk/lib/entity/recommend_list_view_event.dart delete mode 100644 analytics_sdk/lib/entity/sdk_init_event.dart delete mode 100644 analytics_sdk/lib/entity/video_event.dart delete mode 100644 analytics_sdk/lib/enum/ad_type_enum.dart delete mode 100644 analytics_sdk/lib/enum/auth_type_enum.dart delete mode 100644 analytics_sdk/lib/enum/banner_event_enum.dart delete mode 100644 analytics_sdk/lib/enum/click_item_type_enum.dart delete mode 100644 analytics_sdk/lib/enum/consume_reason_enum.dart delete mode 100644 analytics_sdk/lib/enum/currency_enum.dart delete mode 100644 analytics_sdk/lib/enum/device_enum.dart delete mode 100644 analytics_sdk/lib/enum/order_type_enum.dart delete mode 100644 analytics_sdk/lib/enum/page_route_enum.dart delete mode 100644 analytics_sdk/lib/enum/pay_type_enum.dart delete mode 100644 analytics_sdk/lib/enum/read_behavior_enum.dart delete mode 100644 analytics_sdk/lib/enum/recommend_content_type_enum.dart delete mode 100644 analytics_sdk/lib/enum/user_type_enum.dart delete mode 100644 analytics_sdk/lib/enum/video_content_type_enum.dart delete mode 100644 analytics_sdk/lib/enum/video_event_enum.dart delete mode 100644 analytics_sdk/lib/extension/tab_analytics_extension.dart delete mode 100644 analytics_sdk/lib/manager/ad_impression_manager.dart delete mode 100644 analytics_sdk/lib/manager/domain_manager.dart delete mode 100644 analytics_sdk/lib/manager/event_persistence.dart delete mode 100644 analytics_sdk/lib/manager/event_persistence_native.dart delete mode 100644 analytics_sdk/lib/manager/event_persistence_web.dart delete mode 100644 analytics_sdk/lib/manager/event_processor.dart delete mode 100644 analytics_sdk/lib/manager/event_type_config_manager.dart delete mode 100644 analytics_sdk/lib/manager/network_state_fields_stub.dart delete mode 100644 analytics_sdk/lib/manager/network_state_fields_web.dart delete mode 100644 analytics_sdk/lib/manager/page_click_auto_fields.dart delete mode 100644 analytics_sdk/lib/manager/page_click_auto_fields_stub.dart delete mode 100644 analytics_sdk/lib/manager/page_click_auto_fields_web.dart delete mode 100644 analytics_sdk/lib/manager/page_name_manager.dart delete mode 100644 analytics_sdk/lib/manager/page_observer_registration_guard.dart delete mode 100644 analytics_sdk/lib/manager/page_view_auto_fields.dart delete mode 100644 analytics_sdk/lib/manager/page_view_auto_fields_stub.dart delete mode 100644 analytics_sdk/lib/manager/page_view_auto_fields_web.dart delete mode 100644 analytics_sdk/lib/manager/sdk_activation_store.dart delete mode 100644 analytics_sdk/lib/manager/sdk_context.dart delete mode 100644 analytics_sdk/lib/manager/sdk_init_payload_builder.dart delete mode 100644 analytics_sdk/lib/manager/sdk_init_platform_fields.dart delete mode 100644 analytics_sdk/lib/manager/sdk_init_platform_fields_native.dart delete mode 100644 analytics_sdk/lib/manager/sdk_init_platform_fields_web.dart delete mode 100644 analytics_sdk/lib/manager/session_behavior_manager.dart delete mode 100644 analytics_sdk/lib/manager/session_behavior_monitor.dart delete mode 100644 analytics_sdk/lib/manager/session_behavior_monitor_stub.dart delete mode 100644 analytics_sdk/lib/manager/session_behavior_monitor_web.dart delete mode 100644 analytics_sdk/lib/manager/session_manager.dart delete mode 100644 analytics_sdk/lib/manager/session_metrics_manager.dart delete mode 100644 analytics_sdk/lib/manager/session_sensor_proximity.dart delete mode 100644 analytics_sdk/lib/manager/session_sensor_proximity_native.dart delete mode 100644 analytics_sdk/lib/manager/session_sensor_proximity_stub.dart delete mode 100644 analytics_sdk/lib/manager/session_sensor_sampler.dart delete mode 100644 analytics_sdk/lib/manager/user_manager.dart delete mode 100644 analytics_sdk/lib/observer/app_lifecycle_observer.dart delete mode 100644 analytics_sdk/lib/observer/app_lifecycle_web_unload_stub.dart delete mode 100644 analytics_sdk/lib/observer/app_lifecycle_web_unload_web.dart delete mode 100644 analytics_sdk/lib/observer/page_lifecycle_observer.dart delete mode 100644 analytics_sdk/lib/utils/aes_gcm_util.dart delete mode 100644 analytics_sdk/lib/utils/device_fingerprint_native.dart delete mode 100644 analytics_sdk/lib/utils/device_fingerprint_util.dart delete mode 100644 analytics_sdk/lib/utils/device_fingerprint_web.dart delete mode 100644 analytics_sdk/lib/utils/device_id_native.dart delete mode 100644 analytics_sdk/lib/utils/device_id_util.dart delete mode 100644 analytics_sdk/lib/utils/device_id_web.dart delete mode 100644 analytics_sdk/lib/utils/device_info_native.dart delete mode 100644 analytics_sdk/lib/utils/device_info_util.dart delete mode 100644 analytics_sdk/lib/utils/device_info_web.dart delete mode 100644 analytics_sdk/lib/utils/environment_info_native.dart delete mode 100644 analytics_sdk/lib/utils/environment_info_util.dart delete mode 100644 analytics_sdk/lib/utils/environment_info_web.dart delete mode 100644 analytics_sdk/lib/utils/event_validator.dart delete mode 100644 analytics_sdk/lib/utils/js_interop_compat.dart delete mode 100644 analytics_sdk/lib/utils/js_interop_legacy.dart delete mode 100644 analytics_sdk/lib/utils/js_interop_modern.dart delete mode 100644 analytics_sdk/lib/utils/logger.dart delete mode 100644 analytics_sdk/lib/utils/network_type_resolver.dart delete mode 100644 analytics_sdk/lib/utils/platform_storage.dart delete mode 100644 analytics_sdk/lib/utils/platform_storage_native.dart delete mode 100644 analytics_sdk/lib/utils/platform_storage_web.dart delete mode 100644 analytics_sdk/lib/utils/runtime_observation_native.dart delete mode 100644 analytics_sdk/lib/utils/runtime_observation_stub.dart delete mode 100644 analytics_sdk/lib/utils/uuid_util.dart delete mode 100644 analytics_sdk/lib/utils/widget_bridge.dart delete mode 100644 analytics_sdk/lib/widget/_tab_helpers.dart delete mode 100644 analytics_sdk/lib/widget/analytics_bottom_nav_wrapper.dart delete mode 100644 analytics_sdk/lib/widget/analytics_debug_banner.dart delete mode 100644 analytics_sdk/lib/widget/analytics_tab_bar_wrapper.dart delete mode 100644 analytics_sdk/lib/widget/global_click_wrapper.dart delete mode 100644 analytics_sdk/pubspec.yaml delete mode 100644 analytics_sdk/test/analytics_sdk_ad_dedup_test.dart delete mode 100644 analytics_sdk/test/analytics_sdk_decrypt_test.dart delete mode 100644 analytics_sdk/test/analytics_utils_fp_version_test.dart delete mode 100644 analytics_sdk/test/configure_null_test.dart delete mode 100644 analytics_sdk/test/data_plus_sdk_method_channel_test.dart delete mode 100644 analytics_sdk/test/data_plus_sdk_test.dart delete mode 100644 analytics_sdk/test/domain_manager_cache_test.dart delete mode 100644 analytics_sdk/test/event_processor_logic_test.dart delete mode 100644 analytics_sdk/test/event_processor_tombstone_test.dart delete mode 100644 analytics_sdk/test/event_validator_regression_test.dart delete mode 100644 analytics_sdk/test/event_validator_type_coercion_test.dart delete mode 100644 analytics_sdk/test/logout_user_test.dart delete mode 100644 analytics_sdk/test/page_name_mapper_test.dart delete mode 100644 analytics_sdk/test/session_manager_regression_test.dart delete mode 100644 assets/images/holiday/1/home_0_0.png delete mode 100644 assets/images/holiday/1/home_0_1.png delete mode 100644 assets/images/holiday/1/home_1_0.png delete mode 100644 assets/images/holiday/1/home_1_1.png delete mode 100644 assets/images/holiday/1/home_2_0.png delete mode 100644 assets/images/holiday/1/home_2_1.png delete mode 100644 assets/images/holiday/1/home_3_0.png delete mode 100644 assets/images/holiday/1/home_3_1.png delete mode 100644 assets/images/holiday/1/home_4_0.png delete mode 100644 assets/images/holiday/1/home_4_1.png delete mode 100644 assets/images/holiday/1/home_5_0.png delete mode 100644 assets/images/holiday/1/home_5_1.png delete mode 100644 assets/images/holiday/1/ic_launcher.webp delete mode 100644 assets/images/holiday/1/mine_agent_enter.webp delete mode 100644 assets/images/holiday/1/mine_ai_enter.webp delete mode 100644 assets/images/holiday/1/mine_gold_enter.webp delete mode 100644 assets/images/holiday/1/mine_vip_bg.webp delete mode 100644 assets/images/holiday/1/mine_vip_enter.webp delete mode 100644 assets/images/holiday/1/publish.png delete mode 100644 assets/images/holiday/5/home_0_0.png delete mode 100644 assets/images/holiday/5/home_0_1.png delete mode 100644 assets/images/holiday/5/home_1_0.png delete mode 100644 assets/images/holiday/5/home_1_1.png delete mode 100644 assets/images/holiday/5/home_2_0.png delete mode 100644 assets/images/holiday/5/home_2_1.png delete mode 100644 assets/images/holiday/5/home_3_0.png delete mode 100644 assets/images/holiday/5/home_3_1.png delete mode 100644 assets/images/holiday/5/home_4_0.png delete mode 100644 assets/images/holiday/5/home_4_1.png delete mode 100644 assets/images/holiday/5/home_5_0.png delete mode 100644 assets/images/holiday/5/home_5_1.png delete mode 100644 assets/images/holiday/5/ic_launcher.webp delete mode 100644 assets/images/holiday/5/icon_broadcast.png delete mode 100644 assets/images/holiday/5/main_bg.webp delete mode 100644 assets/images/holiday/5/mine_agent_enter.webp delete mode 100644 assets/images/holiday/5/mine_ai_enter.webp delete mode 100644 assets/images/holiday/5/mine_bg.webp delete mode 100644 assets/images/holiday/5/mine_gold_enter.webp delete mode 100644 assets/images/holiday/5/mine_vip_bg.webp delete mode 100644 assets/images/holiday/5/mine_vip_enter.webp delete mode 100644 assets/images/holiday/5/publish.png delete mode 100644 assets/images/holiday/5/sign_icon.png delete mode 100644 lib/hj_utils/holiday_theme.dart delete mode 100644 lib/im_manager/api/im_api_service.dart delete mode 100644 lib/im_manager/chat_detail_page/Im_ad_view.dart delete mode 100644 lib/im_manager/chat_detail_page/chat_detail_logic.dart delete mode 100644 lib/im_manager/chat_detail_page/chat_detail_page.dart delete mode 100644 lib/im_manager/chat_detail_page/chat_session_list_logic.dart delete mode 100644 lib/im_manager/chat_detail_page/chat_session_list_view.dart delete mode 100644 lib/im_manager/chat_detail_page/widget/chat_item_cell.dart delete mode 100644 lib/im_manager/chat_detail_page/widget/input_foot_view.dart delete mode 100644 lib/im_manager/chat_detail_page/widget/input_sheet_view.dart delete mode 100644 lib/im_manager/im_ad/im_ad_item.dart delete mode 100644 lib/im_manager/im_ad/im_ad_manager.dart delete mode 100644 lib/im_manager/im_ad/im_ad_provider.dart delete mode 100644 lib/im_manager/im_manager.dart delete mode 100644 lib/im_manager/im_notification.dart delete mode 100644 lib/im_manager/model/im_message_resp.dart delete mode 100644 lib/im_manager/model/im_user_model.dart delete mode 100644 lib/track_event_manager/track_manager.dart 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 dd42a3f2c0651bd297d8d3852aada735f613791e..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 5200 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 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 ed90360ce9857c124727bffd9f75c397cdeffd82..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4188 zcmV-i5ToyjP)#Mr6?hCAzGzE@0clrb$g|O#7oYq#6zNkyhzL5qwp7 zv%v1&bIwe^$ILnB-rbdZFT3Yf)T%oY0z*^v{5JtN3z&-f>z(DWXr(qIdLJ4&1 z?JK|HQuio|#vIxv?vb`0wprX~4nn-FUqSk6`eUe^;u5%b^ zxqQpUp66i#p)LZ=U(&rD@tFfv3YO6Ws8*mJ?5I8@Ng8+KyyDFWGe%zmQ9-ry*RD0X z4HZ<^Kwr-@FhQW}>^BWpP7rDTC@ygq3GO06v|`jdiR15LKj3fa1BtaR=5KJZvzEu_ z{b){`y(32?&bLcj{w{O63>TS`aKLX34|Xo?{xVDu)Wt!acdq&ZZf_ZO?y%M(Cus6f z&c&v4t2!1fPv-B`%23{qM5w5Av2FnW|6|RZ4~$wT>+TJ~=!PRor#B5e{_qEIl5kQ= zdfwu$enj$WdJnYVy^qm1BTj%#Ksw{_5iUxijQKE!xA8d{7HO!tU&sK%j#H+vU&@NE}K!CDI4=v z)i@<{(OAgW%;uIh{=V3>xBUc85a0IW zwryT|2)vTc7E~e>-c9CjkoR(1^qozQuKBPQ59HJ*aI&ka%syLBTi_lo%W|J=d*oBz zJg{-yKcPv8#%s8BY1i_2*!@TuCF|01BlvFkHFszi zUa3^Z^d)i0UR+^A?tN?Y6D&{3E&=1`8_DlbxftS#%dxlvnuKvh%sTiXTMuMc+z_#L zg8l}ww<6I=f$Bjllp}Fs>mvQ3_^^eMck0LO3Q`*DTsEHGRlX6 zx74X5Z}a^un`xZJGg3JHRlCc$WP<(vEZe(~|A;jv)B z%Cl@_ug8V1!_Uhz(^a#RDvtvjJ4(lI+^}K8QD_jNdOI4WzmX#MgvGUDUxT{vG((G9j|Ab@~-RSpad=@Ma>3?%(Kh6L_$U6 z7JB2(&Ig+UZ1h>BDG@x+_m10~{PQv!MHL4PK&n$DJBKd5hWUsBT*bJs*DvcKhn@#b zKwTV!gt%Ifk|uO)fpfS{%Ak~kLMAS8Y*C1ltbF@=?uR9kn3b)fl1{lE87m@hjFb74 zIHO_B3>PKef>>-Q5>LgHRU-;Rf2gdCrt_D63-lECnH@cx|Fz5J7Y?-3!Pyuh<#p z-7Ra^9$UEVXJ^Jkl?U;}r(uxk#gq&}cc{9@SbN!D$@@{C;NkVd)D4VHd zE=ePzrv%oQtSU*_jW8GOf{7*&ESoeP*4^6YVMPCC z%a)!h9@)J9fxQ@czm3yg;@I08ZDcTQdnHbHnzHHjsnyrdThg^xEB6)>YUv$pRofm02wYsKV?sc2BBUnRzl}BX6M!Bhm1aka&me;vbH{IU=~wyD*+p~_O07K z-o$wNsU`71D3!{iILNZ{RcW4|wx?mI>(E!cj#tte@v>gQan3Pw8yFu^e3FcGZsG~_ z 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 b-X{M5TCi%2{E#<-00000NkvXXu0mjfh?zMd 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 f33266ce64e93bb1f3dd66ca6e4be489dec693fe..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2652 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 KMNUMnLSTXg-Q@x8NbgeOa+tpsz&PVkl$k?Qu zE{-E4^vYC?pcp-WQ9?-AJ5-`>1ck0u`iy&2q?$m(@9eHM=Eg?i9V$_!i?MXA=eUIZ zriM4F6G3J+xpua}?_GZRf8cDMX=-^R@d)z%Wv($H8oJSa^HFC-wfFskAfgcz8%Y_O zr@T#rIjrz;qAP}M7hB;2sMCpKC=D0)Mq&c_=o#>lczJ#l*mu}>ts#V51f_YNDS=wh z-zDaaLM@j|qR>HrV8U6?gqgHC;UQL{Bk%ovjVihRX zY_{OQ;3d*=b)%Fz(=2QPc|T&Zhun7JT;9lL6E>$e;)|eF(PWgv^ctiF5D^6cTo^H!7OV$I0j)aO99sknazk%TYfn zt{as_P>$!J!S&2yZ7SCj7I zr#p6#A=w*|BdB200pa1t%gKJ^;aG`1CdnJoBghm@M$bQbVy#_p{k=&k(UJs;K4OdO zVMFysvo1fGNdozurD4y`HHfs2+h%&o#bl%C z_wQH-3e?*`DZp-OFVZ=(O0IE<*Ckcyp_q#vZlZbt{~V(zilQirq9}@@D8>8({6N_j TgEcmo00000NkvXXu0mjfvjeAx 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 30c39a4a71f6f8b68704331a9ce1991c28bdd0b4..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4376 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 Y1B`Ql62Yshy#N3J07*qoM6N<$f<~}SXaE2J 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 3b77a729ea360a28bc8fdd3d06dcf546315f5cef..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4435 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 lQzbr 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 b9f013b9a2d694e38cddd64569dc40da9102fd71..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 5224 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 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 064df1d197f56ae487e17cba0bbd1858b6528090..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4030 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 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 0916c7fe1482efd3bf1e6732292158d53f888f23..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 12496 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 aOO$=&q?Go!U7%lNdWwzRrf&L%zyJU{>mNV> 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 84f3681cec171707088c711c08076c0749786456..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 7452 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 A*Z=?k 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 a35b2a94248ded4d63b80839e9951ce940840687..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6668 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≶JSokaj?wIvoUXUeossY!%V4)Ta{5GS?5=Hiq&B{!A? zMuZTlG3f#DLa)Le&&tl8{aSq^y5Nnpv#m+{j@+QEtaZ) z_05Rws6zCE?RFtv>D8&xkkqV14Fw5cQ$-cl@mtDE*`Ra+2FXS!dsNbmEGt$RCfj6s$g0lS-d+eZDkxNmjxaFYKBrq9z!*ytVWg}W1dz}De zcKt}?g^6|u{(iBYLxrT)q_&jDI@KN6NRSW6RiW%`#lqy_bJ`cP=hV>D^wTW$qX)v< zerU>;S7vT{a6XCv3a+Cv!(nJOizry6WYkSfz=5L?dA}9)*W3?*&9X^l}+Of zloDi+m)cCm<+BM|?1KSj=MqP|x7ht3QK}F%^fesk1r^r?B^#JD@6t~55z=MAbYAG0UdL zP5g1)%vNMA?gvv@r2xiLx#6p#bwboHU*qchxKI)eRh@`z}uc4xR zm{`_hCdrw!C$7Uv%DKhEbMB3uNMpjqIH+GP z-Xas5NHoJS4FT$H9jU5^50b8I-maPs5i=>GWDkKiS7f{UYHF6KVRP^3QcDRsWjV*U zkhy<*GrmA@Qj6=VaqPfx(zv3Gx%UQd$dGd{k4CKlOqNwrBxZ6w8gxn97A~)TY}U_j zn5VjNMU1*6eC>LLyD7I&o%Sde`FDeA*OJ@Conr=aN2W)Tvpi2T&u>Lf>k9=ZwXp6O z@1HJ!x6NB;8PW21#X*Dr`5kIPlE-toU`DC_?DcT<(l2pGHZ|A+`fp_4^Rsadz!B2b z0R4C5BySn*zj}umk@4}0ssu=JT7PyXSN^TcQgb}Aq_SVK;8XXZoBut=efScF$D4lmM>Q7x zdNiczZl9!*x!oh@J}K(M=S*{YiloRL(LFOZ^Xgl9WY6@+lfU(d|NBCBylMYGk;K0@ zAaNC*-_BJ(_U~(HPyS}02gtbws6$+ZIZ!g7I=sEngQQ0QR9;@LK+dC5WB~58f-LkP z=@9^BXUD1X<8#NN5=zFFWuga3j{wN4J4L?XQm9NzMWg_Fkn{+E>Ka--lw-IOMQEpg z#>kN>dWiH0fad;gx*JNiqG`H#moMFUQESg}9z8(pV0Fv7-%hro?`&GPa@G*^Ao>5H WSY0wi`9G@w00000VbLPxB-}%1hyzldF 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≶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 Hu0mjf8wy8x 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 36497b9ee3c7b9195ebd5e347b1086a16916433a..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6445 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 Hu0mjf8wy8x 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 0fd24f75514d280d260af15966b1481e5c17a3df..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 7151 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 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 0fd24f75514d280d260af15966b1481e5c17a3df..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 7151 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 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 7f022435b4d6ed6afc0a21833cafa033c374ad7e..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 5630 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 Y19D6tT?PRm^#A|>07*qoM6N<$f_}!GHvj+t 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 7f022435b4d6ed6afc0a21833cafa033c374ad7e..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 5630 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 Y19D6tT?PRm^#A|>07*qoM6N<$f_}!GHvj+t 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 c81384a8078be465b1dc3277234b270e5072f6a6..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 5965 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 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 c81384a8078be465b1dc3277234b270e5072f6a6..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 5965 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 diff --git a/assets/images/holiday/5/ic_launcher.webp b/assets/images/holiday/5/ic_launcher.webp deleted file mode 100644 index 3b00caaa7c2c031f63d7108d632fead267589ea8..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 50994 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 diff --git a/assets/images/holiday/5/icon_broadcast.png b/assets/images/holiday/5/icon_broadcast.png deleted file mode 100644 index 1a0d344fe45cf15824397e78b8cd834077660e26..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1148 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 z 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