Initial commit: 91dj android client dev_1.0.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+193
@@ -0,0 +1,193 @@
|
||||
# Miscellaneous
|
||||
*.class
|
||||
*.log
|
||||
*.pyc
|
||||
*.swp
|
||||
.DS_Store
|
||||
.atom/
|
||||
.buildlog/
|
||||
.history
|
||||
.svn/
|
||||
.properties
|
||||
.flutter-plugins
|
||||
.packages
|
||||
.lock
|
||||
.bin
|
||||
pubspec.look
|
||||
local.properties
|
||||
.packages
|
||||
.flutter-plugins
|
||||
searchFile.js
|
||||
|
||||
|
||||
|
||||
.packages
|
||||
.metadata
|
||||
.fvm/
|
||||
# VSCode 工作区配置纳入版本管理(settings.json 里的排除规则要团队共用),
|
||||
# 其余个人化/临时文件仍然忽略。
|
||||
# 注意:全局 ~/.gitignore_global 也忽略了这两个文件,靠项目级白名单覆盖
|
||||
.vscode/*
|
||||
!.vscode/settings.json
|
||||
!.vscode/launch.json
|
||||
!.vscode/extensions.json
|
||||
zaixiankefu/pubspec.lock
|
||||
# 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
|
||||
**/doc/api/
|
||||
.dart_tool/*
|
||||
.flutter-plugins
|
||||
.packages
|
||||
.pub-cache/
|
||||
.pub/
|
||||
/build/
|
||||
|
||||
|
||||
# Android related
|
||||
# Android related
|
||||
**/android/**/gradle-wrapper.jar
|
||||
**/android/.gradle
|
||||
**/android/captures/
|
||||
**/android/gradlew
|
||||
**/android/gradlew.bat
|
||||
**/android/local.properties
|
||||
**/android/**/GeneratedPluginRegistrant.java
|
||||
**/android/key.properties
|
||||
*.jks
|
||||
*.jks.old
|
||||
**/android/build/
|
||||
|
||||
|
||||
# 针对flutter当做module集成进现有项目时,.android目录下的文件
|
||||
**/.android/**/gradle-wrapper.jar
|
||||
**/.android/.gradle
|
||||
**/.android/captures/
|
||||
**/.android/gradlew
|
||||
**/.android/gradlew.bat
|
||||
**/.android/local.properties
|
||||
**/.android/**/GeneratedPluginRegistrant.java
|
||||
**/.android/key.properties
|
||||
**/*.jks
|
||||
|
||||
|
||||
|
||||
# iOS/XCode related
|
||||
**/ios/**/*.mode1v3
|
||||
**/ios/**/*.mode2v3
|
||||
**/ios/**/*.moved-aside
|
||||
**/ios/**/*.pbxuser
|
||||
**/ios/**/*.perspectivev3
|
||||
**/ios/**/*sync/
|
||||
**/ios/**/.sconsign.dblite
|
||||
**/ios/**/.tags*
|
||||
**/ios/**/.vagrant/
|
||||
**/ios/**/DerivedData/
|
||||
**/ios/**/Icon?
|
||||
**/ios/**/Pods/
|
||||
**/ios/**/.symlinks/
|
||||
**/ios/**/profile
|
||||
**/ios/**/xcuserdata
|
||||
**/ios/.generated/
|
||||
**/ios/Flutter/App.framework
|
||||
**/ios/Flutter/Flutter.framework
|
||||
**/ios/Flutter/Generated.xcconfig
|
||||
**/ios/Flutter/app.flx
|
||||
**/ios/Flutter/app.zip
|
||||
**/ios/Flutter/flutter_assets/
|
||||
**/ios/ServiceDefinitions.json
|
||||
**/ios/Runner/GeneratedPluginRegistrant.*
|
||||
|
||||
|
||||
# 针对flutter当做module集成进现有项目时,.ios目录下的文件
|
||||
**/.ios/**/*.mode1v3
|
||||
**/.ios/**/*.mode2v3
|
||||
**/.ios/**/*.moved-aside
|
||||
**/.ios/**/*.pbxuser
|
||||
**/.ios/**/*.perspectivev3
|
||||
**/.ios/**/*sync/
|
||||
**/.ios/**/.sconsign.dblite
|
||||
**/.ios/**/.tags*
|
||||
**/.ios/**/.vagrant/
|
||||
**/.ios/**/DerivedData/
|
||||
**/.ios/**/Icon?
|
||||
**/.ios/**/Pods/
|
||||
**/.ios/**/.symlinks/
|
||||
**/.ios/**/profile
|
||||
**/.ios/**/xcuserdata
|
||||
**/.ios/.generated/
|
||||
**/.ios/Flutter/App.framework
|
||||
**/.ios/Flutter/Flutter.framework
|
||||
**/.ios/Flutter/Generated.xcconfig
|
||||
**/.ios/Flutter/app.flx
|
||||
**/.ios/Flutter/app.zip
|
||||
**/.ios/Flutter/flutter_assets/
|
||||
**/.ios/ServiceDefinitions.json
|
||||
**/.ios/Runner/GeneratedPluginRegistrant.*
|
||||
|
||||
|
||||
|
||||
|
||||
# macOS
|
||||
**/macos/Flutter/GeneratedPluginRegistrant.swift
|
||||
**/macos/Flutter/Flutter-Debug.xcconfig
|
||||
**/macos/Flutter/Flutter-Release.xcconfig
|
||||
**/macos/Flutter/Flutter-Profile.xcconfig
|
||||
|
||||
|
||||
# Coverage
|
||||
coverage/
|
||||
|
||||
|
||||
# Symbols
|
||||
app.*.symbols
|
||||
|
||||
|
||||
# Exceptions to above rules.
|
||||
!**/ios/**/default.mode1v3
|
||||
!**/ios/**/default.mode2v3
|
||||
!**/ios/**/default.pbxuser
|
||||
!**/ios/**/default.perspectivev3
|
||||
!/packages/flutter_tools/test/data/dart_dependencies_test/**/.packages
|
||||
!/dev/ci/**/Gemfile.lock
|
||||
.packages~92442170bbeb67da2b4cc92142720c48c0d7f490
|
||||
|
||||
/pubspec.lock
|
||||
/.flutter-plugins-dependencies
|
||||
linux/*
|
||||
!ios/Podfile
|
||||
!ios/ExportOptions.plist.example
|
||||
|
||||
m3u8_downloader-master/.dart_tool/
|
||||
m3u8_downloader-master/example/.dart_tool/
|
||||
m3u8_downloader-master/example/.flutter-plugins-dependencies
|
||||
m3u8_downloader-master/example/build/
|
||||
m3u8_downloader-master/*/*.lock
|
||||
m3u8_downloader-master/pubspec.lock
|
||||
dist/
|
||||
|
||||
ume_kit/*/.dart_tool/
|
||||
ume_kit/*/.flutter-plugins-dependencies
|
||||
ume_kit/*/pubspec.lock
|
||||
|
||||
third_party/*/.dart_tool/
|
||||
third_party/*/.flutter-plugins-dependencies
|
||||
third_party/*/pubspec.lock
|
||||
|
||||
|
||||
*.apk
|
||||
ume_kit/
|
||||
|
||||
# Claude Code 本地配置
|
||||
.claude/
|
||||
@@ -0,0 +1,16 @@
|
||||
# 91Dj
|
||||
|
||||
A new Flutter project. 3.16.8
|
||||
|
||||
## Getting Started
|
||||
|
||||
This project is a starting point for a Flutter application.
|
||||
|
||||
A few resources to get you started if this is your first Flutter project:
|
||||
|
||||
- [Lab: Write your first Flutter app](https://docs.flutter.dev/get-started/codelab)
|
||||
- [Cookbook: Useful Flutter samples](https://docs.flutter.dev/cookbook)
|
||||
|
||||
For help getting started with Flutter development, view the
|
||||
[online documentation](https://docs.flutter.dev/), which offers tutorials,
|
||||
samples, guidance on mobile development, and a full API reference.
|
||||
@@ -0,0 +1,35 @@
|
||||
# This file configures the analyzer, which statically analyzes Dart code to
|
||||
# check for errors, warnings, and lints.
|
||||
#
|
||||
# The issues identified by the analyzer are surfaced in the UI of Dart-enabled
|
||||
# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be
|
||||
# invoked from the command line by running `flutter analyze`.
|
||||
|
||||
# The following line activates a set of recommended lints for Flutter apps,
|
||||
# packages, and plugins designed to encourage good coding practices.
|
||||
include: package:flutter_lints/flutter.yaml
|
||||
|
||||
linter:
|
||||
# The lint rules applied to this project can be customized in the
|
||||
# section below to disable rules from the `package:flutter_lints/flutter.yaml`
|
||||
# included above or to enable additional rules. A list of all available lints
|
||||
# and their documentation is published at https://dart.dev/lints.
|
||||
#
|
||||
# Instead of disabling a lint rule for the entire project in the
|
||||
# section below, it can also be suppressed for a single line of code
|
||||
# or a specific dart file by using the `// ignore: name_of_lint` and
|
||||
# `// ignore_for_file: name_of_lint` syntax on the line or in the file
|
||||
# producing the lint.
|
||||
rules:
|
||||
# avoid_print: false # Uncomment to disable the `avoid_print` rule
|
||||
# prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule
|
||||
|
||||
# 禁止提示要把构造函数设为 const
|
||||
prefer_const_constructors: false
|
||||
# 禁止提示要把集合/列表设为 const
|
||||
prefer_const_literals_to_create_immutables: false
|
||||
# 禁止提示在构造函数中使用 const
|
||||
prefer_const_constructors_in_immutables: false
|
||||
|
||||
# Additional information about this file can be found at
|
||||
# https://dart.dev/guides/language/analysis-options
|
||||
@@ -0,0 +1,29 @@
|
||||
# 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/
|
||||
@@ -0,0 +1 @@
|
||||
TODO: Add your license here.
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,4 @@
|
||||
include: package:flutter_lints/flutter.yaml
|
||||
|
||||
# Additional information about this file can be found at
|
||||
# https://dart.dev/guides/language/analysis-options
|
||||
@@ -0,0 +1,9 @@
|
||||
*.iml
|
||||
.gradle
|
||||
/local.properties
|
||||
/.idea/workspace.xml
|
||||
/.idea/libraries
|
||||
.DS_Store
|
||||
/build
|
||||
/captures
|
||||
.cxx
|
||||
@@ -0,0 +1,64 @@
|
||||
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'
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
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
|
||||
@@ -0,0 +1 @@
|
||||
rootProject.name = 'analytics_sdk'
|
||||
@@ -0,0 +1,3 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
package="com.example.analytics_sdk">
|
||||
</manifest>
|
||||
+491
@@ -0,0 +1,491 @@
|
||||
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<String, Any>
|
||||
): Map<String, Any> {
|
||||
val fields = mutableMapOf<String, Any>()
|
||||
|
||||
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<String, Any> {
|
||||
val fields = mutableMapOf<String, Any>(
|
||||
"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<String, Any> {
|
||||
val fields = mutableMapOf<String, Any>(
|
||||
"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<String, Any> {
|
||||
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<String, Any> {
|
||||
val fields = mutableMapOf<String, Any>(
|
||||
"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<String, Any> {
|
||||
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<String, Any>) -> Unit) {
|
||||
val clientIdValue = resolveClientIdRaw()
|
||||
val rawGuid = guid()
|
||||
val fields = mutableMapOf<String, Any>(
|
||||
"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<String, Any> {
|
||||
val fields = mutableMapOf<String, Any>(
|
||||
"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<String, Any> {
|
||||
val fields = mutableMapOf<String, Any>(
|
||||
"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
|
||||
}
|
||||
}
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
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' }
|
||||
}
|
||||
}
|
||||
+80
@@ -0,0 +1,80 @@
|
||||
package com.example.analytics_sdk
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
|
||||
internal class ClientIdentifierResolverTest {
|
||||
@Test
|
||||
fun resolve_returnsFirstValidCandidateAndStopsReading() {
|
||||
val calls = mutableListOf<String>()
|
||||
|
||||
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<String?>(
|
||||
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)
|
||||
}
|
||||
}
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
.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
|
||||
@@ -0,0 +1,320 @@
|
||||
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<ifaddrs>?
|
||||
guard getifaddrs(&addresses) == 0, let firstAddress = addresses else {
|
||||
return false
|
||||
}
|
||||
defer { freeifaddrs(firstAddress) }
|
||||
|
||||
var pointer: UnsafeMutablePointer<ifaddrs>? = 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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
#
|
||||
# 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
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,12 @@
|
||||
import 'package:flutter_web_plugins/flutter_web_plugins.dart';
|
||||
|
||||
/// Analytics SDK 的 Web 平台插件入口。
|
||||
///
|
||||
/// Flutter Web 插件系统要求此文件存在,并实现 [registerWith]。
|
||||
/// SDK 的 Web 实现(设备信息、指纹等)位于 utils/ 目录下的 web 专属文件。
|
||||
class AnalyticsSdkWeb {
|
||||
static void registerWith(Registrar registrar) {
|
||||
// SDK 不使用 MethodChannel,无需注册通道。
|
||||
// Web 平台功能通过条件导入的工具类直接实现。
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
/// API 配置管理类:统一管理所有 API 端点路径
|
||||
class ApiConfig {
|
||||
ApiConfig._();
|
||||
|
||||
// ==================== API 路径(固定不变)====================
|
||||
|
||||
/// 上报接口路径(固定)
|
||||
static const String defaultReportPath = '/api/eventTracking/batchReport.json';
|
||||
|
||||
/// 上报接口路径
|
||||
static String get reportPath => defaultReportPath;
|
||||
|
||||
// ==================== 配置方法 ====================
|
||||
|
||||
/// 构建完整的 URL
|
||||
/// [baseDomain] 基础域名,如 'https://api.example.com'
|
||||
/// [path] API 路径,如 '/api/analytics/report'
|
||||
/// [appId] 应用ID(可选),如果提供会作为查询参数添加到URL中
|
||||
/// 返回完整的 URL,如 'https://api.example.com/api/analytics/report?appId=xxx'
|
||||
static String buildUrl(String baseDomain, String path, {String? appId}) {
|
||||
// 确保路径以 / 开头
|
||||
final normalizedPath = _normalizePath(path);
|
||||
|
||||
// 解析基础域名
|
||||
final uri = Uri.parse(baseDomain);
|
||||
final scheme = uri.scheme.isEmpty ? 'https' : uri.scheme;
|
||||
final host = uri.host.isEmpty ? baseDomain : uri.host;
|
||||
|
||||
// 保留非默认端口(如 :8080),避免 IP+端口 场景下端口丢失
|
||||
final defaultPort = scheme == 'https' ? 443 : 80;
|
||||
final portSuffix =
|
||||
(uri.port > 0 && uri.port != defaultPort) ? ':${uri.port}' : '';
|
||||
|
||||
// 保留 baseDomain 中的路径前缀(如 /v2),仅在 host 成功解析时使用
|
||||
// 去掉末尾 / 避免与 normalizedPath 拼出双斜杠
|
||||
final basePath = uri.host.isNotEmpty && uri.path.isNotEmpty && uri.path != '/'
|
||||
? (uri.path.endsWith('/')
|
||||
? uri.path.substring(0, uri.path.length - 1)
|
||||
: uri.path)
|
||||
: '';
|
||||
|
||||
// 构建完整 URL
|
||||
String url = '$scheme://$host$portSuffix$basePath$normalizedPath';
|
||||
|
||||
// 如果提供了 appId,添加到查询参数
|
||||
if (appId != null && appId.isNotEmpty) {
|
||||
url = _addQueryParameter(url, 'appId', appId);
|
||||
}
|
||||
|
||||
return url;
|
||||
}
|
||||
|
||||
/// 规范化路径:确保路径以 / 开头
|
||||
static String _normalizePath(String path) {
|
||||
if (path.isEmpty) {
|
||||
return '/';
|
||||
}
|
||||
return path.startsWith('/') ? path : '/$path';
|
||||
}
|
||||
|
||||
/// 获取上报接口 URL
|
||||
/// [baseDomain] 基础域名
|
||||
/// [appId] 应用ID(可选,如果提供会作为查询参数)
|
||||
static String getReportUrl(String baseDomain, {String? appId}) {
|
||||
return buildUrl(baseDomain, reportPath, appId: appId);
|
||||
}
|
||||
|
||||
/// 添加查询参数到 URL
|
||||
/// [url] 原始 URL
|
||||
/// [key] 参数名
|
||||
/// [value] 参数值
|
||||
static String _addQueryParameter(String url, String key, String value) {
|
||||
try {
|
||||
final uri = Uri.parse(url);
|
||||
final queryParams = Map<String, String>.from(uri.queryParameters);
|
||||
queryParams[key] = value;
|
||||
return uri.replace(queryParameters: queryParams).toString();
|
||||
} catch (e) {
|
||||
// 如果 URL 解析失败,直接拼接
|
||||
final separator = url.contains('?') ? '&' : '?';
|
||||
return '$url$separator$key=${Uri.encodeComponent(value)}';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,334 @@
|
||||
/// SDK 配置管理类:统一管理所有 SDK 相关的配置常量
|
||||
class SdkConfig {
|
||||
SdkConfig._();
|
||||
|
||||
// ==================== 默认值常量(单一来源)====================
|
||||
// 字段声明和 reset() 均引用这些常量,避免三处重复。
|
||||
|
||||
static const int _defaultMaxQueueSize = 5000;
|
||||
static const int _defaultAutoUploadThreshold = 10;
|
||||
static const int _defaultMaxBatchSize = 500;
|
||||
static const int _defaultIsolateEncodingThreshold = 100;
|
||||
static const Duration _defaultUploadInterval = Duration(seconds: 5);
|
||||
static const Duration _defaultPageLoadTimeout = Duration(seconds: 5);
|
||||
static const Duration _defaultClickThrottleDuration =
|
||||
Duration(milliseconds: 300);
|
||||
static const Duration _defaultConnectionTimeout = Duration(seconds: 10);
|
||||
static const Duration _defaultReadTimeout = Duration(seconds: 30);
|
||||
static const Duration _defaultSpeedTestTimeout = Duration(seconds: 5);
|
||||
static const Duration _defaultConfigRequestTimeout = Duration(seconds: 10);
|
||||
static const int _defaultMaxBackoffLevel = 5;
|
||||
static const Duration _defaultBaseRetryDelay = Duration(seconds: 2);
|
||||
static const Duration _defaultMaxRetryDelay = Duration(seconds: 60);
|
||||
static const String _defaultCacheFileName = 'data_plus_events_cache.jsonl';
|
||||
static const String _defaultDebugReportEventLogFileName =
|
||||
'data_plus_report_events_debug.jsonl';
|
||||
static const bool _defaultEnableDebugReportLog = false;
|
||||
static const String _defaultEventTypeConfigCacheFileName =
|
||||
'analytics_event_types_config.json';
|
||||
static const String _defaultDomainCacheFileName =
|
||||
'data_plus_domains_cache.json';
|
||||
static const int _defaultMaxCacheLines = 2000;
|
||||
static const int _defaultMaxCacheBytes = 2 * 1024 * 1024;
|
||||
static const int _defaultTombstoneCompactionThreshold = 200;
|
||||
static const String _defaultTombstoneFileName =
|
||||
'data_plus_events_tombstone.txt';
|
||||
static const int _defaultMaxJsonSize = 5 * 1024 * 1024;
|
||||
static const int _defaultMaxSingleEventSize = 300 * 1024;
|
||||
static const int _defaultMaxConfigResponseSize = 1 * 1024 * 1024;
|
||||
static const int _defaultMaxPageStackSize = 100;
|
||||
static const int _defaultMaxAdImpressionCapacity = 10000;
|
||||
static const int _defaultCacheBufferThreshold = 10;
|
||||
static const Duration _defaultCacheFlushInterval = Duration(seconds: 5);
|
||||
|
||||
// ==================== 事件队列配置 ====================
|
||||
|
||||
/// 事件队列最大容量限制,防止内存无限增长
|
||||
static int maxQueueSize = _defaultMaxQueueSize;
|
||||
|
||||
/// 自动上报阈值:队列达到此数量时立即触发上报
|
||||
static int autoUploadThreshold = _defaultAutoUploadThreshold;
|
||||
|
||||
/// 单次上报的最大事件数量限制,避免请求过大导致超时
|
||||
static int maxBatchSize = _defaultMaxBatchSize;
|
||||
|
||||
/// JSON 编码的批次大小阈值,超过此大小使用 isolate 编码
|
||||
static int isolateEncodingThreshold = _defaultIsolateEncodingThreshold;
|
||||
|
||||
// ==================== 定时器配置 ====================
|
||||
|
||||
/// 定时上报间隔(秒)
|
||||
static Duration uploadInterval = _defaultUploadInterval;
|
||||
|
||||
/// 页面加载超时时间(秒)
|
||||
static Duration pageLoadTimeout = _defaultPageLoadTimeout;
|
||||
|
||||
/// 点击事件节流时间(毫秒)
|
||||
static Duration clickThrottleDuration = _defaultClickThrottleDuration;
|
||||
|
||||
// ==================== 网络超时配置 ====================
|
||||
|
||||
/// HTTP 连接超时时间
|
||||
static Duration connectionTimeout = _defaultConnectionTimeout;
|
||||
|
||||
/// HTTP 读取超时时间
|
||||
static Duration readTimeout = _defaultReadTimeout;
|
||||
|
||||
/// HTTP 写入超时时间(与连接超时相同)
|
||||
static Duration get writeTimeout => connectionTimeout;
|
||||
|
||||
/// HTTP 总超时时间(连接 + 写入 + 读取)
|
||||
static Duration get totalRequestTimeout =>
|
||||
connectionTimeout + writeTimeout + readTimeout;
|
||||
|
||||
/// 域名测速超时时间
|
||||
static Duration speedTestTimeout = _defaultSpeedTestTimeout;
|
||||
|
||||
/// 配置请求超时时间
|
||||
static Duration configRequestTimeout = _defaultConfigRequestTimeout;
|
||||
|
||||
// ==================== 加密配置 ====================
|
||||
|
||||
/// AES-GCM 解密密钥(固定密钥,用于 decryptResponseAuto 等)
|
||||
static const String decryptKey =
|
||||
'en1BNo0VBrN/zi+mI2LO7E9W40ehCBYwC+frBn8s3rQ';
|
||||
|
||||
// ==================== 重试配置 ====================
|
||||
|
||||
/// 最大退避级别
|
||||
static int maxBackoffLevel = _defaultMaxBackoffLevel;
|
||||
|
||||
/// 基础延迟时间
|
||||
static Duration baseRetryDelay = _defaultBaseRetryDelay;
|
||||
|
||||
/// 最大延迟时间
|
||||
static Duration maxRetryDelay = _defaultMaxRetryDelay;
|
||||
|
||||
// ==================== 缓存配置 ====================
|
||||
|
||||
/// 事件缓存文件名
|
||||
static String cacheFileName = _defaultCacheFileName;
|
||||
|
||||
/// Debug 包完整事件 JSONL 文件名(Native 平台 applicationDocumentsDirectory 下)
|
||||
static String debugReportEventLogFileName =
|
||||
_defaultDebugReportEventLogFileName;
|
||||
|
||||
/// 是否启用调试事件日志。
|
||||
///
|
||||
/// 默认关闭;开启后才会本地记录上传事件完整 JSONL,并打印 accepted event 完整 JSON。
|
||||
static bool enableDebugReportLog = _defaultEnableDebugReportLog;
|
||||
|
||||
/// 事件类型配置缓存文件名
|
||||
static String eventTypeConfigCacheFileName =
|
||||
_defaultEventTypeConfigCacheFileName;
|
||||
|
||||
/// 域名列表缓存文件名
|
||||
static String domainCacheFileName = _defaultDomainCacheFileName;
|
||||
|
||||
/// 最大缓存行数限制,防止文件过大导致内存问题
|
||||
/// 默认 2000 行,避免在网络不稳定时生成过大的缓存文件
|
||||
static int maxCacheLines = _defaultMaxCacheLines;
|
||||
|
||||
/// 缓存文件最大字节数限制,超过后从文件开头裁剪旧事件
|
||||
/// 默认 2MB
|
||||
static int maxCacheBytes = _defaultMaxCacheBytes;
|
||||
|
||||
/// tombstone 触发压缩的 event_id 数量阈值
|
||||
/// 默认 200:每积累 200 条已上报 ID 就压缩一次主缓存文件
|
||||
static int tombstoneCompactionThreshold =
|
||||
_defaultTombstoneCompactionThreshold;
|
||||
|
||||
/// tombstone 缓存文件名
|
||||
static String tombstoneFileName = _defaultTombstoneFileName;
|
||||
|
||||
/// 持久化 buffer 攒批阈值(达到此数量立即写盘)
|
||||
static int cacheBufferThreshold = _defaultCacheBufferThreshold;
|
||||
|
||||
/// 持久化 buffer 定时刷写间隔
|
||||
static Duration cacheFlushInterval = _defaultCacheFlushInterval;
|
||||
|
||||
// ==================== 请求大小限制 ====================
|
||||
|
||||
/// JSON 最大大小限制(5MB)
|
||||
static int maxJsonSize = _defaultMaxJsonSize;
|
||||
|
||||
/// 单条事件最大字节数限制,超过则丢弃(300KB)
|
||||
static int maxSingleEventSize = _defaultMaxSingleEventSize;
|
||||
|
||||
/// 配置接口响应体最大字节数限制(1MB),防止恶意/异常服务器返回超大数据导致 OOM
|
||||
static int maxConfigResponseSize = _defaultMaxConfigResponseSize;
|
||||
|
||||
// ==================== 容量配置 ====================
|
||||
|
||||
/// 页面栈最大容量
|
||||
static int maxPageStackSize = _defaultMaxPageStackSize;
|
||||
|
||||
/// 广告去重最大容量
|
||||
static int maxAdImpressionCapacity = _defaultMaxAdImpressionCapacity;
|
||||
|
||||
// ==================== 配置方法 ====================
|
||||
|
||||
/// 配置所有 SDK 参数
|
||||
///
|
||||
/// 示例:
|
||||
/// ```dart
|
||||
/// SdkConfig.configure(
|
||||
/// maxQueueSize: 20000,
|
||||
/// uploadInterval: Duration(seconds: 30),
|
||||
/// connectionTimeout: Duration(seconds: 15),
|
||||
/// );
|
||||
/// ```
|
||||
static void configure({
|
||||
int? maxQueueSize,
|
||||
int? autoUploadThreshold,
|
||||
int? maxBatchSize,
|
||||
int? isolateEncodingThreshold,
|
||||
Duration? uploadInterval,
|
||||
Duration? pageLoadTimeout,
|
||||
Duration? clickThrottleDuration,
|
||||
Duration? connectionTimeout,
|
||||
Duration? readTimeout,
|
||||
Duration? speedTestTimeout,
|
||||
Duration? configRequestTimeout,
|
||||
int? maxBackoffLevel,
|
||||
Duration? baseRetryDelay,
|
||||
Duration? maxRetryDelay,
|
||||
String? cacheFileName,
|
||||
String? debugReportEventLogFileName,
|
||||
bool? enableDebugReportLog,
|
||||
String? eventTypeConfigCacheFileName,
|
||||
String? domainCacheFileName,
|
||||
int? maxCacheLines,
|
||||
int? maxCacheBytes,
|
||||
int? maxJsonSize,
|
||||
int? maxSingleEventSize,
|
||||
int? maxPageStackSize,
|
||||
int? maxAdImpressionCapacity,
|
||||
}) {
|
||||
if (maxQueueSize != null && maxQueueSize > 0) {
|
||||
SdkConfig.maxQueueSize = maxQueueSize;
|
||||
}
|
||||
if (autoUploadThreshold != null && autoUploadThreshold > 0) {
|
||||
SdkConfig.autoUploadThreshold = autoUploadThreshold;
|
||||
}
|
||||
if (maxBatchSize != null && maxBatchSize > 0) {
|
||||
SdkConfig.maxBatchSize = maxBatchSize;
|
||||
}
|
||||
if (isolateEncodingThreshold != null && isolateEncodingThreshold > 0) {
|
||||
SdkConfig.isolateEncodingThreshold = isolateEncodingThreshold;
|
||||
}
|
||||
if (uploadInterval != null) {
|
||||
SdkConfig.uploadInterval = uploadInterval;
|
||||
}
|
||||
if (pageLoadTimeout != null) {
|
||||
SdkConfig.pageLoadTimeout = pageLoadTimeout;
|
||||
}
|
||||
if (clickThrottleDuration != null) {
|
||||
SdkConfig.clickThrottleDuration = clickThrottleDuration;
|
||||
}
|
||||
if (connectionTimeout != null) {
|
||||
SdkConfig.connectionTimeout = connectionTimeout;
|
||||
}
|
||||
if (readTimeout != null) {
|
||||
SdkConfig.readTimeout = readTimeout;
|
||||
}
|
||||
if (speedTestTimeout != null) {
|
||||
SdkConfig.speedTestTimeout = speedTestTimeout;
|
||||
}
|
||||
if (configRequestTimeout != null) {
|
||||
SdkConfig.configRequestTimeout = configRequestTimeout;
|
||||
}
|
||||
if (maxBackoffLevel != null && maxBackoffLevel >= 0) {
|
||||
SdkConfig.maxBackoffLevel = maxBackoffLevel;
|
||||
}
|
||||
if (baseRetryDelay != null) {
|
||||
SdkConfig.baseRetryDelay = baseRetryDelay;
|
||||
}
|
||||
if (maxRetryDelay != null) {
|
||||
SdkConfig.maxRetryDelay = maxRetryDelay;
|
||||
}
|
||||
if (cacheFileName != null &&
|
||||
cacheFileName.isNotEmpty &&
|
||||
!cacheFileName.contains('/') &&
|
||||
!cacheFileName.contains('\\') &&
|
||||
!cacheFileName.contains('..')) {
|
||||
SdkConfig.cacheFileName = cacheFileName;
|
||||
}
|
||||
if (debugReportEventLogFileName != null &&
|
||||
debugReportEventLogFileName.isNotEmpty &&
|
||||
!debugReportEventLogFileName.contains('/') &&
|
||||
!debugReportEventLogFileName.contains('\\') &&
|
||||
!debugReportEventLogFileName.contains('..')) {
|
||||
SdkConfig.debugReportEventLogFileName = debugReportEventLogFileName;
|
||||
}
|
||||
if (enableDebugReportLog != null) {
|
||||
SdkConfig.enableDebugReportLog = enableDebugReportLog;
|
||||
}
|
||||
if (eventTypeConfigCacheFileName != null &&
|
||||
eventTypeConfigCacheFileName.isNotEmpty &&
|
||||
!eventTypeConfigCacheFileName.contains('/') &&
|
||||
!eventTypeConfigCacheFileName.contains('\\') &&
|
||||
!eventTypeConfigCacheFileName.contains('..')) {
|
||||
SdkConfig.eventTypeConfigCacheFileName = eventTypeConfigCacheFileName;
|
||||
}
|
||||
if (domainCacheFileName != null &&
|
||||
domainCacheFileName.isNotEmpty &&
|
||||
!domainCacheFileName.contains('/') &&
|
||||
!domainCacheFileName.contains('\\') &&
|
||||
!domainCacheFileName.contains('..')) {
|
||||
SdkConfig.domainCacheFileName = domainCacheFileName;
|
||||
}
|
||||
if (maxCacheLines != null && maxCacheLines > 0) {
|
||||
SdkConfig.maxCacheLines = maxCacheLines;
|
||||
}
|
||||
if (maxCacheBytes != null && maxCacheBytes > 0) {
|
||||
SdkConfig.maxCacheBytes = maxCacheBytes;
|
||||
}
|
||||
if (maxJsonSize != null && maxJsonSize > 0) {
|
||||
SdkConfig.maxJsonSize = maxJsonSize;
|
||||
}
|
||||
if (maxSingleEventSize != null && maxSingleEventSize > 0) {
|
||||
SdkConfig.maxSingleEventSize = maxSingleEventSize;
|
||||
}
|
||||
if (maxPageStackSize != null && maxPageStackSize > 0) {
|
||||
SdkConfig.maxPageStackSize = maxPageStackSize;
|
||||
}
|
||||
if (maxAdImpressionCapacity != null && maxAdImpressionCapacity > 0) {
|
||||
SdkConfig.maxAdImpressionCapacity = maxAdImpressionCapacity;
|
||||
}
|
||||
}
|
||||
|
||||
/// 重置所有配置为默认值
|
||||
static void reset() {
|
||||
maxQueueSize = _defaultMaxQueueSize;
|
||||
autoUploadThreshold = _defaultAutoUploadThreshold;
|
||||
maxBatchSize = _defaultMaxBatchSize;
|
||||
isolateEncodingThreshold = _defaultIsolateEncodingThreshold;
|
||||
uploadInterval = _defaultUploadInterval;
|
||||
pageLoadTimeout = _defaultPageLoadTimeout;
|
||||
clickThrottleDuration = _defaultClickThrottleDuration;
|
||||
connectionTimeout = _defaultConnectionTimeout;
|
||||
readTimeout = _defaultReadTimeout;
|
||||
speedTestTimeout = _defaultSpeedTestTimeout;
|
||||
configRequestTimeout = _defaultConfigRequestTimeout;
|
||||
maxBackoffLevel = _defaultMaxBackoffLevel;
|
||||
baseRetryDelay = _defaultBaseRetryDelay;
|
||||
maxRetryDelay = _defaultMaxRetryDelay;
|
||||
cacheFileName = _defaultCacheFileName;
|
||||
debugReportEventLogFileName = _defaultDebugReportEventLogFileName;
|
||||
enableDebugReportLog = _defaultEnableDebugReportLog;
|
||||
eventTypeConfigCacheFileName = _defaultEventTypeConfigCacheFileName;
|
||||
domainCacheFileName = _defaultDomainCacheFileName;
|
||||
maxCacheLines = _defaultMaxCacheLines;
|
||||
maxCacheBytes = _defaultMaxCacheBytes;
|
||||
tombstoneCompactionThreshold = _defaultTombstoneCompactionThreshold;
|
||||
tombstoneFileName = _defaultTombstoneFileName;
|
||||
cacheBufferThreshold = _defaultCacheBufferThreshold;
|
||||
cacheFlushInterval = _defaultCacheFlushInterval;
|
||||
maxJsonSize = _defaultMaxJsonSize;
|
||||
maxSingleEventSize = _defaultMaxSingleEventSize;
|
||||
maxConfigResponseSize = _defaultMaxConfigResponseSize;
|
||||
maxPageStackSize = _defaultMaxPageStackSize;
|
||||
maxAdImpressionCapacity = _defaultMaxAdImpressionCapacity;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
// 如果需要其他 UUID 生成,保留;但 eventId 现在固定,不再使用
|
||||
|
||||
/// 事件类型枚举:定义所有可能的事件类型
|
||||
/// 每个枚举值存储固定的 event 字符串
|
||||
/// 同时覆盖客户端、服务端上报的所有事件,方便统一管理
|
||||
enum EventType {
|
||||
// ==================== 安装 & 用户 ====================
|
||||
|
||||
/// App 安装事件(客户端)
|
||||
appInstall(event: 'app_install'),
|
||||
|
||||
/// SDK 初始化快照事件(客户端自动)
|
||||
sdkInit(event: 'sdk_init'),
|
||||
|
||||
/// 应用生命周期事件(客户端自动)
|
||||
appLifecycle(event: 'app_lifecycle'),
|
||||
|
||||
/// 网络状态变化事件(客户端自动)
|
||||
networkStateChange(event: 'network_state_change'),
|
||||
|
||||
/// 会话汇总事件(客户端自动)
|
||||
appSessionSummary(event: 'app_session_summary'),
|
||||
|
||||
/// 用户注册事件(服务端)
|
||||
userRegister(event: 'user_register'),
|
||||
|
||||
/// 用户登录事件(服务端)
|
||||
userLogin(event: 'user_login'),
|
||||
|
||||
/// 实时在线人数(服务端)
|
||||
realtimeOnline(event: 'realtime_online'),
|
||||
|
||||
// ==================== 订单 & 金币 ====================
|
||||
|
||||
/// 订单创建事件(服务端)
|
||||
orderCreated(event: 'order_created'),
|
||||
|
||||
/// 订单支付成功事件(服务端)
|
||||
orderPaid(event: 'order_paid'),
|
||||
|
||||
/// 金币消耗事件(服务端)
|
||||
coinConsume(event: 'coin_consume'),
|
||||
|
||||
/// 导航模块点击事件
|
||||
navigation(event: 'navigation'),
|
||||
|
||||
/// 应用页面展示
|
||||
appPageView(event: 'app_page_view'),
|
||||
|
||||
/// 应用页面点击
|
||||
pageClick(event: 'page_click'),
|
||||
|
||||
/// 页面内元素曝光事件(客户端)
|
||||
appPageItemExposure(event: 'app_pageitem_exposure'),
|
||||
|
||||
/// 页面内元素点击事件(客户端)
|
||||
appPageItemClick(event: 'app_pageitem_click'),
|
||||
|
||||
/// 广告事件
|
||||
advertising(event: 'advertising'),
|
||||
|
||||
/// 广告点击事件
|
||||
adClick(event: 'ad_click'),
|
||||
|
||||
/// 页面存活
|
||||
pageLifecycle(event: 'page_lifecycle'),
|
||||
|
||||
/// 视频事件
|
||||
videoEvent(event: 'video_event'),
|
||||
|
||||
/// 广告展示
|
||||
adImpression(event: 'ad_impression'),
|
||||
|
||||
// ==================== 广告 SDK 生命周期事件 ====================
|
||||
|
||||
/// 广告 SDK 初始化
|
||||
adSdkInit(event: 'ad_sdk_init'),
|
||||
|
||||
/// 广告请求
|
||||
adRequest(event: 'ad_request'),
|
||||
|
||||
/// 广告填充成功
|
||||
adFill(event: 'ad_fill'),
|
||||
|
||||
/// 广告无填充
|
||||
adNoFill(event: 'ad_no_fill'),
|
||||
|
||||
/// 广告素材加载成功
|
||||
adLoadSuccess(event: 'ad_load_success'),
|
||||
|
||||
/// 广告素材加载失败。
|
||||
@Deprecated('ad_load_fail 已并入 ad_error,使用 MATERIAL_LOAD_FAIL 错误码。')
|
||||
adLoadFail(event: 'ad_load_fail'),
|
||||
|
||||
/// 广告 SDK 曝光
|
||||
adImpressionSdk(event: 'ad_impression_sdk'),
|
||||
|
||||
/// 广告 SDK 点击
|
||||
adClickSdk(event: 'ad_click_sdk'),
|
||||
|
||||
/// 广告关闭
|
||||
adClose(event: 'ad_close'),
|
||||
|
||||
/// 广告跳过
|
||||
adSkip(event: 'ad_skip'),
|
||||
|
||||
/// 广告错误
|
||||
adError(event: 'ad_error'),
|
||||
|
||||
/// 视频点赞事件
|
||||
videoLike(event: 'video_like'),
|
||||
|
||||
/// 小说事件
|
||||
novelEvent(event: 'novel_event'),
|
||||
|
||||
/// 小说点赞事件
|
||||
novelLike(event: 'novel_like'),
|
||||
|
||||
/// 漫画事件
|
||||
comicEvent(event: 'comic_event'),
|
||||
|
||||
/// 漫画点赞事件
|
||||
comicLike(event: 'comic_like'),
|
||||
|
||||
/// 关键词搜索事件
|
||||
keywordSearch(event: 'keyword_search'),
|
||||
|
||||
/// 关键词搜索结果点击事件
|
||||
keywordClick(event: 'keyword_click'),
|
||||
|
||||
/// 小说收藏事件
|
||||
novelCollect(event: 'novel_collect'),
|
||||
|
||||
/// 漫画收藏事件
|
||||
comicCollect(event: 'comic_collect'),
|
||||
|
||||
// ==================== 推荐引擎相关 ====================
|
||||
|
||||
/// 推荐列表展示事件(服务端/客户端)
|
||||
recommendListView(event: 'recommend_list_view'),
|
||||
|
||||
/// 推荐列表点击事件(服务端/客户端)
|
||||
recommendListClick(event: 'recommend_list_click'),
|
||||
|
||||
// ==================== 视频相关扩展事件 ====================
|
||||
|
||||
/// 视频评论事件(服务端/客户端)
|
||||
videoComment(event: 'video_comment'),
|
||||
|
||||
/// 视频收藏事件(服务端/客户端)
|
||||
videoCollect(event: 'video_collect'),
|
||||
|
||||
/// 视频购买事件(服务端/客户端)
|
||||
videoPurchase(event: 'video_purchase'),
|
||||
|
||||
/// 视频状态更改事件(服务端)
|
||||
videoStatusChange(event: 'video_status_change'),
|
||||
|
||||
// ==================== 小说相关扩展事件 ====================
|
||||
|
||||
/// 小说评论事件(服务端/客户端)
|
||||
novelComment(event: 'novel_comment'),
|
||||
|
||||
/// 小说购买事件(服务端/客户端)
|
||||
novelPurchase(event: 'novel_purchase'),
|
||||
|
||||
// ==================== 漫画相关扩展事件 ====================
|
||||
|
||||
/// 漫画评论事件(服务端/客户端)
|
||||
comicComment(event: 'comic_comment'),
|
||||
|
||||
/// 漫画购买事件(服务端/客户端)
|
||||
comicPurchase(event: 'comic_purchase'),
|
||||
;
|
||||
|
||||
/// 事件类型字符串
|
||||
final String event;
|
||||
|
||||
const EventType({required this.event});
|
||||
|
||||
/// 静态方法:根据 event 字符串获取枚举值(如果存在)
|
||||
static EventType? fromEvent(String event) {
|
||||
try {
|
||||
return EventType.values.firstWhere((e) => e.event == event);
|
||||
} catch (_) {
|
||||
return null; // 或 throw ArgumentError('Unknown event: $event')
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import 'package:analytics_sdk/manager/page_name_manager.dart';
|
||||
import 'package:analytics_sdk/const/event_type.dart';
|
||||
import 'package:analytics_sdk/manager/sdk_context.dart';
|
||||
|
||||
/// 广告点击日志 (手动上报)
|
||||
class AdClickEvent {
|
||||
/// 事件类型(固定)
|
||||
final String event = EventType.adClick.event;
|
||||
|
||||
/// 去重 ID(动态计算)
|
||||
late final String eventId;
|
||||
|
||||
/// 页面唯一标识,建议使用 [AppPageEnum](如 `AppPageEnum.videoPlay.key`),
|
||||
/// 也可传入自定义字符串。
|
||||
final String pageKey;
|
||||
|
||||
/// 页面展示名称,建议与 [pageKey] 保持对应(如 `AppPageEnum.videoPlay.name`)。
|
||||
final String pageName;
|
||||
|
||||
/// 广告位标识:与展示事件一致,如 home_banner_1
|
||||
final String adSlotKey;
|
||||
|
||||
/// 被点击的广告ID
|
||||
final String adId;
|
||||
|
||||
/// 素材ID(可选)
|
||||
final String creativeId;
|
||||
|
||||
/// 广告类型字符串,由广告中心定义,如 banner, feed 等
|
||||
final String adType;
|
||||
|
||||
/// 公共字段(通过工具类生成)
|
||||
late final Map<String, dynamic> _commonFields;
|
||||
|
||||
AdClickEvent({
|
||||
required String pageKey,
|
||||
required this.pageName,
|
||||
required this.adSlotKey,
|
||||
required this.adId,
|
||||
this.creativeId = '',
|
||||
this.adType = '',
|
||||
}) : pageKey = PageNameMapper.normalizeKey(pageKey) {
|
||||
_commonFields = SdkContext.generateCommonFields(event);
|
||||
|
||||
eventId = SdkContext.generateEventIdFromCommonFields(_commonFields, [
|
||||
pageKey,
|
||||
pageName,
|
||||
adSlotKey,
|
||||
adId,
|
||||
adType,
|
||||
creativeId,
|
||||
]);
|
||||
}
|
||||
|
||||
/// 转换为 JSON Map,用于上报和缓存
|
||||
Map<String, dynamic> toJson() {
|
||||
Map<String, dynamic> payload = {
|
||||
'page_key': pageKey,
|
||||
'page_name': pageName,
|
||||
'ad_slot_key': adSlotKey,
|
||||
'ad_id': adId,
|
||||
'ad_type': adType,
|
||||
'creative_id': creativeId,
|
||||
};
|
||||
return {..._commonFields, 'event_id': eventId, "payload": payload};
|
||||
}
|
||||
|
||||
/// 从 JSON 恢复实例(用于缓存恢复)
|
||||
factory AdClickEvent.fromJson(Map<String, dynamic> json) {
|
||||
return AdClickEvent(
|
||||
pageKey: json['page_key'] as String,
|
||||
pageName: json['page_name'] as String,
|
||||
adSlotKey: json['ad_slot_key'] as String,
|
||||
adId: json['ad_id'] as String,
|
||||
creativeId: json['creative_id'] as String? ?? '',
|
||||
adType: json['ad_type'] as String? ?? '',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import 'package:analytics_sdk/manager/page_name_manager.dart';
|
||||
import 'package:analytics_sdk/const/event_type.dart';
|
||||
import 'package:analytics_sdk/manager/sdk_context.dart';
|
||||
|
||||
/// 广告展示日志 (手动)
|
||||
/// 展示上报逻辑:
|
||||
/// 1. 进入 APP → TabA
|
||||
/// - 20 条曝光 → 上报 20 条(批量)
|
||||
/// 2. 切换到TabB
|
||||
/// - 20 条曝光 → 上报 20 条
|
||||
/// 3. 返回TabA → 上滑加载 10 条
|
||||
/// - 新 10 条渲染出来 → 上报 10 条
|
||||
/// 4. 用户再次上下滑动TabA
|
||||
/// - 所有广告已曝光,无需再上报
|
||||
|
||||
class AdImpressionEvent {
|
||||
final String event = EventType.adImpression.event;
|
||||
late final String eventId;
|
||||
|
||||
/// 页面唯一标识,建议使用 [AppPageEnum](如 `AppPageEnum.home.key`),
|
||||
/// 也可传入自定义字符串。
|
||||
final String pageKey;
|
||||
|
||||
/// 页面展示名称,建议与 [pageKey] 保持对应(如 `AppPageEnum.home.name`)。
|
||||
final String pageName;
|
||||
|
||||
/// 广告位标识:如 home_banner_1等 对应广告导入数据的advertiseLocationCode
|
||||
final String adSlotKey;
|
||||
|
||||
/// 本次广告请求唯一 ID。
|
||||
final String requestId;
|
||||
|
||||
/// 广告ID, 多个广告ID英文逗号分隔 对应广告导入数据的advertiseCode
|
||||
final String adId;
|
||||
|
||||
/// 实际素材尺寸,格式如 "1080x1920"。
|
||||
final String materialSize;
|
||||
|
||||
/// 素材ID(可选)多个素材ID英文逗号分隔,和广告ID一一对应,没有的填充空字符串
|
||||
final String creativeId;
|
||||
|
||||
/// 广告类型字符串,如 banner, feed, interstitial, reward_video 等,由广告中心定义
|
||||
final String adType;
|
||||
|
||||
late final Map<String, dynamic> _commonFields;
|
||||
|
||||
AdImpressionEvent({
|
||||
required String pageKey,
|
||||
required this.pageName,
|
||||
required this.adSlotKey,
|
||||
this.requestId = '',
|
||||
required this.adId,
|
||||
this.materialSize = '',
|
||||
this.creativeId = '',
|
||||
this.adType = '',
|
||||
}) : pageKey = PageNameMapper.normalizeKey(pageKey) {
|
||||
_commonFields = SdkContext.generateCommonFields(event);
|
||||
|
||||
eventId = SdkContext.generateEventIdFromCommonFields(_commonFields, [
|
||||
pageKey,
|
||||
pageName,
|
||||
adSlotKey,
|
||||
requestId,
|
||||
adId,
|
||||
adType,
|
||||
materialSize,
|
||||
creativeId,
|
||||
]);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
Map<String, dynamic> payload = {
|
||||
'page_key': pageKey,
|
||||
'page_name': pageName,
|
||||
'request_id': requestId,
|
||||
'ad_slot_key': adSlotKey,
|
||||
'ad_id': adId,
|
||||
'ad_type': adType,
|
||||
'material_size': materialSize,
|
||||
'creative_id': creativeId,
|
||||
};
|
||||
return {..._commonFields, 'event_id': eventId, "payload": payload};
|
||||
}
|
||||
|
||||
factory AdImpressionEvent.fromJson(Map<String, dynamic> json) {
|
||||
return AdImpressionEvent(
|
||||
pageKey: json['page_key'] as String,
|
||||
pageName: json['page_name'] as String,
|
||||
adSlotKey: json['ad_slot_key'] as String,
|
||||
requestId: json['request_id'] as String? ?? '',
|
||||
adId: json['ad_id'] as String,
|
||||
materialSize: json['material_size'] as String? ?? '',
|
||||
creativeId: json['creative_id'] as String? ?? '',
|
||||
adType: json['ad_type'] as String? ?? '',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,459 @@
|
||||
import 'package:analytics_sdk/const/event_type.dart';
|
||||
import 'package:analytics_sdk/manager/sdk_context.dart';
|
||||
|
||||
abstract class _AdSdkEvent {
|
||||
final String event;
|
||||
|
||||
late final String eventId;
|
||||
late final Map<String, dynamic> _commonFields;
|
||||
|
||||
_AdSdkEvent(this.event, List<String> eventIdFields) {
|
||||
_commonFields = SdkContext.generateCommonFields(event);
|
||||
eventId = SdkContext.generateEventIdFromCommonFields(
|
||||
_commonFields,
|
||||
eventIdFields,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> buildPayload();
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
..._commonFields,
|
||||
'event_id': eventId,
|
||||
'payload': buildPayload(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// 广告 SDK 初始化事件。
|
||||
class AdSdkInitEvent extends _AdSdkEvent {
|
||||
final int screenWidth;
|
||||
final int screenHeight;
|
||||
final int screenDpi;
|
||||
final String orientation;
|
||||
final String networkType;
|
||||
final String carrier;
|
||||
final String language;
|
||||
final String timezone;
|
||||
final String oaid;
|
||||
final String androidId;
|
||||
final String idfv;
|
||||
final String idfa;
|
||||
final bool lat;
|
||||
final String adSdkVersion;
|
||||
final int availableStorage;
|
||||
final int batteryLevel;
|
||||
final String cpu;
|
||||
final int storage;
|
||||
final String activationTime;
|
||||
|
||||
AdSdkInitEvent({
|
||||
this.screenWidth = 0,
|
||||
this.screenHeight = 0,
|
||||
this.screenDpi = 0,
|
||||
this.orientation = '',
|
||||
this.networkType = '',
|
||||
this.carrier = '',
|
||||
this.language = '',
|
||||
this.timezone = '',
|
||||
this.oaid = '',
|
||||
this.androidId = '',
|
||||
this.idfv = '',
|
||||
this.idfa = '',
|
||||
this.lat = false,
|
||||
required this.adSdkVersion,
|
||||
this.availableStorage = 0,
|
||||
this.batteryLevel = 0,
|
||||
this.cpu = '',
|
||||
this.storage = 0,
|
||||
this.activationTime = '',
|
||||
}) : super(EventType.adSdkInit.event, [
|
||||
screenWidth.toString(),
|
||||
screenHeight.toString(),
|
||||
screenDpi.toString(),
|
||||
orientation,
|
||||
networkType,
|
||||
carrier,
|
||||
language,
|
||||
timezone,
|
||||
oaid,
|
||||
androidId,
|
||||
idfv,
|
||||
idfa,
|
||||
lat.toString(),
|
||||
adSdkVersion,
|
||||
availableStorage.toString(),
|
||||
batteryLevel.toString(),
|
||||
cpu,
|
||||
storage.toString(),
|
||||
activationTime,
|
||||
]);
|
||||
|
||||
@override
|
||||
Map<String, dynamic> buildPayload() => {
|
||||
'screen_width': screenWidth,
|
||||
'screen_height': screenHeight,
|
||||
'screen_dpi': screenDpi,
|
||||
'orientation': orientation,
|
||||
'network_type': networkType,
|
||||
'carrier': carrier,
|
||||
'language': language,
|
||||
'timezone': timezone,
|
||||
'oaid': oaid,
|
||||
'android_id': androidId,
|
||||
'idfv': idfv,
|
||||
'idfa': idfa,
|
||||
'lat': lat,
|
||||
'ad_sdk_version': adSdkVersion,
|
||||
'available_storage': availableStorage,
|
||||
'battery_level': batteryLevel,
|
||||
'cpu': cpu,
|
||||
'storage': storage,
|
||||
'activation_time': activationTime,
|
||||
};
|
||||
}
|
||||
|
||||
/// 广告请求事件。
|
||||
class AdRequestEvent extends _AdSdkEvent {
|
||||
final String requestId;
|
||||
final String adSlotKey;
|
||||
final String adType;
|
||||
final String? adId;
|
||||
|
||||
AdRequestEvent({
|
||||
required this.requestId,
|
||||
required this.adSlotKey,
|
||||
this.adType = '',
|
||||
this.adId,
|
||||
}) : super(EventType.adRequest.event, [
|
||||
requestId,
|
||||
adSlotKey,
|
||||
adType,
|
||||
adId ?? '',
|
||||
]);
|
||||
|
||||
@override
|
||||
Map<String, dynamic> buildPayload() => {
|
||||
'request_id': requestId,
|
||||
'ad_slot_key': adSlotKey,
|
||||
'ad_type': adType,
|
||||
if (adId?.trim().isNotEmpty ?? false) 'ad_id': adId!.trim(),
|
||||
};
|
||||
}
|
||||
|
||||
/// 广告填充成功事件。
|
||||
class AdFillEvent extends _AdSdkEvent {
|
||||
final String traceId;
|
||||
final String requestId;
|
||||
final String adSlotKey;
|
||||
final String adId;
|
||||
final String adType;
|
||||
final int fillCount;
|
||||
|
||||
AdFillEvent({
|
||||
this.traceId = '',
|
||||
required this.requestId,
|
||||
required this.adSlotKey,
|
||||
required this.adId,
|
||||
required this.adType,
|
||||
required this.fillCount,
|
||||
}) : super(EventType.adFill.event, [
|
||||
if (traceId.isNotEmpty) traceId,
|
||||
requestId,
|
||||
adSlotKey,
|
||||
adId,
|
||||
adType,
|
||||
fillCount.toString(),
|
||||
]);
|
||||
|
||||
@override
|
||||
Map<String, dynamic> buildPayload() => {
|
||||
'trace_id': traceId,
|
||||
'request_id': requestId,
|
||||
'ad_slot_key': adSlotKey,
|
||||
'ad_id': adId,
|
||||
'ad_type': adType,
|
||||
'fill_count': fillCount,
|
||||
};
|
||||
}
|
||||
|
||||
/// 广告无填充事件。
|
||||
class AdNoFillEvent extends _AdSdkEvent {
|
||||
final String requestId;
|
||||
final String adSlotKey;
|
||||
final String adType;
|
||||
final String reason;
|
||||
|
||||
AdNoFillEvent({
|
||||
required this.requestId,
|
||||
required this.adSlotKey,
|
||||
required this.adType,
|
||||
required this.reason,
|
||||
}) : super(EventType.adNoFill.event, [requestId, adSlotKey, adType, reason]);
|
||||
|
||||
@override
|
||||
Map<String, dynamic> buildPayload() => {
|
||||
'request_id': requestId,
|
||||
'ad_slot_key': adSlotKey,
|
||||
'ad_type': adType,
|
||||
'reason': reason,
|
||||
};
|
||||
}
|
||||
|
||||
/// 广告素材加载成功事件。
|
||||
class AdLoadSuccessEvent extends _AdSdkEvent {
|
||||
final String traceId;
|
||||
final String requestId;
|
||||
final String adSlotKey;
|
||||
final String adId;
|
||||
final String adType;
|
||||
final int loadDuration;
|
||||
|
||||
AdLoadSuccessEvent({
|
||||
this.traceId = '',
|
||||
required this.requestId,
|
||||
required this.adSlotKey,
|
||||
required this.adId,
|
||||
required this.adType,
|
||||
required this.loadDuration,
|
||||
}) : super(EventType.adLoadSuccess.event, [
|
||||
if (traceId.isNotEmpty) traceId,
|
||||
requestId,
|
||||
adSlotKey,
|
||||
adId,
|
||||
adType,
|
||||
loadDuration.toString(),
|
||||
]);
|
||||
|
||||
@override
|
||||
Map<String, dynamic> buildPayload() => {
|
||||
'trace_id': traceId,
|
||||
'request_id': requestId,
|
||||
'ad_slot_key': adSlotKey,
|
||||
'ad_id': adId,
|
||||
'ad_type': adType,
|
||||
'load_duration': loadDuration,
|
||||
};
|
||||
}
|
||||
|
||||
/// 广告素材加载失败事件。
|
||||
@Deprecated('ad_load_fail 已并入 ad_error,使用 MATERIAL_LOAD_FAIL 错误码。')
|
||||
class AdLoadFailEvent extends _AdSdkEvent {
|
||||
final String traceId;
|
||||
final String requestId;
|
||||
final String adSlotKey;
|
||||
final String adId;
|
||||
final String adType;
|
||||
final String errorCode;
|
||||
|
||||
AdLoadFailEvent({
|
||||
this.traceId = '',
|
||||
required this.requestId,
|
||||
required this.adSlotKey,
|
||||
required this.adId,
|
||||
required this.adType,
|
||||
required this.errorCode,
|
||||
}) : super(EventType.adLoadFail.event, [
|
||||
if (traceId.isNotEmpty) traceId,
|
||||
requestId,
|
||||
adSlotKey,
|
||||
adId,
|
||||
adType,
|
||||
errorCode,
|
||||
]);
|
||||
|
||||
@override
|
||||
Map<String, dynamic> buildPayload() => {
|
||||
'trace_id': traceId,
|
||||
'request_id': requestId,
|
||||
'ad_slot_key': adSlotKey,
|
||||
'ad_id': adId,
|
||||
'ad_type': adType,
|
||||
'error_code': errorCode,
|
||||
};
|
||||
}
|
||||
|
||||
/// 广告 SDK 曝光事件。
|
||||
class AdImpressionSdkEvent extends _AdSdkEvent {
|
||||
final String traceId;
|
||||
final String requestId;
|
||||
final String adSlotKey;
|
||||
final String adId;
|
||||
final String adType;
|
||||
final String materialSize;
|
||||
|
||||
AdImpressionSdkEvent({
|
||||
this.traceId = '',
|
||||
required this.requestId,
|
||||
required this.adSlotKey,
|
||||
required this.adId,
|
||||
required this.adType,
|
||||
required this.materialSize,
|
||||
}) : super(EventType.adImpressionSdk.event, [
|
||||
traceId,
|
||||
requestId,
|
||||
adSlotKey,
|
||||
adId,
|
||||
adType,
|
||||
materialSize,
|
||||
]);
|
||||
|
||||
@override
|
||||
Map<String, dynamic> buildPayload() => {
|
||||
'trace_id': traceId,
|
||||
'request_id': requestId,
|
||||
'ad_slot_key': adSlotKey,
|
||||
'ad_id': adId,
|
||||
'ad_type': adType,
|
||||
'material_size': materialSize,
|
||||
};
|
||||
}
|
||||
|
||||
/// 广告 SDK 点击事件。
|
||||
class AdClickSdkEvent extends _AdSdkEvent {
|
||||
final String traceId;
|
||||
final String requestId;
|
||||
final String adSlotKey;
|
||||
final String adId;
|
||||
final String adType;
|
||||
|
||||
AdClickSdkEvent({
|
||||
this.traceId = '',
|
||||
required this.requestId,
|
||||
required this.adSlotKey,
|
||||
required this.adId,
|
||||
required this.adType,
|
||||
}) : super(EventType.adClickSdk.event, [
|
||||
traceId,
|
||||
requestId,
|
||||
adSlotKey,
|
||||
adId,
|
||||
adType,
|
||||
]);
|
||||
|
||||
@override
|
||||
Map<String, dynamic> buildPayload() => {
|
||||
'trace_id': traceId,
|
||||
'request_id': requestId,
|
||||
'ad_slot_key': adSlotKey,
|
||||
'ad_id': adId,
|
||||
'ad_type': adType,
|
||||
};
|
||||
}
|
||||
|
||||
/// 广告关闭事件。
|
||||
class AdCloseEvent extends _AdSdkEvent {
|
||||
final String traceId;
|
||||
final String requestId;
|
||||
final String adSlotKey;
|
||||
final String adId;
|
||||
final String adType;
|
||||
final String closeType;
|
||||
final int shownDuration;
|
||||
|
||||
AdCloseEvent({
|
||||
this.traceId = '',
|
||||
required this.requestId,
|
||||
required this.adSlotKey,
|
||||
required this.adId,
|
||||
required this.adType,
|
||||
required this.closeType,
|
||||
this.shownDuration = 0,
|
||||
}) : super(EventType.adClose.event, [
|
||||
if (traceId.isNotEmpty) traceId,
|
||||
requestId,
|
||||
adSlotKey,
|
||||
adId,
|
||||
adType,
|
||||
closeType,
|
||||
shownDuration.toString(),
|
||||
]);
|
||||
|
||||
@override
|
||||
Map<String, dynamic> buildPayload() => {
|
||||
'trace_id': traceId,
|
||||
'request_id': requestId,
|
||||
'ad_slot_key': adSlotKey,
|
||||
'ad_id': adId,
|
||||
'ad_type': adType,
|
||||
'close_type': closeType,
|
||||
'shown_duration': shownDuration,
|
||||
};
|
||||
}
|
||||
|
||||
/// 广告跳过事件。
|
||||
class AdSkipEvent extends _AdSdkEvent {
|
||||
final String traceId;
|
||||
final String requestId;
|
||||
final String adSlotKey;
|
||||
final String adId;
|
||||
final String adType;
|
||||
final int shownDuration;
|
||||
|
||||
AdSkipEvent({
|
||||
this.traceId = '',
|
||||
required this.requestId,
|
||||
required this.adSlotKey,
|
||||
required this.adId,
|
||||
required this.adType,
|
||||
this.shownDuration = 0,
|
||||
}) : super(EventType.adSkip.event, [
|
||||
if (traceId.isNotEmpty) traceId,
|
||||
requestId,
|
||||
adSlotKey,
|
||||
adId,
|
||||
adType,
|
||||
shownDuration.toString(),
|
||||
]);
|
||||
|
||||
@override
|
||||
Map<String, dynamic> buildPayload() => {
|
||||
'trace_id': traceId,
|
||||
'request_id': requestId,
|
||||
'ad_slot_key': adSlotKey,
|
||||
'ad_id': adId,
|
||||
'ad_type': adType,
|
||||
'shown_duration': shownDuration,
|
||||
};
|
||||
}
|
||||
|
||||
/// 广告错误事件。
|
||||
class AdErrorEvent extends _AdSdkEvent {
|
||||
final String traceId;
|
||||
final String requestId;
|
||||
final String adSlotKey;
|
||||
final String adType;
|
||||
final String errorCode;
|
||||
final String errorMsg;
|
||||
final String errorStage;
|
||||
|
||||
AdErrorEvent({
|
||||
this.traceId = '',
|
||||
this.requestId = '',
|
||||
required this.adSlotKey,
|
||||
required this.adType,
|
||||
required this.errorCode,
|
||||
required this.errorMsg,
|
||||
required this.errorStage,
|
||||
}) : super(EventType.adError.event, [
|
||||
if (traceId.isNotEmpty) traceId,
|
||||
requestId,
|
||||
adSlotKey,
|
||||
adType,
|
||||
errorCode,
|
||||
errorMsg,
|
||||
errorStage,
|
||||
]);
|
||||
|
||||
@override
|
||||
Map<String, dynamic> buildPayload() => {
|
||||
'trace_id': traceId,
|
||||
'request_id': requestId,
|
||||
'ad_slot_key': adSlotKey,
|
||||
'ad_type': adType,
|
||||
'error_code': errorCode,
|
||||
'error_msg': errorMsg,
|
||||
'error_stage': errorStage,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import 'package:analytics_sdk/const/event_type.dart';
|
||||
import 'package:analytics_sdk/manager/sdk_context.dart';
|
||||
|
||||
/// APP广告行为
|
||||
class AdvertisingEvent {
|
||||
final String event = EventType.advertising.event;
|
||||
late final String eventId;
|
||||
|
||||
///事件类型:click(点击), close(关闭), show(展示)
|
||||
final String eventType;
|
||||
|
||||
/// 广告标识:home_popup(首页弹窗), home_banner(首页Banner), video_reward(激励视频)等
|
||||
final String advertisingKey;
|
||||
|
||||
/// 广告标识名称:首页弹窗, 首页Banner, 激励视频
|
||||
final String advertisingName;
|
||||
|
||||
/// 广告ID
|
||||
final String advertisingId;
|
||||
|
||||
late final Map<String, dynamic> _commonFields;
|
||||
|
||||
AdvertisingEvent({
|
||||
required this.eventType,
|
||||
required this.advertisingKey,
|
||||
required this.advertisingName,
|
||||
required this.advertisingId,
|
||||
}) {
|
||||
_commonFields = SdkContext.generateCommonFields(event);
|
||||
|
||||
eventId = SdkContext.generateEventIdFromCommonFields(_commonFields, [
|
||||
eventType,
|
||||
advertisingKey,
|
||||
advertisingName,
|
||||
advertisingId,
|
||||
]);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
Map<String, dynamic> payload = {
|
||||
'event_type': eventType,
|
||||
'advertising_key': advertisingKey,
|
||||
'advertising_name': advertisingName,
|
||||
'advertising_id': advertisingId,
|
||||
};
|
||||
return {..._commonFields, 'event_id': eventId, "payload": payload};
|
||||
}
|
||||
|
||||
factory AdvertisingEvent.fromJson(Map<String, dynamic> json) {
|
||||
return AdvertisingEvent(
|
||||
eventType: json['event_type'] as String,
|
||||
advertisingKey: json['advertising_key'] as String,
|
||||
advertisingName: json['advertising_name'] as String,
|
||||
advertisingId: json['advertising_id'] as String,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
/// Tab 页面的 analytics 配置。
|
||||
///
|
||||
/// - [key]:pageKey(必填),建议使用 [AppPageEnum](如 `AppPageEnum.home.key`),
|
||||
/// 也可传入自定义字符串。
|
||||
/// - [name]:pageName(可选),省略时由 [PageNameMapper] 自动解析;
|
||||
/// 传入时建议与 [AppPageEnum] 保持一致(如 `AppPageEnum.home.name`)。
|
||||
///
|
||||
/// ```dart
|
||||
/// // 使用枚举
|
||||
/// AnalyticsTab(AppPageEnum.home.key, AppPageEnum.home.name)
|
||||
/// AnalyticsTab(AppPageEnum.video.key, AppPageEnum.video.name)
|
||||
///
|
||||
/// // 使用自定义值
|
||||
/// AnalyticsTab('live_room', '直播间')
|
||||
///
|
||||
/// // 省略 name(由 PageNameMapper 解析)
|
||||
/// AnalyticsTab('home')
|
||||
/// ```
|
||||
class AnalyticsTab {
|
||||
final String key;
|
||||
final String? name;
|
||||
|
||||
const AnalyticsTab(this.key, [this.name]);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import 'package:analytics_sdk/manager/sdk_context.dart';
|
||||
export 'package:analytics_sdk/manager/sdk_context.dart';
|
||||
|
||||
// SdkContext 的向后兼容别名,内部使用。
|
||||
typedef AnalyticsUtils = SdkContext;
|
||||
@@ -0,0 +1,46 @@
|
||||
import 'package:analytics_sdk/const/event_type.dart';
|
||||
import 'package:analytics_sdk/manager/sdk_context.dart';
|
||||
|
||||
/// App 安装事件(客户端)
|
||||
class AppInstallEvent {
|
||||
/// 事件类型(固定)
|
||||
final String event = EventType.appInstall.event;
|
||||
|
||||
/// 去重 ID(动态计算)
|
||||
late final String eventId;
|
||||
|
||||
/// 落地页点击生成唯一ID, 复制到剪切板, 打开APP后读取剪切板
|
||||
final String traceId;
|
||||
|
||||
/// 原生包是否开启归因:1 开启,0 关闭(默认)
|
||||
final int attributionFlag;
|
||||
|
||||
late final Map<String, dynamic> _commonFields;
|
||||
|
||||
AppInstallEvent({
|
||||
required this.traceId,
|
||||
this.attributionFlag = 0,
|
||||
}) {
|
||||
_commonFields = SdkContext.generateCommonFields(event);
|
||||
|
||||
eventId = SdkContext.generateEventIdFromCommonFields(_commonFields, [
|
||||
traceId,
|
||||
attributionFlag.toString(),
|
||||
]);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final payload = <String, dynamic>{
|
||||
'trace_id': traceId,
|
||||
'attribution_flag': attributionFlag,
|
||||
};
|
||||
return {..._commonFields, 'event_id': eventId, 'payload': payload};
|
||||
}
|
||||
|
||||
factory AppInstallEvent.fromJson(Map<String, dynamic> json) {
|
||||
return AppInstallEvent(
|
||||
traceId: json['trace_id'] as String,
|
||||
attributionFlag: (json['attribution_flag'] as int?) ?? 0,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:analytics_sdk/const/event_type.dart';
|
||||
import 'package:analytics_sdk/manager/sdk_context.dart';
|
||||
|
||||
/// 应用生命周期事件。
|
||||
///
|
||||
/// `app_lifecycle` 的子事件字段写入 `payload`。每个工厂方法只写入该
|
||||
/// 子事件文档定义的字段。
|
||||
class AppLifecycleEvent {
|
||||
final String event = EventType.appLifecycle.event;
|
||||
final String lifecycleEventKey;
|
||||
final Map<String, dynamic> _fields;
|
||||
|
||||
late final String eventId;
|
||||
late final Map<String, dynamic> _commonFields;
|
||||
|
||||
AppLifecycleEvent._(
|
||||
this.lifecycleEventKey,
|
||||
Map<String, dynamic> fields,
|
||||
) : _fields = Map<String, dynamic>.from(fields)
|
||||
..removeWhere((_, value) => value == null) {
|
||||
_commonFields = SdkContext.generateCommonFields(event);
|
||||
eventId = SdkContext.generateEventIdFromCommonFields(_commonFields, [
|
||||
lifecycleEventKey,
|
||||
jsonEncode(_stableFields),
|
||||
]);
|
||||
}
|
||||
|
||||
factory AppLifecycleEvent.coldStart({
|
||||
required int coldStartDurationMs,
|
||||
String lastExitType = 'unknown',
|
||||
int bootTime = 0,
|
||||
}) =>
|
||||
AppLifecycleEvent._('cold_start', {
|
||||
'cold_start_duration_ms': _nonNegative(coldStartDurationMs),
|
||||
'last_exit_type': _allowedString(
|
||||
lastExitType,
|
||||
const {'normal', 'killed', 'crash', 'unknown'},
|
||||
'unknown',
|
||||
),
|
||||
'boot_time': bootTime > 0 ? bootTime : 0,
|
||||
});
|
||||
|
||||
factory AppLifecycleEvent.webLoad({
|
||||
required String pageUrl,
|
||||
required int pageLoadTimeMs,
|
||||
required int viewportWidth,
|
||||
required int viewportHeight,
|
||||
}) =>
|
||||
AppLifecycleEvent._('web_load', {
|
||||
'page_url': pageUrl,
|
||||
'page_load_time_ms': _nonNegative(pageLoadTimeMs),
|
||||
'viewport_width': viewportWidth,
|
||||
'viewport_height': viewportHeight,
|
||||
});
|
||||
|
||||
factory AppLifecycleEvent.warmStart({
|
||||
required int backgroundDurationMs,
|
||||
String resumeReason = 'unknown',
|
||||
}) =>
|
||||
AppLifecycleEvent._('warm_start', {
|
||||
'background_duration_ms': _nonNegative(backgroundDurationMs),
|
||||
'resume_reason': _allowedString(
|
||||
resumeReason,
|
||||
const {'user', 'push', 'scheme', 'unknown'},
|
||||
'unknown',
|
||||
),
|
||||
});
|
||||
|
||||
factory AppLifecycleEvent.foreground({
|
||||
required int foregroundCountInSid,
|
||||
}) =>
|
||||
AppLifecycleEvent._('foreground', {
|
||||
'foreground_count_in_sid':
|
||||
foregroundCountInSid < 1 ? 1 : foregroundCountInSid,
|
||||
'is_foreground': 1,
|
||||
});
|
||||
|
||||
factory AppLifecycleEvent.background({
|
||||
required int foregroundDurationMs,
|
||||
required String lastActivePageKey,
|
||||
}) =>
|
||||
AppLifecycleEvent._('background', {
|
||||
'is_foreground': 0,
|
||||
'foreground_duration_ms': _nonNegative(foregroundDurationMs),
|
||||
'last_active_page_key': lastActivePageKey,
|
||||
});
|
||||
|
||||
factory AppLifecycleEvent.webUnload({
|
||||
required String pageUrl,
|
||||
required String lastActivePageKey,
|
||||
required int pageStayDurationMs,
|
||||
String unloadReason = 'unknown',
|
||||
required int scrollDepthPercentAtUnload,
|
||||
required String webVisibilityState,
|
||||
}) =>
|
||||
AppLifecycleEvent._('web_unload', {
|
||||
'page_url': pageUrl,
|
||||
'last_active_page_key': lastActivePageKey,
|
||||
'page_stay_duration_ms': _nonNegative(pageStayDurationMs),
|
||||
'unload_reason': _allowedString(
|
||||
unloadReason,
|
||||
const {
|
||||
'normal',
|
||||
'visibility_hidden',
|
||||
'navigation',
|
||||
'tab_close',
|
||||
'unknown',
|
||||
},
|
||||
'unknown',
|
||||
),
|
||||
'scroll_depth_percent_at_unload':
|
||||
scrollDepthPercentAtUnload.clamp(0, 100),
|
||||
'web_visibility_state': _allowedString(
|
||||
webVisibilityState,
|
||||
const {'visible', 'hidden', 'unknown'},
|
||||
'unknown',
|
||||
),
|
||||
});
|
||||
|
||||
factory AppLifecycleEvent.terminate({
|
||||
required String lastActivePageKey,
|
||||
String terminateType = 'unknown',
|
||||
required int sessionDurationMs,
|
||||
required int terminateTs,
|
||||
}) =>
|
||||
AppLifecycleEvent._('terminate', {
|
||||
'last_active_page_key': lastActivePageKey,
|
||||
'terminate_type': _allowedString(
|
||||
terminateType,
|
||||
const {'user_quit', 'system_kill', 'app_finish', 'unknown'},
|
||||
'unknown',
|
||||
),
|
||||
'session_duration_ms': _nonNegative(sessionDurationMs),
|
||||
'terminate_ts': terminateTs > 0
|
||||
? terminateTs
|
||||
: DateTime.now().millisecondsSinceEpoch ~/ 1000,
|
||||
});
|
||||
|
||||
factory AppLifecycleEvent.unknown({
|
||||
String unknownReason = 'unknown',
|
||||
}) =>
|
||||
AppLifecycleEvent._('unknown', {
|
||||
'unknown_reason': _allowedString(
|
||||
unknownReason,
|
||||
const {
|
||||
'callback_missing',
|
||||
'state_conflict',
|
||||
'unsupported_platform',
|
||||
'sdk_not_ready',
|
||||
'system_restricted',
|
||||
'route_unrecognized',
|
||||
'collect_error',
|
||||
'unknown',
|
||||
},
|
||||
'unknown',
|
||||
),
|
||||
});
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
..._commonFields,
|
||||
'event_id': eventId,
|
||||
'payload': {
|
||||
'lifecycle_event_key': lifecycleEventKey,
|
||||
..._fields,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
Map<String, dynamic> get _stableFields {
|
||||
final sortedKeys = _fields.keys.toList()..sort();
|
||||
return {
|
||||
for (final key in sortedKeys) key: _fields[key],
|
||||
};
|
||||
}
|
||||
|
||||
static int _nonNegative(int value) => value < 0 ? 0 : value;
|
||||
|
||||
static String _allowedString(
|
||||
String value,
|
||||
Set<String> allowed,
|
||||
String fallback,
|
||||
) {
|
||||
final normalized = value.trim();
|
||||
return allowed.contains(normalized) ? normalized : fallback;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
import 'package:analytics_sdk/const/event_type.dart';
|
||||
import 'package:analytics_sdk/manager/page_name_manager.dart';
|
||||
import 'package:analytics_sdk/manager/sdk_context.dart';
|
||||
|
||||
/// 页面内元素曝光事件(客户端手动上报)。
|
||||
///
|
||||
/// 仅限需要采集页面内元素曝光的项目接入。未对接推荐引擎时,推荐相关字段传空字符串。
|
||||
class AppPageItemExposureEvent {
|
||||
final String event = EventType.appPageItemExposure.event;
|
||||
late final String eventId;
|
||||
|
||||
/// 元素唯一 ID。
|
||||
final String itemId;
|
||||
|
||||
/// 元素类型,如推荐、关注、评论、小说、视频、漫画等业务字典值。
|
||||
final String itemType;
|
||||
|
||||
/// 元素名称,如推荐、关注、评论、小说、视频、漫画等业务字典值。
|
||||
final String itemName;
|
||||
|
||||
/// 父元素 ID。
|
||||
final String parentItemId;
|
||||
|
||||
/// 当前页面标识,建议使用 [AppPageEnum] 或业务字典中的稳定 key。
|
||||
final String pageKey;
|
||||
|
||||
/// 当前页面名称。
|
||||
final String pageName;
|
||||
|
||||
/// 来路页面标识。
|
||||
final String referrerPageKey;
|
||||
|
||||
/// 来路页面名称。
|
||||
final String referrerPageName;
|
||||
|
||||
/// 推荐引擎 trace_id;多个推荐列表用英文逗号分隔。
|
||||
final String recommendTraceId;
|
||||
|
||||
/// 推荐内容 ID;未对接推荐引擎传空字符串。
|
||||
final String recommendId;
|
||||
|
||||
/// 推荐引擎 trace_info;未对接推荐引擎传空字符串。
|
||||
final String recommendTraceInfo;
|
||||
|
||||
late final Map<String, dynamic> _commonFields;
|
||||
|
||||
AppPageItemExposureEvent({
|
||||
required this.itemId,
|
||||
required this.itemType,
|
||||
required this.itemName,
|
||||
required this.parentItemId,
|
||||
required String pageKey,
|
||||
required this.pageName,
|
||||
required String referrerPageKey,
|
||||
required this.referrerPageName,
|
||||
this.recommendTraceId = '',
|
||||
this.recommendId = '',
|
||||
this.recommendTraceInfo = '',
|
||||
}) : pageKey = PageNameMapper.normalizeKey(pageKey),
|
||||
referrerPageKey = PageNameMapper.normalizeKey(referrerPageKey) {
|
||||
_commonFields = SdkContext.generateCommonFields(event);
|
||||
|
||||
eventId = SdkContext.generateEventIdFromCommonFields(_commonFields, [
|
||||
itemId,
|
||||
itemType,
|
||||
itemName,
|
||||
parentItemId,
|
||||
this.pageKey,
|
||||
pageName,
|
||||
this.referrerPageKey,
|
||||
referrerPageName,
|
||||
recommendTraceId,
|
||||
recommendId,
|
||||
recommendTraceInfo,
|
||||
]);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final payload = <String, dynamic>{
|
||||
'item_id': itemId,
|
||||
'item_type': itemType,
|
||||
'item_name': itemName,
|
||||
'parent_item_id': parentItemId,
|
||||
'page_key': pageKey,
|
||||
'page_name': pageName,
|
||||
'referrer_page_key': referrerPageKey,
|
||||
'referrer_page_name': referrerPageName,
|
||||
'recommend_trace_id': recommendTraceId,
|
||||
'recommend_id': recommendId,
|
||||
'recommend_trace_info': recommendTraceInfo,
|
||||
};
|
||||
return {..._commonFields, 'event_id': eventId, 'payload': payload};
|
||||
}
|
||||
|
||||
factory AppPageItemExposureEvent.fromJson(Map<String, dynamic> json) {
|
||||
final source = _payloadOrSelf(json);
|
||||
return AppPageItemExposureEvent(
|
||||
itemId: source['item_id'] as String,
|
||||
itemType: source['item_type'] as String,
|
||||
itemName: source['item_name'] as String,
|
||||
parentItemId: source['parent_item_id'] as String,
|
||||
pageKey: source['page_key'] as String,
|
||||
pageName: source['page_name'] as String,
|
||||
referrerPageKey: source['referrer_page_key'] as String,
|
||||
referrerPageName: source['referrer_page_name'] as String,
|
||||
recommendTraceId: source['recommend_trace_id'] as String? ?? '',
|
||||
recommendId: source['recommend_id'] as String? ?? '',
|
||||
recommendTraceInfo: source['recommend_trace_info'] as String? ?? '',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 页面内元素点击事件(客户端手动上报)。
|
||||
///
|
||||
/// 仅限需要采集页面内元素点击的项目接入。未对接推荐引擎时,推荐相关字段传空字符串。
|
||||
class AppPageItemClickEvent {
|
||||
final String event = EventType.appPageItemClick.event;
|
||||
late final String eventId;
|
||||
|
||||
/// 元素唯一 ID。
|
||||
final String itemId;
|
||||
|
||||
/// 点击元素类型,如推荐、关注、评论、小说、视频、漫画等业务字典值。
|
||||
final String itemType;
|
||||
|
||||
/// 点击元素名称,如推荐、关注、评论、小说、视频、漫画等业务字典值。
|
||||
final String itemName;
|
||||
|
||||
/// 父元素 ID。
|
||||
final String parentItemId;
|
||||
|
||||
/// 在父元素中的顺序索引(从 0 或 1 开始,按业务约定传入)。
|
||||
final int position;
|
||||
|
||||
/// 当前页面标识,建议使用 [AppPageEnum] 或业务字典中的稳定 key。
|
||||
final String pageKey;
|
||||
|
||||
/// 当前页面名称。
|
||||
final String pageName;
|
||||
|
||||
/// 推荐引擎 trace_id;多个推荐列表用英文逗号分隔。
|
||||
final String recommendTraceId;
|
||||
|
||||
/// 推荐内容 ID;未对接推荐引擎传空字符串。
|
||||
final String recommendId;
|
||||
|
||||
/// 推荐引擎 trace_info;未对接推荐引擎传空字符串。
|
||||
final String recommendTraceInfo;
|
||||
|
||||
late final Map<String, dynamic> _commonFields;
|
||||
|
||||
AppPageItemClickEvent({
|
||||
required this.itemId,
|
||||
required this.itemType,
|
||||
required this.itemName,
|
||||
required this.parentItemId,
|
||||
required this.position,
|
||||
required String pageKey,
|
||||
required this.pageName,
|
||||
this.recommendTraceId = '',
|
||||
this.recommendId = '',
|
||||
this.recommendTraceInfo = '',
|
||||
}) : pageKey = PageNameMapper.normalizeKey(pageKey) {
|
||||
_commonFields = SdkContext.generateCommonFields(event);
|
||||
|
||||
eventId = SdkContext.generateEventIdFromCommonFields(_commonFields, [
|
||||
itemId,
|
||||
itemType,
|
||||
itemName,
|
||||
parentItemId,
|
||||
position.toString(),
|
||||
this.pageKey,
|
||||
pageName,
|
||||
recommendTraceId,
|
||||
recommendId,
|
||||
recommendTraceInfo,
|
||||
]);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final payload = <String, dynamic>{
|
||||
'item_id': itemId,
|
||||
'item_type': itemType,
|
||||
'item_name': itemName,
|
||||
'parent_item_id': parentItemId,
|
||||
'position': position,
|
||||
'page_key': pageKey,
|
||||
'page_name': pageName,
|
||||
'recommend_trace_id': recommendTraceId,
|
||||
'recommend_id': recommendId,
|
||||
'recommend_trace_info': recommendTraceInfo,
|
||||
};
|
||||
return {..._commonFields, 'event_id': eventId, 'payload': payload};
|
||||
}
|
||||
|
||||
factory AppPageItemClickEvent.fromJson(Map<String, dynamic> json) {
|
||||
final source = _payloadOrSelf(json);
|
||||
return AppPageItemClickEvent(
|
||||
itemId: source['item_id'] as String,
|
||||
itemType: source['item_type'] as String,
|
||||
itemName: source['item_name'] as String,
|
||||
parentItemId: source['parent_item_id'] as String,
|
||||
position: source['position'] as int,
|
||||
pageKey: source['page_key'] as String,
|
||||
pageName: source['page_name'] as String,
|
||||
recommendTraceId: source['recommend_trace_id'] as String? ?? '',
|
||||
recommendId: source['recommend_id'] as String? ?? '',
|
||||
recommendTraceInfo: source['recommend_trace_info'] as String? ?? '',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, dynamic> _payloadOrSelf(Map<String, dynamic> json) {
|
||||
final payload = json['payload'];
|
||||
if (payload is Map) return Map<String, dynamic>.from(payload);
|
||||
return json;
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
import 'dart:ui' show window;
|
||||
|
||||
import 'package:analytics_sdk/const/event_type.dart';
|
||||
import 'package:analytics_sdk/manager/page_view_auto_fields.dart';
|
||||
import 'package:analytics_sdk/manager/sdk_context.dart';
|
||||
import 'package:analytics_sdk/manager/page_name_manager.dart';
|
||||
import 'package:analytics_sdk/utils/uuid_util.dart';
|
||||
|
||||
/// 应用页面展示日志(由 [PageLifecycleObserver] 和 Tab 包装器自动上报)。
|
||||
///
|
||||
/// **page_key / page_name 填写规范**
|
||||
///
|
||||
/// 建议使用 [AppPageEnum] 预定义值,也可直接传入自定义字符串:
|
||||
/// ```dart
|
||||
/// AppPageViewEvent(
|
||||
/// pageKey: AppPageEnum.videoDetail.key, // 'video_detail'
|
||||
/// pageName: AppPageEnum.videoDetail.name, // '视频详情页'
|
||||
/// ...
|
||||
/// )
|
||||
/// ```
|
||||
class AppPageViewEvent {
|
||||
final String event = EventType.appPageView.event;
|
||||
late final String eventId;
|
||||
|
||||
final String userType;
|
||||
|
||||
/// 当前页面标识,建议使用 [AppPageEnum](如 `AppPageEnum.videoDetail.key`),
|
||||
/// 也可传入自定义字符串。
|
||||
final String pageKey;
|
||||
|
||||
/// 当前页面展示名称,建议与 [pageKey] 保持对应(如 `AppPageEnum.videoDetail.name`)。
|
||||
final String pageName;
|
||||
|
||||
/// 来路页面标识,填写跳转前所在页面的 pageKey,规范同 [pageKey]。
|
||||
final String referrerPageKey;
|
||||
|
||||
/// 来路页面展示名称,规范同 [pageName]。
|
||||
final String referrerPageName;
|
||||
|
||||
/// 当前页面标识(与 [pageKey] 通常保持一致,保留用于扩展场景)。
|
||||
final String currentPageKey;
|
||||
|
||||
/// 当前页面展示名称(与 [pageName] 通常保持一致)。
|
||||
final String currentPageName;
|
||||
|
||||
/// 页面加载耗时(毫秒)
|
||||
final int pageLoadTime;
|
||||
|
||||
/// 当前页面实例唯一 ID,由 SDK 自动生成。
|
||||
final String pageId;
|
||||
|
||||
/// SDK 首次识别当前页面实例的时间,10 位时间戳。
|
||||
final int pageEnterTs;
|
||||
|
||||
/// Web/H5 当前页面物理地址,仅包含 scheme + host + path。
|
||||
final String pageUrl;
|
||||
|
||||
/// 当前页面根容器可交互可视区域宽度。
|
||||
final int viewportWidth;
|
||||
|
||||
/// 当前页面根容器可交互可视区域高度。
|
||||
final int viewportHeight;
|
||||
|
||||
/// 推荐引擎的trace_id,有多个推荐列表时用英文逗号分隔,未接推荐引擎传空字符串
|
||||
final String recommendTraceId;
|
||||
|
||||
late final Map<String, dynamic> _commonFields;
|
||||
|
||||
AppPageViewEvent({
|
||||
required this.userType,
|
||||
required String pageKey,
|
||||
required this.pageName,
|
||||
required String referrerPageKey,
|
||||
required this.referrerPageName,
|
||||
required String currentPageKey,
|
||||
required this.currentPageName,
|
||||
required this.pageLoadTime,
|
||||
String? pageId,
|
||||
int? pageEnterTs,
|
||||
String? pageUrl,
|
||||
int? viewportWidth,
|
||||
int? viewportHeight,
|
||||
this.recommendTraceId = '',
|
||||
}) : pageKey = PageNameMapper.normalizeKey(pageKey),
|
||||
referrerPageKey = PageNameMapper.normalizeKey(referrerPageKey),
|
||||
currentPageKey = PageNameMapper.normalizeKey(currentPageKey),
|
||||
pageId = _pageId(pageId),
|
||||
pageEnterTs = _pageEnterTs(pageEnterTs),
|
||||
pageUrl = pageUrl ?? currentPageUrl(),
|
||||
viewportWidth = _viewportDimension(viewportWidth, isWidth: true),
|
||||
viewportHeight = _viewportDimension(viewportHeight, isWidth: false) {
|
||||
_commonFields = SdkContext.generateCommonFields(event);
|
||||
|
||||
eventId = SdkContext.generateEventIdFromCommonFields(_commonFields, [
|
||||
this.pageId,
|
||||
this.pageEnterTs.toString(),
|
||||
this.pageUrl,
|
||||
this.viewportWidth.toString(),
|
||||
this.viewportHeight.toString(),
|
||||
userType,
|
||||
this.pageKey,
|
||||
pageName,
|
||||
this.referrerPageKey,
|
||||
referrerPageName,
|
||||
this.currentPageKey,
|
||||
currentPageName,
|
||||
pageLoadTime.toString(),
|
||||
recommendTraceId,
|
||||
]);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
Map<String, dynamic> payload = {
|
||||
'user_type': userType,
|
||||
'page_key': pageKey,
|
||||
'page_name': pageName,
|
||||
'referrer_page_key': referrerPageKey,
|
||||
'referrer_page_name': referrerPageName,
|
||||
'current_page_key': currentPageKey,
|
||||
'current_page_name': currentPageName,
|
||||
'page_load_time': pageLoadTime,
|
||||
'page_id': pageId,
|
||||
'page_enter_ts': pageEnterTs,
|
||||
'page_url': pageUrl,
|
||||
'viewport_width': viewportWidth,
|
||||
'viewport_height': viewportHeight,
|
||||
'recommend_trace_id': recommendTraceId,
|
||||
};
|
||||
return {
|
||||
..._commonFields,
|
||||
'event_id': eventId,
|
||||
"payload": payload,
|
||||
};
|
||||
}
|
||||
|
||||
factory AppPageViewEvent.fromJson(Map<String, dynamic> json) {
|
||||
return AppPageViewEvent(
|
||||
userType: json['user_type'] as String,
|
||||
pageKey: json['page_key'] as String,
|
||||
pageName: json['page_name'] as String,
|
||||
referrerPageKey: json['referrer_page_key'] as String,
|
||||
referrerPageName: json['referrer_page_name'] as String,
|
||||
currentPageKey: json['current_page_key'] as String,
|
||||
currentPageName: json['current_page_name'] as String,
|
||||
pageLoadTime: json['page_load_time'] as int,
|
||||
pageId: json['page_id'] as String?,
|
||||
pageEnterTs: json['page_enter_ts'] as int?,
|
||||
pageUrl: json['page_url'] as String?,
|
||||
viewportWidth: json['viewport_width'] as int?,
|
||||
viewportHeight: json['viewport_height'] as int?,
|
||||
recommendTraceId: json['recommend_trace_id'] as String? ?? '',
|
||||
);
|
||||
}
|
||||
|
||||
static String _pageId(String? value) {
|
||||
final normalized = value?.trim() ?? '';
|
||||
if (normalized.isNotEmpty) return normalized;
|
||||
return generateUuidV4().replaceAll('-', '');
|
||||
}
|
||||
|
||||
static int _pageEnterTs(int? value) {
|
||||
if (value != null && value > 0) return value;
|
||||
return DateTime.now().millisecondsSinceEpoch ~/ 1000;
|
||||
}
|
||||
|
||||
static int _viewportDimension(int? value, {required bool isWidth}) {
|
||||
if (value != null) return value >= 0 ? value : -1;
|
||||
try {
|
||||
final pixelRatio = window.devicePixelRatio;
|
||||
if (pixelRatio <= 0) return -1;
|
||||
final physicalSize = window.physicalSize;
|
||||
final logical = isWidth
|
||||
? physicalSize.width / pixelRatio
|
||||
: physicalSize.height / pixelRatio;
|
||||
if (!logical.isFinite || logical <= 0) return -1;
|
||||
return logical.round();
|
||||
} catch (_) {
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:analytics_sdk/const/event_type.dart';
|
||||
import 'package:analytics_sdk/manager/sdk_context.dart';
|
||||
import 'package:analytics_sdk/manager/session_manager.dart';
|
||||
import 'package:analytics_sdk/manager/session_behavior_manager.dart';
|
||||
import 'package:analytics_sdk/manager/session_metrics_manager.dart';
|
||||
import 'package:analytics_sdk/manager/session_sensor_sampler.dart';
|
||||
|
||||
/// 会话汇总事件。
|
||||
///
|
||||
/// 汇总字段写入 `payload`;签名类 payload 字段由
|
||||
/// [SdkContext.attachDeviceEnvIfNeeded] 在 track 管道中统一补充。
|
||||
class AppSessionSummaryEvent {
|
||||
final String event = EventType.appSessionSummary.event;
|
||||
final Map<String, dynamic> _payloadFields;
|
||||
|
||||
late final String eventId;
|
||||
late final Map<String, dynamic> _commonFields;
|
||||
|
||||
AppSessionSummaryEvent({
|
||||
required String sessionEndReason,
|
||||
DateTime? endedAt,
|
||||
}) : _payloadFields = _buildPayloadFields(
|
||||
sessionEndReason: sessionEndReason,
|
||||
endedAt: endedAt ?? DateTime.now(),
|
||||
) {
|
||||
_commonFields = SdkContext.generateCommonFields(event);
|
||||
eventId = SdkContext.generateEventIdFromCommonFields(_commonFields, [
|
||||
jsonEncode(_stablePayloadFields),
|
||||
]);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
..._commonFields,
|
||||
'event_id': eventId,
|
||||
'payload': Map<String, dynamic>.from(_payloadFields),
|
||||
};
|
||||
}
|
||||
|
||||
Map<String, dynamic> get _stablePayloadFields {
|
||||
final sortedKeys = _payloadFields.keys.toList()..sort();
|
||||
return {
|
||||
for (final key in sortedKeys) key: _payloadFields[key],
|
||||
};
|
||||
}
|
||||
|
||||
static Map<String, dynamic> _buildPayloadFields({
|
||||
required String sessionEndReason,
|
||||
required DateTime endedAt,
|
||||
}) {
|
||||
final metrics = SessionMetricsManager.instance.snapshot(endedAt: endedAt);
|
||||
final behavior = SessionBehaviorManager.instance.snapshot();
|
||||
final environment = SdkContext.getEnvironmentSnapshotFields();
|
||||
final screenBrightness = _intField(environment['screen_brightness'], -1);
|
||||
final sensor = SessionSensorSampler.instance.snapshot(
|
||||
screenBrightness: screenBrightness,
|
||||
);
|
||||
final sessionStart = SessionManager.instance.sessionStartTime ?? endedAt;
|
||||
final sessionStartTs = sessionStart.millisecondsSinceEpoch ~/ 1000;
|
||||
final sessionEndTs = endedAt.millisecondsSinceEpoch ~/ 1000;
|
||||
final durationMs = endedAt.difference(sessionStart).inMilliseconds;
|
||||
|
||||
return {
|
||||
'session_start_ts': sessionStartTs,
|
||||
'session_end_ts': sessionEndTs,
|
||||
'session_duration_ms': durationMs < 0 ? 0 : durationMs,
|
||||
'event_count_total': metrics.eventCountTotal,
|
||||
'business_event_count': metrics.businessEventCount,
|
||||
'foreground_count': metrics.foregroundCount,
|
||||
'background_count_in_sid': metrics.backgroundCountInSid,
|
||||
'page_count': metrics.pageCount,
|
||||
'page_view_count': metrics.pageViewCount,
|
||||
'page_click_count': metrics.pageClickCount,
|
||||
'network_change_count': metrics.networkChangeCount,
|
||||
'session_end_reason': _sessionEndReason(sessionEndReason),
|
||||
'last_event_name': metrics.lastEventName,
|
||||
'orientation': environment['orientation']?.toString() ?? '',
|
||||
'screen_brightness': screenBrightness,
|
||||
'volume': _intField(environment['volume'], -1),
|
||||
'is_screen_on': _intField(environment['is_screen_on'], -1),
|
||||
'battery_level': _intField(environment['battery_level'], -1),
|
||||
'is_charging': _intField(environment['is_charging'], -1),
|
||||
'battery_state': _stringField(environment['battery_state'], 'unknown'),
|
||||
'sensor_signature': sensor.sensorSignature,
|
||||
'sensor_count_active': sensor.sensorCountActive,
|
||||
'scroll_total_count': behavior.scrollTotalCount,
|
||||
'scroll_total_distance_px': behavior.scrollTotalDistancePx,
|
||||
'scroll_up_count': behavior.scrollUpCount,
|
||||
'scroll_down_count': behavior.scrollDownCount,
|
||||
'scroll_direction_change_count': behavior.scrollDirectionChangeCount,
|
||||
'first_scroll_delay_ms': behavior.firstScrollDelayMs,
|
||||
'max_scroll_depth_percent': behavior.maxScrollDepthPercent,
|
||||
'is_reach_bottom': behavior.isReachBottom,
|
||||
'content_height': behavior.contentHeight,
|
||||
'touch_total_count': behavior.touchTotalCount,
|
||||
'swipe_total_count': behavior.swipeTotalCount,
|
||||
'long_press_total_count': behavior.longPressTotalCount,
|
||||
'pinch_total_count': behavior.pinchTotalCount,
|
||||
'first_interaction_delay_ms': behavior.firstInteractionDelayMs,
|
||||
'page_stay_list': metrics.pageStayList,
|
||||
'behavior_sequence_hash': metrics.behaviorSequenceHash,
|
||||
};
|
||||
}
|
||||
|
||||
static int _intField(dynamic value, int fallback) {
|
||||
if (value is int) return value;
|
||||
if (value is num) return value.round();
|
||||
return int.tryParse(value?.toString() ?? '') ?? fallback;
|
||||
}
|
||||
|
||||
static String _stringField(dynamic value, String fallback) {
|
||||
final text = value?.toString().trim() ?? '';
|
||||
return text.isEmpty ? fallback : text;
|
||||
}
|
||||
|
||||
static String _sessionEndReason(String value) {
|
||||
final normalized = value.trim();
|
||||
const allowed = {
|
||||
'idle',
|
||||
'app_exit',
|
||||
'web_visibility_hidden',
|
||||
'forced',
|
||||
};
|
||||
return allowed.contains(normalized) ? normalized : 'app_exit';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import 'package:analytics_sdk/const/event_type.dart';
|
||||
import 'package:analytics_sdk/manager/sdk_context.dart';
|
||||
import 'package:analytics_sdk/enum/read_behavior_enum.dart';
|
||||
|
||||
/// 漫画事件
|
||||
class ComicEvent {
|
||||
final String event = EventType.comicEvent.event;
|
||||
late final String eventId;
|
||||
|
||||
/// 漫画 id
|
||||
final String comicId;
|
||||
|
||||
/// 漫画标题
|
||||
final String comicTitle;
|
||||
|
||||
/// 分类ID
|
||||
final String comicTypeId;
|
||||
|
||||
/// 分类名称
|
||||
final String comicTypeName;
|
||||
|
||||
/// 标签KEY, 多个标签使用英文逗号分隔
|
||||
final String comicTagKey;
|
||||
|
||||
/// 标签名称,多个标签使用英文逗号分隔
|
||||
final String comicTagName;
|
||||
|
||||
/// 推荐引擎的trace_id,未接推荐引擎传空字符串
|
||||
final String recommendTraceId;
|
||||
|
||||
/// 阅读进度百分比(0-100)
|
||||
final int readProgress;
|
||||
|
||||
/// 当前阅读第几页(从1开始)
|
||||
final int pageNo;
|
||||
|
||||
/// 行为标识:view(展示), page_next(下一页), page_prev(上一页), complete(读完) 等
|
||||
final ReadBehaviorEnum comicBehavior;
|
||||
|
||||
/// 章节ID,没有章节上报空字符串,没有对接搜索推荐引擎的上报空字符串
|
||||
final String chapterId;
|
||||
|
||||
/// 章节名称,没有章节上报空字符串,没有对接搜索推荐引擎的上报空字符串
|
||||
final String chapterName;
|
||||
|
||||
/// 老司机媒体资源ID,如未使用老司机库资源可传空字符串
|
||||
final String mediaId;
|
||||
|
||||
late final Map<String, dynamic> _commonFields;
|
||||
|
||||
ComicEvent({
|
||||
required this.comicId,
|
||||
required this.comicTitle,
|
||||
required this.comicTypeId,
|
||||
required this.comicTypeName,
|
||||
required this.comicTagKey,
|
||||
required this.comicTagName,
|
||||
required this.readProgress,
|
||||
required this.pageNo,
|
||||
required this.comicBehavior,
|
||||
this.recommendTraceId = '',
|
||||
this.mediaId = '',
|
||||
this.chapterId = '',
|
||||
this.chapterName = '',
|
||||
}) {
|
||||
_commonFields = SdkContext.generateCommonFields(event);
|
||||
|
||||
eventId = SdkContext.generateEventIdFromCommonFields(_commonFields, [
|
||||
comicId,
|
||||
comicTitle,
|
||||
comicTypeId,
|
||||
comicTypeName,
|
||||
comicTagKey,
|
||||
comicTagName,
|
||||
readProgress.toString(),
|
||||
pageNo.toString(),
|
||||
comicBehavior.key,
|
||||
comicBehavior.name,
|
||||
recommendTraceId,
|
||||
mediaId,
|
||||
chapterId,
|
||||
chapterName,
|
||||
]);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
Map<String, dynamic> payload = {
|
||||
'comic_id': comicId,
|
||||
'comic_title': comicTitle,
|
||||
'comic_type_id': comicTypeId,
|
||||
'comic_type_name': comicTypeName,
|
||||
'comic_tag_key': comicTagKey,
|
||||
'comic_tag_name': comicTagName,
|
||||
'recommend_trace_id': recommendTraceId,
|
||||
'read_progress': readProgress,
|
||||
'page_no': pageNo,
|
||||
'comic_behavior_key': comicBehavior.key,
|
||||
'comic_behavior_name': comicBehavior.name,
|
||||
'media_id': mediaId,
|
||||
'chapter_id': chapterId,
|
||||
'chapter_name': chapterName,
|
||||
};
|
||||
return {..._commonFields, 'event_id': eventId, "payload": payload};
|
||||
}
|
||||
|
||||
factory ComicEvent.fromJson(Map<String, dynamic> json) {
|
||||
final behaviorKey = json['comic_behavior_key'] as String? ?? '';
|
||||
final behaviorName = json['comic_behavior_name'] as String? ?? '';
|
||||
final behavior = ReadBehaviorEnum(
|
||||
behaviorKey.isNotEmpty ? behaviorKey : ReadBehaviorEnum.VIEW.key,
|
||||
behaviorName.isNotEmpty ? behaviorName : ReadBehaviorEnum.VIEW.name,
|
||||
);
|
||||
|
||||
return ComicEvent(
|
||||
comicId: json['comic_id'] as String,
|
||||
comicTitle: json['comic_title'] as String,
|
||||
comicTypeId: json['comic_type_id'] as String,
|
||||
comicTypeName: json['comic_type_name'] as String,
|
||||
comicTagKey: json['comic_tag_key'] as String,
|
||||
comicTagName: json['comic_tag_name'] as String,
|
||||
readProgress: json['read_progress'] as int,
|
||||
pageNo: json['page_no'] as int,
|
||||
comicBehavior: behavior,
|
||||
recommendTraceId: json['recommend_trace_id'] as String? ?? '',
|
||||
mediaId: json['media_id'] as String? ?? '',
|
||||
chapterId: json['chapter_id'] as String? ?? '',
|
||||
chapterName: json['chapter_name'] as String? ?? '',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import 'package:analytics_sdk/const/event_type.dart';
|
||||
import 'package:analytics_sdk/manager/sdk_context.dart';
|
||||
import 'package:analytics_sdk/enum/click_item_type_enum.dart';
|
||||
|
||||
/// 关键词搜索结果点击事件
|
||||
class KeywordClickEvent {
|
||||
final String event = EventType.keywordClick.event;
|
||||
late final String eventId;
|
||||
|
||||
/// 关联关键词
|
||||
final String keyword;
|
||||
|
||||
/// 点击项目ID(视频ID/小说ID等)
|
||||
final String clickItemId;
|
||||
|
||||
/// 点击项目类型,可用预定义常量或自定义:
|
||||
/// `ClickItemTypeEnum.video` / `ClickItemTypeEnum('manga', '漫画类')`
|
||||
final ClickItemTypeEnum clickItemType;
|
||||
|
||||
/// 点击位置(搜索结果中的排序位置)
|
||||
final int clickPosition;
|
||||
|
||||
/// 搜索引擎 trace ID,未接搜索引擎时传空字符串
|
||||
final String searchTraceId;
|
||||
|
||||
late final Map<String, dynamic> _commonFields;
|
||||
|
||||
KeywordClickEvent({
|
||||
required this.keyword,
|
||||
required this.clickItemId,
|
||||
required this.clickItemType,
|
||||
required this.clickPosition,
|
||||
this.searchTraceId = '',
|
||||
}) {
|
||||
_commonFields = SdkContext.generateCommonFields(event);
|
||||
|
||||
eventId = SdkContext.generateEventIdFromCommonFields(_commonFields, [
|
||||
keyword,
|
||||
clickItemId,
|
||||
clickItemType.key,
|
||||
clickItemType.name,
|
||||
clickPosition.toString(),
|
||||
searchTraceId,
|
||||
]);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
Map<String, dynamic> payload = {
|
||||
'keyword': keyword,
|
||||
'click_item_id': clickItemId,
|
||||
'click_item_type_key': clickItemType.key,
|
||||
'click_item_type_name': clickItemType.name,
|
||||
'click_position': clickPosition,
|
||||
'search_trace_id': searchTraceId,
|
||||
};
|
||||
return {..._commonFields, 'event_id': eventId, "payload": payload};
|
||||
}
|
||||
|
||||
factory KeywordClickEvent.fromJson(Map<String, dynamic> json) {
|
||||
return KeywordClickEvent(
|
||||
keyword: json['keyword'] as String,
|
||||
clickItemId: json['click_item_id'] as String,
|
||||
clickItemType: ClickItemTypeEnum(
|
||||
json['click_item_type_key'] as String,
|
||||
json['click_item_type_name'] as String,
|
||||
),
|
||||
clickPosition: json['click_position'] as int,
|
||||
searchTraceId: json['search_trace_id'] as String? ?? '',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import 'package:analytics_sdk/const/event_type.dart';
|
||||
import 'package:analytics_sdk/manager/sdk_context.dart';
|
||||
|
||||
/// 关键词搜索事件
|
||||
class KeywordSearchEvent {
|
||||
final String event = EventType.keywordSearch.event;
|
||||
late final String eventId;
|
||||
|
||||
/// 搜索关键词
|
||||
final String keyword;
|
||||
|
||||
/// 搜索结果数量
|
||||
final int searchResultCount;
|
||||
|
||||
/// 搜索引擎 trace ID,未接搜索引擎时传空字符串
|
||||
final String searchTraceId;
|
||||
|
||||
/// 搜索引擎 search ID,未接搜索引擎时传空字符串
|
||||
final String searchId;
|
||||
|
||||
/// 搜索列表的内容类型:video / novel / comic
|
||||
/// 未接搜索推荐引擎时传空字符串
|
||||
final String searchContentType;
|
||||
|
||||
late final Map<String, dynamic> _commonFields;
|
||||
|
||||
KeywordSearchEvent({
|
||||
required this.keyword,
|
||||
required this.searchResultCount,
|
||||
this.searchTraceId = '',
|
||||
this.searchId = '',
|
||||
this.searchContentType = '',
|
||||
}) {
|
||||
_commonFields = SdkContext.generateCommonFields(event);
|
||||
|
||||
eventId = SdkContext.generateEventIdFromCommonFields(_commonFields, [
|
||||
keyword,
|
||||
searchResultCount.toString(),
|
||||
searchTraceId,
|
||||
searchId,
|
||||
searchContentType,
|
||||
]);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
Map<String, dynamic> payload = {
|
||||
'keyword': keyword,
|
||||
'search_result_count': searchResultCount,
|
||||
'search_trace_id': searchTraceId,
|
||||
'search_id': searchId,
|
||||
'search_content_type': searchContentType,
|
||||
};
|
||||
return {..._commonFields, 'event_id': eventId, "payload": payload};
|
||||
}
|
||||
|
||||
factory KeywordSearchEvent.fromJson(Map<String, dynamic> json) {
|
||||
return KeywordSearchEvent(
|
||||
keyword: json['keyword'] as String,
|
||||
searchResultCount: json['search_result_count'] as int,
|
||||
searchTraceId: json['search_trace_id'] as String? ?? '',
|
||||
searchId: json['search_id'] as String? ?? '',
|
||||
searchContentType: json['search_content_type'] as String? ?? '',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import 'package:analytics_sdk/const/event_type.dart';
|
||||
import 'package:analytics_sdk/manager/page_name_manager.dart';
|
||||
import 'package:analytics_sdk/manager/sdk_context.dart';
|
||||
|
||||
/// 导航菜单点击事件(纯手动上报)。
|
||||
///
|
||||
/// 仅在用户主动点击导航菜单时触发:底部 Tab、顶部 Tab、侧边抽屉等。
|
||||
/// SDK 不会在路由跳转时自动发送此事件;触发方式:
|
||||
/// - [AnalyticsSdk.trackNavigation] 手动调用
|
||||
/// - [BottomNavigationBar.withAnalytics] / [TabBar.withAnalytics] 包装器内部自动处理
|
||||
///
|
||||
/// **page_key / page_name 填写规范**
|
||||
///
|
||||
/// 建议使用 [AppPageEnum] 预定义值,也可直接传入自定义字符串:
|
||||
/// ```dart
|
||||
/// NavigationEvent(
|
||||
/// navigationKey: AppPageEnum.video.key, // 'video'
|
||||
/// navigationName: AppPageEnum.video.name, // '影视'
|
||||
/// )
|
||||
/// ```
|
||||
///
|
||||
/// **上报时序**:应在同次导航的 [AppPageViewEvent] 之前发出。
|
||||
class NavigationEvent {
|
||||
final String event = EventType.navigation.event;
|
||||
late final String eventId;
|
||||
|
||||
/// 导航目标的唯一标识,与对应页面的 [AppPageViewEvent.pageKey] 保持一致。
|
||||
///
|
||||
/// 示例:`home`(首页)、`video`(影视)、`video_hot`(影视·热门子 Tab)
|
||||
final String navigationKey;
|
||||
|
||||
/// 导航目标的展示名称。
|
||||
///
|
||||
/// 示例:`首页`、`影视`、`影视·热门`
|
||||
final String navigationName;
|
||||
|
||||
late final Map<String, dynamic> _commonFields;
|
||||
|
||||
NavigationEvent({
|
||||
required String navigationKey,
|
||||
required this.navigationName,
|
||||
}) : navigationKey = PageNameMapper.normalizeKey(navigationKey) {
|
||||
_commonFields = SdkContext.generateCommonFields(event);
|
||||
|
||||
eventId = SdkContext.generateEventIdFromCommonFields(_commonFields, [
|
||||
this.navigationKey,
|
||||
navigationName,
|
||||
]);
|
||||
}
|
||||
|
||||
/// 序列化为上报 JSON;payload 包含 `navigation_key` 和 `navigation_name`。
|
||||
Map<String, dynamic> toJson() {
|
||||
Map<String, dynamic> payload = {
|
||||
'navigation_key': navigationKey,
|
||||
'navigation_name': navigationName,
|
||||
};
|
||||
return {..._commonFields, 'event_id': eventId, "payload": payload};
|
||||
}
|
||||
|
||||
/// 从 JSON 反序列化(通常用于缓存恢复场景)。
|
||||
factory NavigationEvent.fromJson(Map<String, dynamic> json) {
|
||||
return NavigationEvent(
|
||||
navigationKey: json['navigation_key'] as String,
|
||||
navigationName: json['navigation_name'] as String,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:analytics_sdk/const/event_type.dart';
|
||||
import 'package:analytics_sdk/manager/sdk_context.dart';
|
||||
import 'package:analytics_sdk/utils/network_type_resolver.dart';
|
||||
|
||||
/// 网络状态变化事件。
|
||||
///
|
||||
/// 文档要求除公共字段外,下表字段写入 `payload`。
|
||||
/// 完整设备环境、SIM 详情和当前页面信息不在本事件重传。
|
||||
class NetworkStateChangeEvent {
|
||||
final String event = EventType.networkStateChange.event;
|
||||
final Map<String, dynamic> _payloadFields;
|
||||
|
||||
late final String eventId;
|
||||
late final Map<String, dynamic> _commonFields;
|
||||
|
||||
NetworkStateChangeEvent({
|
||||
required String networkType,
|
||||
required String fromNetworkType,
|
||||
required int isOnline,
|
||||
required int changeCountInSid,
|
||||
String carrier = '',
|
||||
Map<String, dynamic> webConnectionFields = const {},
|
||||
}) : _payloadFields = _buildPayloadFields(
|
||||
networkType: networkType,
|
||||
fromNetworkType: fromNetworkType,
|
||||
isOnline: isOnline,
|
||||
changeCountInSid: changeCountInSid,
|
||||
carrier: carrier,
|
||||
webConnectionFields: webConnectionFields,
|
||||
) {
|
||||
_commonFields = SdkContext.generateCommonFields(event);
|
||||
eventId = SdkContext.generateEventIdFromCommonFields(_commonFields, [
|
||||
jsonEncode(_stablePayloadFields),
|
||||
]);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
..._commonFields,
|
||||
'event_id': eventId,
|
||||
'payload': Map<String, dynamic>.from(_payloadFields),
|
||||
};
|
||||
}
|
||||
|
||||
Map<String, dynamic> get _stablePayloadFields {
|
||||
final sortedKeys = _payloadFields.keys.toList()..sort();
|
||||
return {
|
||||
for (final key in sortedKeys) key: _payloadFields[key],
|
||||
};
|
||||
}
|
||||
|
||||
static Map<String, dynamic> _buildPayloadFields({
|
||||
required String networkType,
|
||||
required String fromNetworkType,
|
||||
required int isOnline,
|
||||
required int changeCountInSid,
|
||||
required String carrier,
|
||||
required Map<String, dynamic> webConnectionFields,
|
||||
}) {
|
||||
final normalizedNetworkType = _networkType(networkType);
|
||||
final fields = <String, dynamic>{
|
||||
'network_type': normalizedNetworkType,
|
||||
'carrier': carrier.trim(),
|
||||
'from_network_type': _networkType(fromNetworkType),
|
||||
'to_network_type': normalizedNetworkType,
|
||||
'is_online': isOnlineForNetworkType(normalizedNetworkType),
|
||||
'change_count_in_sid': changeCountInSid < 1 ? 1 : changeCountInSid,
|
||||
};
|
||||
|
||||
for (final key in const [
|
||||
'connection_type',
|
||||
'effective_type',
|
||||
'downlink',
|
||||
'rtt',
|
||||
]) {
|
||||
final value = webConnectionFields[key];
|
||||
if (value == null) continue;
|
||||
if (value is String && value.trim().isEmpty) continue;
|
||||
fields[key] = value;
|
||||
}
|
||||
return fields;
|
||||
}
|
||||
|
||||
static String _networkType(String value) {
|
||||
return normalizeNetworkType(value);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import 'package:analytics_sdk/const/event_type.dart';
|
||||
import 'package:analytics_sdk/manager/sdk_context.dart';
|
||||
import 'package:analytics_sdk/enum/read_behavior_enum.dart';
|
||||
|
||||
/// 小说事件
|
||||
class NovelEvent {
|
||||
final String event = EventType.novelEvent.event;
|
||||
late final String eventId;
|
||||
|
||||
/// 小说 id
|
||||
final String novelId;
|
||||
|
||||
/// 小说标题
|
||||
final String novelTitle;
|
||||
|
||||
/// 分类ID
|
||||
final String novelTypeId;
|
||||
|
||||
/// 分类名称
|
||||
final String novelTypeName;
|
||||
|
||||
/// 标签KEY, 多个标签使用英文逗号分隔
|
||||
final String novelTagKey;
|
||||
|
||||
/// 标签名称,多个标签使用英文逗号分隔
|
||||
final String novelTagName;
|
||||
|
||||
/// 推荐引擎的trace_id,未接推荐引擎传空字符串
|
||||
final String recommendTraceId;
|
||||
|
||||
/// 阅读进度百分比(0-100)
|
||||
final int readProgress;
|
||||
|
||||
/// 当前阅读第几页(从1开始)
|
||||
final int pageNo;
|
||||
|
||||
/// 行为标识:view(展示), page_next(下一页), page_prev(上一页), complete(读完) 等
|
||||
final ReadBehaviorEnum novelBehavior;
|
||||
|
||||
/// 章节ID,没有章节上报空字符串,没有对接搜索推荐引擎的上报空字符串
|
||||
final String chapterId;
|
||||
|
||||
/// 章节名称,没有章节上报空字符串,没有对接搜索推荐引擎的上报空字符串
|
||||
final String chapterName;
|
||||
|
||||
/// 老司机媒体资源ID,如未使用老司机库资源可传空字符串
|
||||
final String mediaId;
|
||||
|
||||
late final Map<String, dynamic> _commonFields;
|
||||
|
||||
NovelEvent({
|
||||
required this.novelId,
|
||||
required this.novelTitle,
|
||||
required this.novelTypeId,
|
||||
required this.novelTypeName,
|
||||
required this.novelTagKey,
|
||||
required this.novelTagName,
|
||||
required this.readProgress,
|
||||
required this.pageNo,
|
||||
required this.novelBehavior,
|
||||
this.recommendTraceId = '',
|
||||
this.mediaId = '',
|
||||
this.chapterId = '',
|
||||
this.chapterName = '',
|
||||
}) {
|
||||
_commonFields = SdkContext.generateCommonFields(event);
|
||||
|
||||
eventId = SdkContext.generateEventIdFromCommonFields(_commonFields, [
|
||||
novelId,
|
||||
novelTitle,
|
||||
novelTypeId,
|
||||
novelTypeName,
|
||||
novelTagKey,
|
||||
novelTagName,
|
||||
readProgress.toString(),
|
||||
pageNo.toString(),
|
||||
novelBehavior.key,
|
||||
novelBehavior.name,
|
||||
recommendTraceId,
|
||||
mediaId,
|
||||
chapterId,
|
||||
chapterName,
|
||||
]);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
Map<String, dynamic> payload = {
|
||||
'novel_id': novelId,
|
||||
'novel_title': novelTitle,
|
||||
'novel_type_id': novelTypeId,
|
||||
'novel_type_name': novelTypeName,
|
||||
'novel_tag_key': novelTagKey,
|
||||
'novel_tag_name': novelTagName,
|
||||
'recommend_trace_id': recommendTraceId,
|
||||
'read_progress': readProgress,
|
||||
'page_no': pageNo,
|
||||
'novel_behavior_key': novelBehavior.key,
|
||||
'novel_behavior_name': novelBehavior.name,
|
||||
'media_id': mediaId,
|
||||
'chapter_id': chapterId,
|
||||
'chapter_name': chapterName,
|
||||
};
|
||||
return {..._commonFields, 'event_id': eventId, "payload": payload};
|
||||
}
|
||||
|
||||
factory NovelEvent.fromJson(Map<String, dynamic> json) {
|
||||
final behaviorKey = json['novel_behavior_key'] as String? ?? '';
|
||||
final behaviorName = json['novel_behavior_name'] as String? ?? '';
|
||||
final behavior = ReadBehaviorEnum(
|
||||
behaviorKey.isNotEmpty ? behaviorKey : ReadBehaviorEnum.VIEW.key,
|
||||
behaviorName.isNotEmpty ? behaviorName : ReadBehaviorEnum.VIEW.name,
|
||||
);
|
||||
|
||||
return NovelEvent(
|
||||
novelId: json['novel_id'] as String,
|
||||
novelTitle: json['novel_title'] as String,
|
||||
novelTypeId: json['novel_type_id'] as String,
|
||||
novelTypeName: json['novel_type_name'] as String,
|
||||
novelTagKey: json['novel_tag_key'] as String,
|
||||
novelTagName: json['novel_tag_name'] as String,
|
||||
readProgress: json['read_progress'] as int,
|
||||
pageNo: json['page_no'] as int,
|
||||
novelBehavior: behavior,
|
||||
recommendTraceId: json['recommend_trace_id'] as String? ?? '',
|
||||
mediaId: json['media_id'] as String? ?? '',
|
||||
chapterId: json['chapter_id'] as String? ?? '',
|
||||
chapterName: json['chapter_name'] as String? ?? '',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
import 'package:analytics_sdk/manager/page_name_manager.dart';
|
||||
import 'package:analytics_sdk/const/event_type.dart';
|
||||
import 'package:analytics_sdk/manager/page_click_auto_fields.dart';
|
||||
import 'package:analytics_sdk/manager/sdk_context.dart';
|
||||
|
||||
/// 应用页面点击日志 (自动)
|
||||
class PageClickEvent {
|
||||
final String event = EventType.pageClick.event;
|
||||
late final String eventId;
|
||||
|
||||
final String pageKey;
|
||||
|
||||
/// 页面名称:首页, 我的, 视频页
|
||||
final String pageName;
|
||||
|
||||
/// 点击位置坐标x轴
|
||||
final int clickPageX;
|
||||
|
||||
/// 点击位置坐标y轴
|
||||
final int clickPageY;
|
||||
|
||||
/// SDK 标准化点击相对当前页面根容器可视区域左上角的 X 坐标。
|
||||
final int clickViewportX;
|
||||
|
||||
/// SDK 标准化点击相对当前页面根容器可视区域左上角的 Y 坐标。
|
||||
final int clickViewportY;
|
||||
|
||||
/// 点击相对当前页面主内容完整区域左上角的 X 坐标。
|
||||
final int clickDocumentX;
|
||||
|
||||
/// 点击相对当前页面主内容完整区域左上角的 Y 坐标。
|
||||
final int clickDocumentY;
|
||||
|
||||
/// 点击位置横坐标百分比(0-100)
|
||||
final int clickXPercent;
|
||||
|
||||
/// 点击位置纵坐标百分比(0-100)
|
||||
final int clickYPercent;
|
||||
|
||||
/// 屏幕宽度(像素)
|
||||
final int screenWidth;
|
||||
|
||||
/// 屏幕高度(像素)
|
||||
final int screenHeight;
|
||||
|
||||
/// 点击时当前页面根容器可交互可视区域宽度。
|
||||
final int viewportWidth;
|
||||
|
||||
/// 点击时当前页面根容器可交互可视区域高度。
|
||||
final int viewportHeight;
|
||||
|
||||
/// 点击时当前页面主内容容器完整宽度。
|
||||
final int pageWidth;
|
||||
|
||||
/// 点击时当前页面主内容容器完整高度。
|
||||
final int pageHeight;
|
||||
|
||||
/// 点击时当前页面主内容容器纵向滚动偏移。
|
||||
final int scrollTop;
|
||||
|
||||
/// 点击时当前页面主内容容器横向滚动偏移。
|
||||
final int scrollLeft;
|
||||
|
||||
/// 推荐引擎的trace_id,有多个推荐列表时用英文逗号分隔,未接推荐引擎传空字符串
|
||||
final String recommendTraceId;
|
||||
|
||||
late final Map<String, dynamic> _commonFields;
|
||||
|
||||
PageClickEvent({
|
||||
required String pageKey,
|
||||
required this.pageName,
|
||||
required this.clickPageX,
|
||||
required this.clickPageY,
|
||||
required this.clickXPercent,
|
||||
required this.clickYPercent,
|
||||
required this.screenWidth,
|
||||
required this.screenHeight,
|
||||
int? clickViewportX,
|
||||
int? clickViewportY,
|
||||
int? clickDocumentX,
|
||||
int? clickDocumentY,
|
||||
int? viewportWidth,
|
||||
int? viewportHeight,
|
||||
int? pageWidth,
|
||||
int? pageHeight,
|
||||
int? scrollTop,
|
||||
int? scrollLeft,
|
||||
this.recommendTraceId = '',
|
||||
}) : pageKey = PageNameMapper.normalizeKey(pageKey),
|
||||
clickViewportX = _coordinateOrFallback(clickViewportX, clickPageX),
|
||||
clickViewportY = _coordinateOrFallback(clickViewportY, clickPageY),
|
||||
viewportWidth = _dimensionOrFallback(viewportWidth, screenWidth),
|
||||
viewportHeight = _dimensionOrFallback(viewportHeight, screenHeight),
|
||||
clickDocumentX = _hasInvalidHorizontalDocumentMetrics(
|
||||
pageWidth: pageWidth,
|
||||
viewportWidth: viewportWidth,
|
||||
screenWidth: screenWidth,
|
||||
scrollLeft: scrollLeft,
|
||||
)
|
||||
? -1
|
||||
: _unknownable(clickDocumentX),
|
||||
clickDocumentY = _unknownable(clickDocumentY),
|
||||
pageWidth = _hasInvalidHorizontalDocumentMetrics(
|
||||
pageWidth: pageWidth,
|
||||
viewportWidth: viewportWidth,
|
||||
screenWidth: screenWidth,
|
||||
scrollLeft: scrollLeft,
|
||||
)
|
||||
? -1
|
||||
: _unknownable(pageWidth),
|
||||
pageHeight = _unknownable(pageHeight),
|
||||
scrollTop = _unknownable(scrollTop),
|
||||
scrollLeft = _hasInvalidHorizontalDocumentMetrics(
|
||||
pageWidth: pageWidth,
|
||||
viewportWidth: viewportWidth,
|
||||
screenWidth: screenWidth,
|
||||
scrollLeft: scrollLeft,
|
||||
)
|
||||
? -1
|
||||
: _unknownable(scrollLeft) {
|
||||
_commonFields = SdkContext.generateCommonFields(event);
|
||||
|
||||
eventId = SdkContext.generateEventIdFromCommonFields(_commonFields, [
|
||||
this.pageKey,
|
||||
pageName,
|
||||
clickPageX.toString(),
|
||||
clickPageY.toString(),
|
||||
this.clickViewportX.toString(),
|
||||
this.clickViewportY.toString(),
|
||||
this.clickDocumentX.toString(),
|
||||
this.clickDocumentY.toString(),
|
||||
clickXPercent.toString(),
|
||||
clickYPercent.toString(),
|
||||
screenWidth.toString(),
|
||||
screenHeight.toString(),
|
||||
this.viewportWidth.toString(),
|
||||
this.viewportHeight.toString(),
|
||||
this.pageWidth.toString(),
|
||||
this.pageHeight.toString(),
|
||||
this.scrollTop.toString(),
|
||||
this.scrollLeft.toString(),
|
||||
recommendTraceId,
|
||||
]);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
Map<String, dynamic> payload = {
|
||||
'page_key': pageKey,
|
||||
'page_name': pageName,
|
||||
'click_page_x': clickPageX,
|
||||
'click_page_y': clickPageY,
|
||||
'click_viewport_x': clickViewportX,
|
||||
'click_viewport_y': clickViewportY,
|
||||
'click_document_x': clickDocumentX,
|
||||
'click_document_y': clickDocumentY,
|
||||
'click_x_percent': clickXPercent,
|
||||
'click_y_percent': clickYPercent,
|
||||
'screen_width': screenWidth,
|
||||
'screen_height': screenHeight,
|
||||
'viewport_width': viewportWidth,
|
||||
'viewport_height': viewportHeight,
|
||||
'page_width': pageWidth,
|
||||
'page_height': pageHeight,
|
||||
'scroll_top': scrollTop,
|
||||
'scroll_left': scrollLeft,
|
||||
'recommend_trace_id': recommendTraceId,
|
||||
};
|
||||
return {
|
||||
..._commonFields,
|
||||
'event_id': eventId,
|
||||
"payload": payload,
|
||||
};
|
||||
}
|
||||
|
||||
factory PageClickEvent.fromJson(Map<String, dynamic> json) {
|
||||
return PageClickEvent(
|
||||
pageKey: json['page_key'] as String,
|
||||
pageName: json['page_name'] as String,
|
||||
clickPageX: json['click_page_x'] as int,
|
||||
clickPageY: json['click_page_y'] as int,
|
||||
clickXPercent: json['click_x_percent'] as int,
|
||||
clickYPercent: json['click_y_percent'] as int,
|
||||
screenWidth: json['screen_width'] as int,
|
||||
screenHeight: json['screen_height'] as int,
|
||||
clickViewportX: json['click_viewport_x'] as int?,
|
||||
clickViewportY: json['click_viewport_y'] as int?,
|
||||
clickDocumentX: json['click_document_x'] as int?,
|
||||
clickDocumentY: json['click_document_y'] as int?,
|
||||
viewportWidth: json['viewport_width'] as int?,
|
||||
viewportHeight: json['viewport_height'] as int?,
|
||||
pageWidth: json['page_width'] as int?,
|
||||
pageHeight: json['page_height'] as int?,
|
||||
scrollTop: json['scroll_top'] as int?,
|
||||
scrollLeft: json['scroll_left'] as int?,
|
||||
recommendTraceId: json['recommend_trace_id'] as String? ?? '',
|
||||
);
|
||||
}
|
||||
|
||||
static PageClickEvent fromViewport({
|
||||
required String pageKey,
|
||||
required String pageName,
|
||||
required int clickPageX,
|
||||
required int clickPageY,
|
||||
required int clickViewportX,
|
||||
required int clickViewportY,
|
||||
required int viewportWidth,
|
||||
required int viewportHeight,
|
||||
int? clickDocumentX,
|
||||
int? clickDocumentY,
|
||||
int? pageWidth,
|
||||
int? pageHeight,
|
||||
int? scrollTop,
|
||||
int? scrollLeft,
|
||||
String recommendTraceId = '',
|
||||
}) {
|
||||
final documentMetrics = currentClickDocumentMetrics(
|
||||
clickViewportX: clickViewportX,
|
||||
clickViewportY: clickViewportY,
|
||||
);
|
||||
final clickXPercent = viewportWidth > 0
|
||||
? (clickViewportX / viewportWidth * 100).round().clamp(0, 100)
|
||||
: 0;
|
||||
final clickYPercent = viewportHeight > 0
|
||||
? (clickViewportY / viewportHeight * 100).round().clamp(0, 100)
|
||||
: 0;
|
||||
return PageClickEvent(
|
||||
pageKey: pageKey,
|
||||
pageName: pageName,
|
||||
clickPageX: clickPageX,
|
||||
clickPageY: clickPageY,
|
||||
clickXPercent: clickXPercent,
|
||||
clickYPercent: clickYPercent,
|
||||
screenWidth: viewportWidth,
|
||||
screenHeight: viewportHeight,
|
||||
clickViewportX: clickViewportX,
|
||||
clickViewportY: clickViewportY,
|
||||
clickDocumentX: clickDocumentX ?? documentMetrics.clickDocumentX,
|
||||
clickDocumentY: clickDocumentY ?? documentMetrics.clickDocumentY,
|
||||
viewportWidth: viewportWidth,
|
||||
viewportHeight: viewportHeight,
|
||||
pageWidth: pageWidth ?? documentMetrics.pageWidth,
|
||||
pageHeight: pageHeight ?? documentMetrics.pageHeight,
|
||||
scrollTop: scrollTop ?? documentMetrics.scrollTop,
|
||||
scrollLeft: scrollLeft ?? documentMetrics.scrollLeft,
|
||||
recommendTraceId: recommendTraceId,
|
||||
);
|
||||
}
|
||||
|
||||
static int _coordinateOrFallback(int? value, int fallback) {
|
||||
final resolved = value ?? fallback;
|
||||
return resolved < 0 ? -1 : resolved;
|
||||
}
|
||||
|
||||
static int _dimensionOrFallback(int? value, int fallback) {
|
||||
final resolved = value ?? fallback;
|
||||
return resolved < 0 ? -1 : resolved;
|
||||
}
|
||||
|
||||
static int _unknownable(int? value) {
|
||||
if (value == null || value < 0) return -1;
|
||||
return value;
|
||||
}
|
||||
|
||||
static bool _hasInvalidHorizontalDocumentMetrics({
|
||||
required int? pageWidth,
|
||||
required int? viewportWidth,
|
||||
required int screenWidth,
|
||||
required int? scrollLeft,
|
||||
}) {
|
||||
final resolvedPageWidth = _unknownable(pageWidth);
|
||||
final resolvedViewportWidth =
|
||||
_dimensionOrFallback(viewportWidth, screenWidth);
|
||||
final resolvedScrollLeft = _unknownable(scrollLeft);
|
||||
|
||||
if (resolvedPageWidth < 0 ||
|
||||
resolvedViewportWidth < 0 ||
|
||||
resolvedScrollLeft <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
final maxScrollLeft = resolvedPageWidth - resolvedViewportWidth;
|
||||
return maxScrollLeft < 0 || resolvedScrollLeft > maxScrollLeft;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import 'package:analytics_sdk/manager/page_name_manager.dart';
|
||||
import 'package:analytics_sdk/const/event_type.dart';
|
||||
import 'package:analytics_sdk/manager/sdk_context.dart';
|
||||
import 'package:analytics_sdk/enum/recommend_content_type_enum.dart';
|
||||
|
||||
/// 推荐引擎列表点击事件(服务端/客户端)
|
||||
class RecommendListClickEvent {
|
||||
/// 事件类型(固定)
|
||||
final String event = EventType.recommendListClick.event;
|
||||
|
||||
/// 去重 ID(动态计算)
|
||||
late final String eventId;
|
||||
|
||||
/// 页面标识:page_home(首页), page_user(我的), page_video(视频页)等
|
||||
final String pageKey;
|
||||
|
||||
/// 页面名称:首页, 我的, 视频页
|
||||
final String pageName;
|
||||
|
||||
/// 推荐列表的类型: video(视频),novel(小说),comic(漫画)
|
||||
final RecommendContentTypeEnum recommendContentType;
|
||||
|
||||
/// 推荐引擎的trace_id,未接推荐引擎传空字符串
|
||||
final String recommendTraceId;
|
||||
|
||||
/// 点击内容的ID
|
||||
final String recommendId;
|
||||
|
||||
/// 推荐引擎的trace_info,未接推荐引擎传空字符串
|
||||
final String recommendTraceInfo;
|
||||
|
||||
/// 客户端版本,三位数字点分隔,例如:1.0.1
|
||||
final String clientVersion;
|
||||
|
||||
late final Map<String, dynamic> _commonFields;
|
||||
|
||||
RecommendListClickEvent({
|
||||
required String pageKey,
|
||||
required this.pageName,
|
||||
required this.recommendContentType,
|
||||
required this.recommendId,
|
||||
this.recommendTraceId = '',
|
||||
this.recommendTraceInfo = '',
|
||||
this.clientVersion = '',
|
||||
}) : pageKey = PageNameMapper.normalizeKey(pageKey) {
|
||||
_commonFields = SdkContext.generateCommonFields(event);
|
||||
|
||||
eventId = SdkContext.generateEventIdFromCommonFields(_commonFields, [
|
||||
pageKey,
|
||||
pageName,
|
||||
recommendContentType.label,
|
||||
recommendTraceId,
|
||||
recommendId,
|
||||
recommendTraceInfo,
|
||||
clientVersion,
|
||||
]);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final payload = <String, dynamic>{
|
||||
'page_key': pageKey,
|
||||
'page_name': pageName,
|
||||
'recommend_content_type': recommendContentType.label,
|
||||
'recommend_trace_id': recommendTraceId,
|
||||
'recommend_id': recommendId,
|
||||
'recommend_trace_info': recommendTraceInfo,
|
||||
'client_version': clientVersion,
|
||||
};
|
||||
return {..._commonFields, 'event_id': eventId, 'payload': payload};
|
||||
}
|
||||
|
||||
factory RecommendListClickEvent.fromJson(Map<String, dynamic> json) {
|
||||
final contentTypeStr = json['recommend_content_type'] as String? ?? '';
|
||||
final contentType = RecommendContentTypeEnum(
|
||||
contentTypeStr.isNotEmpty ? contentTypeStr : RecommendContentTypeEnum.VIDEO.label,
|
||||
);
|
||||
return RecommendListClickEvent(
|
||||
pageKey: json['page_key'] as String,
|
||||
pageName: json['page_name'] as String,
|
||||
recommendContentType: contentType,
|
||||
recommendTraceId: json['recommend_trace_id'] as String? ?? '',
|
||||
recommendId: json['recommend_id'] as String? ?? '',
|
||||
recommendTraceInfo: json['recommend_trace_info'] as String? ?? '',
|
||||
clientVersion: json['client_version'] as String? ?? '',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import 'package:analytics_sdk/manager/page_name_manager.dart';
|
||||
import 'package:analytics_sdk/const/event_type.dart';
|
||||
import 'package:analytics_sdk/manager/sdk_context.dart';
|
||||
import 'package:analytics_sdk/enum/recommend_content_type_enum.dart';
|
||||
|
||||
/// 推荐列表内容展示事件(服务端/客户端)
|
||||
class RecommendListViewEvent {
|
||||
/// 事件类型(固定)
|
||||
final String event = EventType.recommendListView.event;
|
||||
|
||||
/// 去重 ID(动态计算)
|
||||
late final String eventId;
|
||||
|
||||
/// 页面标识:home(首页), video_detail(视频详情), user_center(个人中心), discover(发现页)等
|
||||
final String pageKey;
|
||||
|
||||
/// 页面名称:首页, 视频详情页, 个人中心, 发现页
|
||||
final String pageName;
|
||||
|
||||
/// 推荐列表的类型: video(视频),novel(小说),comic(漫画)
|
||||
final RecommendContentTypeEnum recommendContentType;
|
||||
|
||||
/// 推荐引擎的trace_id
|
||||
final String recommendTraceId;
|
||||
|
||||
/// 推荐引擎列表的video_id(视频),novel_id(小说),comic_id(漫画), 可视窗口有多个,使用英文逗号分隔
|
||||
final String recommendId;
|
||||
|
||||
/// 推荐引擎的trace_info,未接推荐引擎传空字符串
|
||||
final String recommendTraceInfo;
|
||||
|
||||
/// 客户端版本,三位数字点分隔,例如:1.0.1
|
||||
final String clientVersion;
|
||||
|
||||
late final Map<String, dynamic> _commonFields;
|
||||
|
||||
RecommendListViewEvent({
|
||||
required String pageKey,
|
||||
required this.pageName,
|
||||
required this.recommendContentType,
|
||||
required this.recommendId,
|
||||
this.recommendTraceId = '',
|
||||
this.recommendTraceInfo = '',
|
||||
this.clientVersion = '',
|
||||
}) : pageKey = PageNameMapper.normalizeKey(pageKey) {
|
||||
_commonFields = SdkContext.generateCommonFields(event);
|
||||
|
||||
eventId = SdkContext.generateEventIdFromCommonFields(_commonFields, [
|
||||
pageKey,
|
||||
pageName,
|
||||
recommendContentType.label,
|
||||
recommendTraceId,
|
||||
recommendId,
|
||||
recommendTraceInfo,
|
||||
clientVersion,
|
||||
]);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final payload = <String, dynamic>{
|
||||
'event': event,
|
||||
'page_key': pageKey,
|
||||
'page_name': pageName,
|
||||
'recommend_content_type': recommendContentType.label,
|
||||
'recommend_trace_id': recommendTraceId,
|
||||
'recommend_id': recommendId,
|
||||
'recommend_trace_info': recommendTraceInfo,
|
||||
'client_version': clientVersion,
|
||||
};
|
||||
return {..._commonFields, 'event_id': eventId, 'payload': payload};
|
||||
}
|
||||
|
||||
factory RecommendListViewEvent.fromJson(Map<String, dynamic> json) {
|
||||
final contentTypeStr = json['recommend_content_type'] as String? ?? '';
|
||||
final contentType = RecommendContentTypeEnum(
|
||||
contentTypeStr.isNotEmpty ? contentTypeStr : RecommendContentTypeEnum.VIDEO.label,
|
||||
);
|
||||
return RecommendListViewEvent(
|
||||
pageKey: json['page_key'] as String,
|
||||
pageName: json['page_name'] as String,
|
||||
recommendContentType: contentType,
|
||||
recommendTraceId: json['recommend_trace_id'] as String,
|
||||
recommendId: json['recommend_id'] as String,
|
||||
recommendTraceInfo: json['recommend_trace_info'] as String? ?? '',
|
||||
clientVersion: json['client_version'] as String? ?? '',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:analytics_sdk/const/event_type.dart';
|
||||
import 'package:analytics_sdk/manager/sdk_context.dart';
|
||||
|
||||
/// SDK 初始化快照事件。
|
||||
///
|
||||
/// `sdk_init` 不携带 `device_env`,硬件与环境快照字段直接写入 payload。
|
||||
class SdkInitEvent {
|
||||
final String event = EventType.sdkInit.event;
|
||||
late final String eventId;
|
||||
|
||||
final Map<String, dynamic> payload;
|
||||
|
||||
late final Map<String, dynamic> _commonFields;
|
||||
|
||||
SdkInitEvent({required Map<String, dynamic> payload})
|
||||
: payload = Map<String, dynamic>.from(payload) {
|
||||
_commonFields = SdkContext.generateCommonFields(event);
|
||||
eventId = SdkContext.generateEventIdFromCommonFields(_commonFields, [
|
||||
jsonEncode(this.payload),
|
||||
]);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
..._commonFields,
|
||||
'event_id': eventId,
|
||||
'payload': payload,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import 'package:analytics_sdk/const/event_type.dart';
|
||||
import 'package:analytics_sdk/manager/sdk_context.dart';
|
||||
import 'package:analytics_sdk/enum/video_content_type_enum.dart';
|
||||
import 'package:analytics_sdk/enum/video_event_enum.dart';
|
||||
|
||||
/// 视频事件 (手动)
|
||||
class VideoEvent {
|
||||
final String event = EventType.videoEvent.event;
|
||||
late final String eventId;
|
||||
|
||||
final String videoId;
|
||||
|
||||
/// 视频标题
|
||||
final String videoTitle;
|
||||
|
||||
/// 视频分类ID
|
||||
final String videoTypeId;
|
||||
|
||||
/// 视频分类名称
|
||||
final String videoTypeName;
|
||||
|
||||
/// 标签名称,多个标签使用英文逗号分隔
|
||||
final String videoTagName;
|
||||
|
||||
/// 标签KEY, 多个标签使用英文逗号分隔
|
||||
final String videoTagKey;
|
||||
|
||||
/// 视频总时长(秒)
|
||||
final int videoDuration;
|
||||
|
||||
/// 本次播放时长(秒)
|
||||
final int playDuration;
|
||||
|
||||
/// 播放进度百分比(0-100)
|
||||
final int playProgress;
|
||||
|
||||
/// 视频行为标识:video_play(播放), video_pause(暂停), video_share(分享), video_complete(播放完成), video_forward(快进), video_rewind(快退)等
|
||||
final VideoEventEnum videoBehavior;
|
||||
|
||||
/// 视频内容类型:video(长视频), short_video(短视频)
|
||||
final VideoContentTypeEnum? videoContentType;
|
||||
|
||||
/// 推荐引擎的trace_id,未接推荐引擎传空字符串
|
||||
final String recommendTraceId;
|
||||
|
||||
/// 老司机媒体资源ID,视频分析日志新加字段,可为空字符串
|
||||
final String mediaId;
|
||||
|
||||
late final Map<String, dynamic> _commonFields;
|
||||
|
||||
VideoEvent(
|
||||
{required this.videoId,
|
||||
required this.videoTitle,
|
||||
required this.videoTypeId,
|
||||
required this.videoTypeName,
|
||||
required this.videoTagKey,
|
||||
required this.videoTagName,
|
||||
required this.videoDuration,
|
||||
required this.playDuration,
|
||||
required this.playProgress,
|
||||
required this.videoBehavior,
|
||||
required this.videoContentType,
|
||||
this.recommendTraceId = '',
|
||||
this.mediaId = ''}) {
|
||||
_commonFields = SdkContext.generateCommonFields(event);
|
||||
|
||||
eventId = SdkContext.generateEventIdFromCommonFields(_commonFields, [
|
||||
videoId,
|
||||
videoTitle,
|
||||
videoTypeId,
|
||||
videoTypeName,
|
||||
videoTagKey,
|
||||
videoTagName,
|
||||
videoDuration.toString(),
|
||||
playDuration.toString(),
|
||||
playProgress.toString(),
|
||||
videoBehavior.key,
|
||||
videoBehavior.name,
|
||||
videoContentType?.label ?? '',
|
||||
recommendTraceId,
|
||||
]);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
Map<String, dynamic> payload = {
|
||||
'video_id': videoId,
|
||||
'video_title': videoTitle,
|
||||
'video_type_id': videoTypeId,
|
||||
'video_type_name': videoTypeName,
|
||||
'video_tag_key': videoTagKey,
|
||||
'video_tag_name': videoTagName,
|
||||
'video_duration': videoDuration,
|
||||
'play_duration': playDuration,
|
||||
'play_progress': playProgress,
|
||||
'video_behavior_key': videoBehavior.key,
|
||||
'video_behavior_name': videoBehavior.name,
|
||||
'video_content_type': videoContentType?.label ?? '',
|
||||
'recommend_trace_id': recommendTraceId,
|
||||
'media_id': mediaId,
|
||||
};
|
||||
return {..._commonFields, 'event_id': eventId, "payload": payload};
|
||||
}
|
||||
|
||||
factory VideoEvent.fromJson(Map<String, dynamic> json) {
|
||||
final behaviorKey = json['video_behavior_key'] as String? ?? '';
|
||||
final behaviorName = json['video_behavior_name'] as String? ?? '';
|
||||
final behavior = VideoEventEnum(
|
||||
behaviorKey.isNotEmpty ? behaviorKey : VideoEventEnum.VIDEO_PLAY.key,
|
||||
behaviorName.isNotEmpty ? behaviorName : VideoEventEnum.VIDEO_PLAY.name,
|
||||
);
|
||||
|
||||
final contentTypeStr = json['video_content_type'] as String? ?? '';
|
||||
final VideoContentTypeEnum? contentType =
|
||||
contentTypeStr.isNotEmpty ? VideoContentTypeEnum(contentTypeStr) : null;
|
||||
|
||||
return VideoEvent(
|
||||
videoId: json['video_id'] as String,
|
||||
videoTitle: json['video_title'] as String,
|
||||
videoTypeId: json['video_type_id'] as String,
|
||||
videoTypeName: json['video_type_name'] as String,
|
||||
videoTagKey: json['video_tag_key'] as String,
|
||||
videoTagName: json['video_tag_name'] as String,
|
||||
videoDuration: json['video_duration'] as int,
|
||||
playDuration: json['play_duration'] as int,
|
||||
playProgress: json['play_progress'] as int,
|
||||
videoBehavior: behavior,
|
||||
videoContentType: contentType,
|
||||
recommendTraceId: json['recommend_trace_id'] as String? ?? '',
|
||||
mediaId: json['media_id'] as String? ?? '',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
/// 广告类型枚举
|
||||
/// 对应埋点中的 ad_type 字段:icon、banner、feed、player、splash、text
|
||||
enum AdTypeEnum {
|
||||
/// 图标广告
|
||||
icon(label: 'icon'),
|
||||
|
||||
/// 横幅广告
|
||||
banner(label: 'banner'),
|
||||
|
||||
/// 信息流广告
|
||||
feed(label: 'feed'),
|
||||
|
||||
/// 播放器内广告
|
||||
player(label: 'player'),
|
||||
|
||||
/// 开屏广告
|
||||
splash(label: 'splash'),
|
||||
|
||||
/// 文字广告
|
||||
text(label: 'text'),
|
||||
|
||||
;
|
||||
|
||||
/// 上报时使用的字符串值
|
||||
final String label;
|
||||
|
||||
const AdTypeEnum({
|
||||
required this.label,
|
||||
});
|
||||
|
||||
/// 根据字符串解析为枚举,未匹配时返回 null
|
||||
static AdTypeEnum? fromLabel(String? value) {
|
||||
if (value == null || value.isEmpty) return null;
|
||||
for (final e in AdTypeEnum.values) {
|
||||
if (e.label == value) return e;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
/// 认证/注册/登录方式枚举
|
||||
/// 对应文档中的 type 字段:phone、deviceid、email、username
|
||||
enum AuthTypeEnum {
|
||||
/// 手机号
|
||||
PHONE(label: 'phone'),
|
||||
|
||||
/// 设备ID
|
||||
DEVICE_ID(label: 'deviceid'),
|
||||
|
||||
/// 邮箱
|
||||
EMAIL(label: 'email'),
|
||||
|
||||
/// 用户名/账号
|
||||
USERNAME(label: 'username'),
|
||||
|
||||
;
|
||||
|
||||
/// 上报时使用的字符串值
|
||||
final String label;
|
||||
|
||||
const AuthTypeEnum({
|
||||
required this.label,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
enum BannerEventEnum{
|
||||
CLICK(label: 'click'),
|
||||
CLOSE(label: 'close'),
|
||||
SHOW(label: 'show');
|
||||
|
||||
final String label;
|
||||
|
||||
const BannerEventEnum({
|
||||
required this.label,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
/// 点击项目类型。
|
||||
///
|
||||
/// 可直接使用预定义常量,也可传入自定义字符串:
|
||||
/// ```dart
|
||||
/// ClickItemTypeEnum.video // 预定义
|
||||
/// ClickItemTypeEnum('manga', '漫画类') // 自定义
|
||||
/// ```
|
||||
///
|
||||
/// 字段规则(debug 模式下触发 assert):
|
||||
/// - [key]:仅限字母、数字、`_`、`-`
|
||||
/// - [name]:字母、数字、`_`、`-`、中文
|
||||
class ClickItemTypeEnum {
|
||||
final String key;
|
||||
final String name;
|
||||
|
||||
const ClickItemTypeEnum._internal(this.key, this.name);
|
||||
|
||||
/// 自定义类型。[key] 仅限字母/数字/`_`/`-`;[name] 允许中文。
|
||||
factory ClickItemTypeEnum(String key, String name) {
|
||||
assert(
|
||||
RegExp(r'^[A-Za-z0-9_-]+$').hasMatch(key),
|
||||
'ClickItemTypeEnum.key 仅支持字母、数字、_ 和 -,实际传入: "$key"',
|
||||
);
|
||||
assert(
|
||||
RegExp(r'^[\u4e00-\u9fa5A-Za-z0-9_-]+$').hasMatch(name),
|
||||
'ClickItemTypeEnum.name 仅支持字母、数字、_、- 和中文,实际传入: "$name"',
|
||||
);
|
||||
return ClickItemTypeEnum._internal(key, name);
|
||||
}
|
||||
|
||||
static const VIDEO = ClickItemTypeEnum._internal('video', '视频');
|
||||
static const NOVEL = ClickItemTypeEnum._internal('novel', '小说');
|
||||
static const COMIC = ClickItemTypeEnum._internal('comic', '漫画');
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
/// 金币消耗原因枚举
|
||||
/// 对应文档中的 consume_reason_key 字段:
|
||||
/// - video_unlock(视频解锁)
|
||||
/// - gift_send(礼物赠送)
|
||||
/// - content_purchase(内容购买)
|
||||
enum ConsumeReasonEnum {
|
||||
/// 视频解锁
|
||||
VIDEO_UNLOCK(label: 'video_unlock'),
|
||||
|
||||
/// 礼物赠送
|
||||
GIFT_SEND(label: 'gift_send'),
|
||||
|
||||
/// 内容购买
|
||||
CONTENT_PURCHASE(label: 'content_purchase'),
|
||||
|
||||
;
|
||||
|
||||
/// 上报时使用的字符串值
|
||||
final String label;
|
||||
|
||||
const ConsumeReasonEnum({
|
||||
required this.label,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
/// 货币类型枚举
|
||||
/// 对应文档中的 currency 字段:CNY(人民币), USD(美元)
|
||||
enum CurrencyEnum {
|
||||
/// 人民币
|
||||
CNY(label: 'CNY'),
|
||||
|
||||
/// 美元
|
||||
USD(label: 'USD'),
|
||||
|
||||
;
|
||||
|
||||
/// 上报时使用的字符串值
|
||||
final String label;
|
||||
|
||||
const CurrencyEnum({
|
||||
required this.label,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
enum DeviceEnum {
|
||||
Android(label: 'Android'),
|
||||
iOS(label: 'iOS'),
|
||||
PC(label: 'PC');
|
||||
|
||||
final String label;
|
||||
|
||||
const DeviceEnum({
|
||||
required this.label,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
/// 订单类型枚举
|
||||
/// 对应文档中的 order_type 字段:coin_purchase(金币购买), vip_subscription(VIP订阅)
|
||||
enum OrderTypeEnum {
|
||||
/// 金币购买
|
||||
COIN_PURCHASE(label: 'coin_purchase'),
|
||||
|
||||
/// VIP 订阅
|
||||
VIP_SUBSCRIPTION(label: 'vip_subscription'),
|
||||
|
||||
;
|
||||
|
||||
/// 上报时使用的字符串值
|
||||
final String label;
|
||||
|
||||
const OrderTypeEnum({
|
||||
required this.label,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
/// 预定义页面路由枚举,提供标准 [key](页面唯一标识)和 [name](展示名称)。
|
||||
///
|
||||
/// **用法示例**
|
||||
///
|
||||
/// ```dart
|
||||
/// // 上报导航事件
|
||||
/// AnalyticsSdk.instance.trackNavigation(
|
||||
/// pageKey: AppPageEnum.video.key,
|
||||
/// pageName: AppPageEnum.video.name,
|
||||
/// );
|
||||
///
|
||||
/// // 配置底部/顶部 Tab
|
||||
/// BottomNavigationBar(...).withAnalytics(tabs: [
|
||||
/// AnalyticsTab(AppPageEnum.home.key, AppPageEnum.home.name),
|
||||
/// AnalyticsTab(AppPageEnum.video.key, AppPageEnum.video.name),
|
||||
/// AnalyticsTab(AppPageEnum.mine.key, AppPageEnum.mine.name),
|
||||
/// ]);
|
||||
///
|
||||
/// // PageView 子页面同步
|
||||
/// AnalyticsSdk.instance.updateCurrentPage(
|
||||
/// pageKey: AppPageEnum.videoList.key,
|
||||
/// pageName: AppPageEnum.videoList.name,
|
||||
/// );
|
||||
/// ```
|
||||
///
|
||||
/// **自定义页面**:直接传入字符串即可,无需扩展枚举:
|
||||
/// ```dart
|
||||
/// AnalyticsSdk.instance.trackNavigation(
|
||||
/// pageKey: 'live_room',
|
||||
/// pageName: '直播间',
|
||||
/// );
|
||||
/// ```
|
||||
enum AppPageEnum {
|
||||
// ── 公共类 ──────────────────────────────────────────────────
|
||||
|
||||
/// 启动页
|
||||
launch('launch', '启动页'),
|
||||
|
||||
/// 引导页(新用户首次打开时展示)
|
||||
guide('guide', '引导页'),
|
||||
|
||||
/// 首页(底部导航第一 Tab 常用值)
|
||||
home('home', '首页'),
|
||||
|
||||
/// 主页
|
||||
mainPage('main_page', '主页'),
|
||||
|
||||
/// 发现页
|
||||
discover('discover', '发现页'),
|
||||
|
||||
/// 搜索页(搜索入口页,含搜索框和热搜)
|
||||
search('search', '搜索页'),
|
||||
|
||||
/// 搜索结果页
|
||||
searchResult('search_result', '搜索结果页'),
|
||||
|
||||
/// 应用中心
|
||||
appCenter('app_center', '应用中心'),
|
||||
|
||||
/// 社区页
|
||||
community('community', '社区页'),
|
||||
|
||||
/// 广场页
|
||||
square('square', '广场页'),
|
||||
|
||||
/// 我的(底部导航"我的"Tab)
|
||||
mine('mine', '我的'),
|
||||
|
||||
/// 个人中心(登录用户的详情页)
|
||||
userCenter('user_center', '个人中心'),
|
||||
|
||||
/// 用户主页(查看他人主页)
|
||||
userHome('user_home', '用户主页'),
|
||||
|
||||
/// 作者主页
|
||||
authorHome('author_home', '作者主页'),
|
||||
|
||||
/// 登录页
|
||||
login('login', '登录页'),
|
||||
|
||||
/// 注册页
|
||||
register('register', '注册页'),
|
||||
|
||||
/// 消息中心
|
||||
messageCenter('message_center', '消息中心'),
|
||||
|
||||
/// 设置页
|
||||
setting('setting', '设置页'),
|
||||
|
||||
/// 充值页
|
||||
recharge('recharge', '充值页'),
|
||||
|
||||
/// 充值记录页
|
||||
rechargeRecord('recharge_record', '充值记录页'),
|
||||
|
||||
/// 我的收藏
|
||||
mineCollect('mine_collect', '我的收藏'),
|
||||
|
||||
/// 我的历史
|
||||
mineHistory('mine_history', '我的历史'),
|
||||
|
||||
/// 会员中心页
|
||||
mineVip('mine_vip', '会员中心页'),
|
||||
|
||||
// ── 视频类 ──────────────────────────────────────────────────
|
||||
|
||||
/// 视频列表页
|
||||
videoList('video_list', '视频列表页'),
|
||||
|
||||
/// 视频分类页
|
||||
videoCategory('video_category', '视频分类页'),
|
||||
|
||||
/// 视频榜单页
|
||||
videoRank('video_rank', '视频榜单页'),
|
||||
|
||||
/// 视频搜索页
|
||||
videoSearch('video_search', '视频搜索页'),
|
||||
|
||||
/// 视频详情页
|
||||
videoDetail('video_detail', '视频详情页'),
|
||||
|
||||
/// 视频播放页
|
||||
videoPlay('video_play', '视频播放页'),
|
||||
|
||||
// ── 小说类 ──────────────────────────────────────────────────
|
||||
|
||||
/// 小说列表页
|
||||
novelList('novel_list', '小说列表页'),
|
||||
|
||||
/// 小说分类页
|
||||
novelCategory('novel_category', '小说分类页'),
|
||||
|
||||
/// 小说榜单页
|
||||
novelRank('novel_rank', '小说榜单页'),
|
||||
|
||||
/// 小说详情页
|
||||
novelDetail('novel_detail', '小说详情页'),
|
||||
|
||||
/// 小说阅读页
|
||||
novelRead('novel_read', '小说阅读页'),
|
||||
|
||||
/// 小说章节页
|
||||
novelChapterList('novel_chapter_list', '小说章节页'),
|
||||
|
||||
// ── 漫画类 ──────────────────────────────────────────────────
|
||||
|
||||
/// 漫画列表页
|
||||
comicList('comic_list', '漫画列表页'),
|
||||
|
||||
/// 漫画分类页
|
||||
comicCategory('comic_category', '漫画分类页'),
|
||||
|
||||
/// 漫画榜单页
|
||||
comicRank('comic_rank', '漫画榜单页'),
|
||||
|
||||
/// 漫画详情页
|
||||
comicDetail('comic_detail', '漫画详情页'),
|
||||
|
||||
/// 漫画阅读页
|
||||
comicRead('comic_read', '漫画阅读页'),
|
||||
|
||||
/// 漫画章节页
|
||||
comicChapterList('comic_chapter_list', '漫画章节页');
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
const AppPageEnum(this.key, this.name);
|
||||
|
||||
/// 页面唯一标识,用于 `page_key` 字段。
|
||||
final String key;
|
||||
|
||||
/// 页面展示名称,用于 `page_name` 字段。
|
||||
final String name;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
/// 支付方式枚举
|
||||
/// 对应文档中的 pay_type 字段:wechat, alipay, bank_card, apple_pay 等
|
||||
enum PayTypeEnum {
|
||||
/// 微信支付
|
||||
WECHAT(label: 'wechat'),
|
||||
|
||||
/// 支付宝
|
||||
ALIPAY(label: 'alipay'),
|
||||
|
||||
/// 银行卡
|
||||
BANK_CARD(label: 'bank_card'),
|
||||
|
||||
/// Apple Pay
|
||||
APPLE_PAY(label: 'apple_pay'),
|
||||
|
||||
;
|
||||
|
||||
/// 上报时使用的字符串值
|
||||
final String label;
|
||||
|
||||
const PayTypeEnum({
|
||||
required this.label,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
/// 阅读行为类型,用于小说和漫画事件。
|
||||
///
|
||||
/// 可使用预定义常量,也可传入自定义字符串:
|
||||
/// ```dart
|
||||
/// ReadBehaviorEnum.view
|
||||
/// ReadBehaviorEnum('custom_key', '自定义行为')
|
||||
/// ```
|
||||
///
|
||||
/// 字段规则(debug 模式下触发 assert):
|
||||
/// - [key]:仅限字母、数字、`_`、`-`
|
||||
/// - [name]:字母、数字、`_`、`-`、中文
|
||||
class ReadBehaviorEnum {
|
||||
final String key;
|
||||
final String name;
|
||||
|
||||
const ReadBehaviorEnum._internal(this.key, this.name);
|
||||
|
||||
/// 自定义行为类型。[key] 仅限字母/数字/`_`/`-`;[name] 允许中文。
|
||||
factory ReadBehaviorEnum(String key, String name) {
|
||||
assert(
|
||||
RegExp(r'^[A-Za-z0-9_-]+$').hasMatch(key),
|
||||
'ReadBehaviorEnum.key 仅支持字母、数字、_ 和 -,实际传入: "$key"',
|
||||
);
|
||||
assert(
|
||||
RegExp(r'^[\u4e00-\u9fa5A-Za-z0-9_-]+$').hasMatch(name),
|
||||
'ReadBehaviorEnum.name 仅支持字母、数字、_、- 和中文,实际传入: "$name"',
|
||||
);
|
||||
return ReadBehaviorEnum._internal(key, name);
|
||||
}
|
||||
|
||||
static const VIEW = ReadBehaviorEnum._internal('view', '展示');
|
||||
static const PAGE_NEXT = ReadBehaviorEnum._internal('page_next', '下一页');
|
||||
static const PAGE_PREV = ReadBehaviorEnum._internal('page_prev', '上一页');
|
||||
static const COMPLETE = ReadBehaviorEnum._internal('complete', '读完');
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
/// 推荐列表内容类型,用于推荐列表展示/点击事件。
|
||||
///
|
||||
/// 可使用预定义常量,也可传入自定义字符串:
|
||||
/// ```dart
|
||||
/// RecommendContentTypeEnum.video
|
||||
/// RecommendContentTypeEnum('podcast')
|
||||
/// ```
|
||||
///
|
||||
/// 字段规则(debug 模式下触发 assert):
|
||||
/// - [label]:仅限字母、数字、`_`、`-`
|
||||
class RecommendContentTypeEnum {
|
||||
final String label;
|
||||
|
||||
const RecommendContentTypeEnum._internal(this.label);
|
||||
|
||||
/// 自定义内容类型。[label] 仅限字母/数字/`_`/`-`。
|
||||
factory RecommendContentTypeEnum(String label) {
|
||||
assert(
|
||||
RegExp(r'^[A-Za-z0-9_-]+$').hasMatch(label),
|
||||
'RecommendContentTypeEnum.label 仅支持字母、数字、_ 和 -,实际传入: "$label"',
|
||||
);
|
||||
return RecommendContentTypeEnum._internal(label);
|
||||
}
|
||||
|
||||
static const VIDEO = RecommendContentTypeEnum._internal('video');
|
||||
static const NOVEL = RecommendContentTypeEnum._internal('novel');
|
||||
static const COMIC = RecommendContentTypeEnum._internal('comic');
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
/// 用户类型,用于上报用户身份信息。
|
||||
///
|
||||
/// 可使用预定义常量,也可传入自定义字符串:
|
||||
/// ```dart
|
||||
/// UserTypeEnum.normal
|
||||
/// UserTypeEnum.vip
|
||||
/// UserTypeEnum('svip')
|
||||
/// ```
|
||||
///
|
||||
/// 字段规则(debug 模式下触发 assert):
|
||||
/// - [label]:仅限字母、数字、`_`、`-`
|
||||
class UserTypeEnum {
|
||||
final String label;
|
||||
|
||||
const UserTypeEnum._internal(this.label);
|
||||
|
||||
/// 自定义用户类型。[label] 仅限字母/数字/`_`/`-`。
|
||||
factory UserTypeEnum(String label) {
|
||||
assert(
|
||||
RegExp(r'^[A-Za-z0-9_-]+$').hasMatch(label),
|
||||
'UserTypeEnum.label 仅支持字母、数字、_ 和 -,实际传入: "$label"',
|
||||
);
|
||||
return UserTypeEnum._internal(label);
|
||||
}
|
||||
|
||||
static const normal = UserTypeEnum._internal('normal');
|
||||
static const vip = UserTypeEnum._internal('vip');
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
/// 视频内容类型,用于视频事件。
|
||||
///
|
||||
/// 可使用预定义常量,也可传入自定义字符串:
|
||||
/// ```dart
|
||||
/// VideoContentTypeEnum.video
|
||||
/// VideoContentTypeEnum('live')
|
||||
/// ```
|
||||
///
|
||||
/// 字段规则(debug 模式下触发 assert):
|
||||
/// - [label]:仅限字母、数字、`_`、`-`
|
||||
class VideoContentTypeEnum {
|
||||
final String label;
|
||||
|
||||
const VideoContentTypeEnum._internal(this.label);
|
||||
|
||||
/// 自定义内容类型。[label] 仅限字母/数字/`_`/`-`。
|
||||
factory VideoContentTypeEnum(String label) {
|
||||
assert(
|
||||
RegExp(r'^[A-Za-z0-9_-]+$').hasMatch(label),
|
||||
'VideoContentTypeEnum.label 仅支持字母、数字、_ 和 -,实际传入: "$label"',
|
||||
);
|
||||
return VideoContentTypeEnum._internal(label);
|
||||
}
|
||||
|
||||
/// 长视频
|
||||
static const video = VideoContentTypeEnum._internal('video');
|
||||
|
||||
/// 短视频
|
||||
static const shortVideo = VideoContentTypeEnum._internal('short_video');
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
/// 视频行为类型,用于视频事件。
|
||||
///
|
||||
/// 可使用预定义常量,也可传入自定义字符串:
|
||||
/// ```dart
|
||||
/// VideoEventEnum.play
|
||||
/// VideoEventEnum('custom_key', '自定义行为')
|
||||
/// ```
|
||||
///
|
||||
/// 字段规则(debug 模式下触发 assert):
|
||||
/// - [key]:仅限字母、数字、`_`、`-`
|
||||
/// - [name]:字母、数字、`_`、`-`、中文
|
||||
class VideoEventEnum {
|
||||
final String key;
|
||||
final String name;
|
||||
|
||||
const VideoEventEnum._internal(this.key, this.name);
|
||||
|
||||
/// 自定义行为类型。[key] 仅限字母/数字/`_`/`-`;[name] 允许中文。
|
||||
factory VideoEventEnum(String key, String name) {
|
||||
assert(
|
||||
RegExp(r'^[A-Za-z0-9_-]+$').hasMatch(key),
|
||||
'VideoEventEnum.key 仅支持字母、数字、_ 和 -,实际传入: "$key"',
|
||||
);
|
||||
assert(
|
||||
RegExp(r'^[\u4e00-\u9fa5A-Za-z0-9_-]+$').hasMatch(name),
|
||||
'VideoEventEnum.name 仅支持字母、数字、_、- 和中文,实际传入: "$name"',
|
||||
);
|
||||
return VideoEventEnum._internal(key, name);
|
||||
}
|
||||
|
||||
static const VIDEO_VIEW = VideoEventEnum._internal('video_view', '视频展示');
|
||||
static const VIDEO_PLAY = VideoEventEnum._internal('video_play', '视频播放');
|
||||
static const VIDEO_PAUSE = VideoEventEnum._internal('video_pause', '暂停');
|
||||
static const VIDEO_SHARE = VideoEventEnum._internal('video_share', '分享');
|
||||
static const VIDEO_COMPLETE = VideoEventEnum._internal('video_complete', '播放完成');
|
||||
static const VIDEO_FORWARD = VideoEventEnum._internal('video_forward', '快进');
|
||||
static const VIDEO_REWIND = VideoEventEnum._internal('video_rewind', '快退');
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
// lib/extension/tab_analytics_extension.dart
|
||||
import 'package:analytics_sdk/entity/analytics_tab.dart';
|
||||
import 'package:analytics_sdk/widget/analytics_bottom_nav_wrapper.dart';
|
||||
import 'package:analytics_sdk/widget/analytics_tab_bar_wrapper.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// 为 [BottomNavigationBar] 提供 analytics 自动埋点能力。
|
||||
///
|
||||
/// 示例:
|
||||
/// ```dart
|
||||
/// BottomNavigationBar(
|
||||
/// currentIndex: _index,
|
||||
/// onTap: (i) => setState(() => _index = i),
|
||||
/// items: const [...],
|
||||
/// ).withAnalytics(tabs: [
|
||||
/// AnalyticsTab('home', '首页'),
|
||||
/// AnalyticsTab('discover'), // pageName 走 PageNameMapper 自动解析
|
||||
/// ])
|
||||
/// ```
|
||||
extension AnalyticsBottomNavExtension on BottomNavigationBar {
|
||||
/// 切换 Tab 时自动上报 AppPageViewEvent + NavigationEvent,
|
||||
/// 并更新 PageLifecycleObserver.currentPageKey。
|
||||
Widget withAnalytics({required List<AnalyticsTab> tabs}) {
|
||||
return AnalyticsBottomNavWrapper(
|
||||
tabs: tabs,
|
||||
child: this,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 为 [TabBar] 提供 analytics 轻导航埋点能力。
|
||||
///
|
||||
/// 示例:
|
||||
/// ```dart
|
||||
/// TabBar(
|
||||
/// controller: _tabController,
|
||||
/// tabs: const [Tab(text: '视频'), Tab(text: '小说')],
|
||||
/// ).withAnalytics(tabs: [
|
||||
/// AnalyticsTab('video', '视频'),
|
||||
/// AnalyticsTab('novel'),
|
||||
/// ])
|
||||
/// ```
|
||||
extension AnalyticsTabBarExtension on TabBar {
|
||||
/// 切换 Tab 时自动上报 NavigationEvent(轻导航,不更新 currentPageKey)。
|
||||
AnalyticsTabBarWrapper withAnalytics({required List<AnalyticsTab> tabs}) {
|
||||
return AnalyticsTabBarWrapper(
|
||||
tabs: tabs,
|
||||
child: this,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import 'package:analytics_sdk/config/sdk_config.dart';
|
||||
import 'package:analytics_sdk/utils/logger.dart';
|
||||
|
||||
/// 广告展示去重管理器
|
||||
///
|
||||
/// 以页面为粒度做去重:同一页面访问内不重复上报同一广告,
|
||||
/// 页面退出后去重状态随之清除,下次进入同一页面广告可重新上报。
|
||||
/// 新 session 时调用 [clear] 清除所有页面记录。
|
||||
class AdImpressionManager {
|
||||
static final AdImpressionManager instance = AdImpressionManager._internal();
|
||||
|
||||
factory AdImpressionManager() => instance;
|
||||
|
||||
AdImpressionManager._internal();
|
||||
|
||||
/// 页面级广告去重表:pageKey → 已上报 adId 集合
|
||||
final Map<String, Set<String>> _reportedAdIdsByPage = {};
|
||||
|
||||
/// 广告 SDK 曝光已上报的 trace_id;同一 session 内全局去重。
|
||||
final Set<String> _reportedSdkTraceIds = {};
|
||||
|
||||
/// 当前已存储的广告ID总数(跨所有页面)
|
||||
int get reportedCount =>
|
||||
_reportedAdIdsByPage.values.fold(0, (sum, s) => sum + s.length);
|
||||
|
||||
/// 批量检查多个广告ID,返回在指定页面内尚未上报的列表(不修改状态)
|
||||
List<String> getUnreportedAdIds(String adIds, {required String pageKey}) {
|
||||
if (adIds.isEmpty) return [];
|
||||
|
||||
final adIdList = adIds
|
||||
.split(',')
|
||||
.map((id) => id.trim())
|
||||
.where((id) => id.isNotEmpty)
|
||||
.toList();
|
||||
|
||||
if (adIdList.isEmpty) return [];
|
||||
|
||||
final pageSet = _reportedAdIdsByPage[pageKey];
|
||||
if (pageSet == null || pageSet.isEmpty) return adIdList;
|
||||
|
||||
return adIdList.where((id) => !pageSet.contains(id)).toList();
|
||||
}
|
||||
|
||||
/// 在指定页面内批量标记广告ID为已上报
|
||||
void markAsReportedBatch(String adIds, {required String pageKey}) {
|
||||
if (adIds.isEmpty) return;
|
||||
|
||||
final adIdList =
|
||||
adIds.split(',').map((id) => id.trim()).where((id) => id.isNotEmpty);
|
||||
|
||||
final pageSet = _reportedAdIdsByPage.putIfAbsent(pageKey, () => {});
|
||||
for (final id in adIdList) {
|
||||
if (reportedCount >= SdkConfig.maxAdImpressionCapacity) {
|
||||
Logger.adImpressionManager(
|
||||
'广告去重表已达上限(${SdkConfig.maxAdImpressionCapacity}),停止写入,跳过 id: $id',
|
||||
level: LogLevel.warn);
|
||||
break;
|
||||
}
|
||||
pageSet.add(id);
|
||||
}
|
||||
}
|
||||
|
||||
/// 指定 trace_id 是否已作为广告 SDK 曝光上报。
|
||||
bool isSdkTraceReported(String traceId) {
|
||||
return _reportedSdkTraceIds.contains(traceId.trim());
|
||||
}
|
||||
|
||||
/// 在广告 SDK 曝光成功入队后标记其 trace_id。
|
||||
void markSdkTraceReported(String traceId) {
|
||||
final normalized = traceId.trim();
|
||||
if (normalized.isEmpty || isSdkTraceReported(normalized)) return;
|
||||
if (_reportedSdkTraceIds.length >= SdkConfig.maxAdImpressionCapacity) {
|
||||
Logger.adImpressionManager(
|
||||
'广告 SDK 曝光 trace_id 去重表已达上限(${SdkConfig.maxAdImpressionCapacity}),停止写入: $normalized',
|
||||
level: LogLevel.warn,
|
||||
);
|
||||
return;
|
||||
}
|
||||
_reportedSdkTraceIds.add(normalized);
|
||||
}
|
||||
|
||||
/// 清除指定页面的广告去重记录(页面退出时调用,下次进入可重新上报)
|
||||
void clearPage(String pageKey) {
|
||||
if (_reportedAdIdsByPage.remove(pageKey) != null) {
|
||||
Logger.adImpressionManager('页面 "$pageKey" 广告去重记录已清除');
|
||||
}
|
||||
}
|
||||
|
||||
/// 清除所有页面的广告去重记录(新 session 开始时调用)
|
||||
void clear() {
|
||||
_reportedAdIdsByPage.clear();
|
||||
_reportedSdkTraceIds.clear();
|
||||
}
|
||||
|
||||
// ── 以下方法供测试使用 ──────────────────────────────
|
||||
|
||||
/// 检查广告ID在指定页面内是否已上报(不修改状态)
|
||||
bool isReported(String adId, {required String pageKey}) {
|
||||
return _reportedAdIdsByPage[pageKey]?.contains(adId.trim()) ?? false;
|
||||
}
|
||||
|
||||
/// [isReported] 的别名,用于断言
|
||||
bool contains(String adId, {required String pageKey}) =>
|
||||
isReported(adId, pageKey: pageKey);
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'package:analytics_sdk/config/sdk_config.dart';
|
||||
import 'package:analytics_sdk/utils/logger.dart';
|
||||
import 'package:analytics_sdk/utils/platform_storage.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
/// 域名管理器:对业务方传入的域名列表进行并发测速,选择最快的上报域名
|
||||
class DomainManager {
|
||||
static final DomainManager instance = DomainManager._internal();
|
||||
|
||||
factory DomainManager() => instance;
|
||||
|
||||
DomainManager._internal();
|
||||
|
||||
List<String> _reportDomains = [];
|
||||
String? _fastestDomain;
|
||||
|
||||
/// 每次 initWithDomains / reset 时递增,用于丢弃过期测速结果
|
||||
int _generation = 0;
|
||||
|
||||
http.Client? _httpClient;
|
||||
http.Client get _client => _httpClient ??= http.Client();
|
||||
|
||||
/// 域名更新回调,当最快域名选出后调用
|
||||
Function(String? fastestDomain)? onFastestDomainChanged;
|
||||
|
||||
/// 获取最快的上报域名
|
||||
String? get fastestDomain => _fastestDomain;
|
||||
|
||||
/// 获取所有上报域名
|
||||
List<String> get reportDomains => List.unmodifiable(_reportDomains);
|
||||
|
||||
/// 传入已解密的域名列表,执行并发测速并选出最快域名
|
||||
///
|
||||
/// [domains] 每项为带协议的完整域名,如 "https://api.example.com"
|
||||
///
|
||||
/// 注意:此方法设计为 fire-and-forget,调用方无需 await,
|
||||
/// 测速完成后通过 [onFastestDomainChanged] 回调通知结果。
|
||||
Future<String?> initWithDomains(List<String> domains) async {
|
||||
// 记录本次调用的代次,测速完成后用于判断结果是否已过期
|
||||
final gen = ++_generation;
|
||||
try {
|
||||
if (domains.isEmpty) {
|
||||
Logger.domainManager('域名列表为空,跳过测速', level: LogLevel.warn);
|
||||
return null;
|
||||
}
|
||||
|
||||
_reportDomains = domains;
|
||||
Logger.domainManager('开始域名测速,共 ${domains.length} 个: $domains');
|
||||
|
||||
String? fastest;
|
||||
if (domains.length == 1) {
|
||||
fastest = domains.first;
|
||||
Logger.domainManager('只有一个域名,跳过测速: $fastest');
|
||||
} else {
|
||||
fastest = await _speedTestAndSelect(domains);
|
||||
Logger.domainManager('测速完成,最快域名: $fastest');
|
||||
}
|
||||
|
||||
// 若 reset() 或新一轮 initWithDomains 已被调用,丢弃本次过期结果
|
||||
if (gen != _generation) {
|
||||
Logger.domainManager('测速结果已过期(generation 不匹配),忽略本次结果');
|
||||
return null;
|
||||
}
|
||||
|
||||
_fastestDomain = fastest;
|
||||
// 测速完成后持久化域名列表(供下次 init 时解密失败时作为降级)
|
||||
_saveDomainCache();
|
||||
if (onFastestDomainChanged != null && _fastestDomain != null) {
|
||||
try {
|
||||
onFastestDomainChanged!(_fastestDomain);
|
||||
} catch (e) {
|
||||
Logger.domainManager('域名更新回调异常: $e');
|
||||
}
|
||||
}
|
||||
return _fastestDomain;
|
||||
} catch (e) {
|
||||
Logger.domainManager('域名测速失败: $e', level: LogLevel.warn);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// 重置域名管理器状态,供 re-init 时使用
|
||||
///
|
||||
/// 清除内存中的域名列表和最快域名,不关闭 HttpClient(复用连接池)。
|
||||
/// 不删除磁盘缓存(缓存代表最近已知可用状态,跨 init 保留)。
|
||||
void reset() {
|
||||
_generation++; // 使所有进行中的测速结果失效
|
||||
_fastestDomain = null;
|
||||
_reportDomains = [];
|
||||
onFastestDomainChanged = null; // 防止旧回调在新一轮测速前残留
|
||||
Logger.domainManager('域名管理器状态已重置');
|
||||
}
|
||||
|
||||
/// 释放 HttpClient 资源,通常在 SDK dispose 时调用
|
||||
void dispose() {
|
||||
_httpClient?.close();
|
||||
_httpClient = null;
|
||||
Logger.domainManager('域名管理器已销毁');
|
||||
}
|
||||
|
||||
/// 从磁盘加载上次缓存的域名列表
|
||||
///
|
||||
/// 返回非空列表时可直接传入 [initWithDomains]。
|
||||
/// 无缓存文件或读取失败时返回空列表(不抛出异常)。
|
||||
Future<List<String>> loadCachedDomains() async {
|
||||
try {
|
||||
final content =
|
||||
await PlatformStorage.read(SdkConfig.domainCacheFileName);
|
||||
if (content == null) return [];
|
||||
final decoded = jsonDecode(content);
|
||||
if (decoded is List) {
|
||||
return (decoded as List)
|
||||
.map<String>((item) => item.toString().trim())
|
||||
.where((d) => d.isNotEmpty)
|
||||
.toList();
|
||||
}
|
||||
return [];
|
||||
} catch (e) {
|
||||
Logger.domainManager('加载域名缓存失败: $e');
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 私有方法 ====================
|
||||
|
||||
/// 将当前 _reportDomains 写入磁盘缓存(fire-and-forget)
|
||||
void _saveDomainCache() {
|
||||
if (_reportDomains.isEmpty) return;
|
||||
final domainsSnapshot = List<String>.from(_reportDomains);
|
||||
Future(() async {
|
||||
try {
|
||||
await PlatformStorage.write(
|
||||
SdkConfig.domainCacheFileName,
|
||||
jsonEncode(domainsSnapshot),
|
||||
);
|
||||
Logger.domainManager('域名缓存已写入: $domainsSnapshot');
|
||||
} catch (e) {
|
||||
Logger.domainManager('写入域名缓存失败: $e');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// 对所有域名进行并发测速,返回最快的域名
|
||||
Future<String?> _speedTestAndSelect(List<String> domains) async {
|
||||
if (domains.isEmpty) return null;
|
||||
|
||||
final speedResults = <String, Duration>{};
|
||||
|
||||
final futures = domains.map((domain) async {
|
||||
try {
|
||||
final speed = await _speedTest(domain);
|
||||
if (speed != null) {
|
||||
speedResults[domain] = speed;
|
||||
Logger.domainManager('域名 $domain 测速: ${speed.inMilliseconds}ms');
|
||||
}
|
||||
} catch (e) {
|
||||
Logger.domainManager('域名 $domain 测速失败: $e');
|
||||
}
|
||||
});
|
||||
|
||||
await Future.wait(futures);
|
||||
|
||||
if (speedResults.isEmpty) {
|
||||
Logger.domainManager('所有域名测速失败,回退到使用第一个域名作为上报域名',
|
||||
level: LogLevel.warn);
|
||||
return domains.first;
|
||||
}
|
||||
|
||||
final fastest =
|
||||
speedResults.entries.reduce((a, b) => a.value < b.value ? a : b);
|
||||
|
||||
Logger.domainManager(
|
||||
'最快域名: ${fastest.key} (${fastest.value.inMilliseconds}ms)');
|
||||
return fastest.key;
|
||||
}
|
||||
|
||||
/// 测速单个域名,返回连接耗时;连接超时或网络异常返回 null
|
||||
///
|
||||
/// 仅 HTTP 200 视为可达。
|
||||
/// 非 200(3xx 重定向、4xx 客户端错误、5xx 服务端错误)/ 连接失败 / 超时 视为不可达,返回 null。
|
||||
Future<Duration?> _speedTest(String domain) async {
|
||||
try {
|
||||
final uri = Uri.parse(domain);
|
||||
final startTime = DateTime.now();
|
||||
final response =
|
||||
await _client.get(uri).timeout(SdkConfig.speedTestTimeout);
|
||||
final elapsed = DateTime.now().difference(startTime);
|
||||
if (response.statusCode != 200) {
|
||||
Logger.domainManager(
|
||||
'域名 $domain 返回 ${response.statusCode},视为不可达',
|
||||
level: LogLevel.warn);
|
||||
return null;
|
||||
}
|
||||
Logger.domainManager(
|
||||
'域名 $domain 可达,状态码: ${response.statusCode}');
|
||||
return elapsed;
|
||||
} catch (e) {
|
||||
Logger.domainManager('域名 $domain 测速失败: $e', level: LogLevel.warn);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export 'event_persistence_native.dart'
|
||||
// ignore: uri_does_not_exist
|
||||
if (dart.library.html) 'event_persistence_web.dart';
|
||||
|
||||
// EventPersistenceImpl 由条件导入文件提供
|
||||
// Native:lib/manager/event_persistence_native.dart
|
||||
// Web :lib/manager/event_persistence_web.dart
|
||||
@@ -0,0 +1,398 @@
|
||||
import 'dart:async';
|
||||
import 'dart:collection';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:analytics_sdk/config/sdk_config.dart';
|
||||
import 'package:analytics_sdk/utils/logger.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:synchronized/synchronized.dart';
|
||||
|
||||
/// Native 平台事件持久化:基于本地文件 + tombstone 机制。
|
||||
class EventPersistenceImpl {
|
||||
String? _cacheFilePath;
|
||||
String? _debugReportEventLogFilePath;
|
||||
String? _tombstoneFilePath;
|
||||
String? _inFlightFilePath;
|
||||
final Lock _cacheLock = Lock();
|
||||
final List<Map<String, dynamic>> _cacheBuffer = [];
|
||||
static int get _cacheBufferThreshold => SdkConfig.cacheBufferThreshold;
|
||||
static Duration get _cacheFlushInterval => SdkConfig.cacheFlushInterval;
|
||||
Timer? _cacheFlushTimer;
|
||||
|
||||
Future<void> init() async {
|
||||
try {
|
||||
final dir = await getApplicationDocumentsDirectory();
|
||||
_cacheFilePath = '${dir.path}/${SdkConfig.cacheFileName}';
|
||||
_debugReportEventLogFilePath =
|
||||
'${dir.path}/${SdkConfig.debugReportEventLogFileName}';
|
||||
_tombstoneFilePath = '${dir.path}/${SdkConfig.tombstoneFileName}';
|
||||
_inFlightFilePath = '${dir.path}/data_plus_events_inflight.txt';
|
||||
} catch (e) {
|
||||
Logger.analyticsSdk('持久化初始化失败: $e');
|
||||
_cacheFilePath = null;
|
||||
_debugReportEventLogFilePath = null;
|
||||
_tombstoneFilePath = null;
|
||||
}
|
||||
}
|
||||
|
||||
String? get debugReportEventLogFilePath =>
|
||||
SdkConfig.enableDebugReportLog ? _debugReportEventLogFilePath : null;
|
||||
|
||||
void appendDebugReportEvent(Map<String, dynamic> event) {
|
||||
if (!SdkConfig.enableDebugReportLog ||
|
||||
_debugReportEventLogFilePath == null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
File(_debugReportEventLogFilePath!).writeAsStringSync(
|
||||
'${jsonEncode(event)}\n',
|
||||
mode: FileMode.append,
|
||||
flush: true,
|
||||
);
|
||||
} catch (e) {
|
||||
Logger.analyticsSdk('debug 完整事件 JSONL 写入失败: $e', level: LogLevel.warn);
|
||||
}
|
||||
}
|
||||
|
||||
void bufferEvent(Map<String, dynamic> event) {
|
||||
if (_cacheFilePath == null) return;
|
||||
_cacheBuffer.add(event);
|
||||
if (_cacheBuffer.length >= _cacheBufferThreshold) {
|
||||
flushBuffer();
|
||||
} else {
|
||||
_cacheFlushTimer ??= Timer(_cacheFlushInterval, () {
|
||||
_cacheFlushTimer = null;
|
||||
flushBuffer();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// 同步写入关键事件,供应用终止阶段的会话汇总使用。
|
||||
///
|
||||
/// 此时 Dart isolate 可能在下一个 await 前被系统终止,不能依赖延迟
|
||||
/// buffer 或异步文件写入。事件已包含 event_id,后续仍通过 tombstone
|
||||
/// 和服务端 event_id 去重保证幂等。
|
||||
void persistCriticalEvent(Map<String, dynamic> event) {
|
||||
if (_cacheFilePath == null) return;
|
||||
try {
|
||||
final line = jsonEncode(event);
|
||||
File(_cacheFilePath!).writeAsStringSync(
|
||||
'$line\n',
|
||||
mode: FileMode.append,
|
||||
flush: true,
|
||||
);
|
||||
} catch (e) {
|
||||
Logger.analyticsSdk('关键事件同步缓存写入失败: $e', level: LogLevel.warn);
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<Map<String, dynamic>>> loadEvents({
|
||||
required Queue<Map<String, dynamic>> queue,
|
||||
required int maxQueueSize,
|
||||
required int maxCacheLines,
|
||||
required bool Function(String) isEventTypeEnabled,
|
||||
}) async {
|
||||
if (_cacheFilePath == null) return const [];
|
||||
final file = File(_cacheFilePath!);
|
||||
if (!await file.exists()) return const [];
|
||||
|
||||
final recovered = <Map<String, dynamic>>[];
|
||||
try {
|
||||
await _cacheLock.synchronized(() async {
|
||||
if (await file.length() > SdkConfig.maxCacheBytes) {
|
||||
await _trimCacheFileIfNeeded(file);
|
||||
}
|
||||
|
||||
final tombstonedIds = await _loadTombstoneIds();
|
||||
final inFlightIds = await _loadInFlightIds();
|
||||
// 请求开始不代表服务端已经确认。进程在请求途中被终止时,in-flight
|
||||
// 事件必须随缓存一起恢复,交由服务端按 event_id 去重,不能在客户端
|
||||
// 为了避免重复而丢失会话汇总等终止事件。
|
||||
if (inFlightIds.isNotEmpty) {
|
||||
Logger.analyticsSdk(
|
||||
'检测到 ${inFlightIds.length} 条未确认 in-flight 事件,将恢复重试');
|
||||
}
|
||||
final lines = await file.readAsLines();
|
||||
final linesToProcess = lines.length > maxCacheLines
|
||||
? lines.sublist(lines.length - maxCacheLines)
|
||||
: lines;
|
||||
|
||||
for (final line in linesToProcess) {
|
||||
if (line.trim().isEmpty) continue;
|
||||
try {
|
||||
final decoded = jsonDecode(line);
|
||||
if (decoded is Map<String, dynamic>) {
|
||||
final eventId = decoded['event_id'];
|
||||
if (eventId != null && tombstonedIds.contains(eventId)) {
|
||||
continue;
|
||||
}
|
||||
final eventType = decoded['event'] as String?;
|
||||
if (eventType != null && !isEventTypeEnabled(eventType)) {
|
||||
Logger.analyticsSdk('缓存恢复:事件类型 $eventType 未启用,跳过');
|
||||
continue;
|
||||
}
|
||||
recovered.add(decoded);
|
||||
}
|
||||
} catch (_) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// 缓存和 tombstone 都不能在“恢复到内存队列”时删除。恢复后的
|
||||
// 事件尚未得到本次请求的 200 确认,进程再次终止时仍需能从磁盘
|
||||
// 重试;只有 removeEvents() 在 200 后写入 tombstone。
|
||||
if (_inFlightFilePath != null) {
|
||||
final inFlight = File(_inFlightFilePath!);
|
||||
if (await inFlight.exists()) await inFlight.delete();
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
Logger.analyticsSdk('缓存加载失败: $e');
|
||||
try {
|
||||
await file.delete();
|
||||
} catch (_) {}
|
||||
}
|
||||
return recovered;
|
||||
}
|
||||
|
||||
Future<void> removeEvents(List<Map<String, dynamic>> reportedEvents) async {
|
||||
if (_tombstoneFilePath == null || reportedEvents.isEmpty) return;
|
||||
|
||||
final ids = reportedEvents
|
||||
.map((e) => e['event_id'])
|
||||
.whereType<String>()
|
||||
.where((id) => id.isNotEmpty && !id.contains('\n'))
|
||||
.toList();
|
||||
|
||||
if (ids.isEmpty) return;
|
||||
|
||||
try {
|
||||
await _cacheLock.synchronized(() async {
|
||||
try {
|
||||
final tombstone = File(_tombstoneFilePath!);
|
||||
await tombstone.writeAsString(
|
||||
'${ids.join('\n')}\n',
|
||||
mode: FileMode.append,
|
||||
);
|
||||
Logger.analyticsSdk('tombstone 追加 ${ids.length} 条 event_id');
|
||||
|
||||
final tombstoneContent = await tombstone.readAsString();
|
||||
final tombstoneCount =
|
||||
tombstoneContent.split('\n').where((l) => l.isNotEmpty).length;
|
||||
if (tombstoneCount >= SdkConfig.tombstoneCompactionThreshold) {
|
||||
await _compactCache(tombstoneContent);
|
||||
}
|
||||
} catch (e) {
|
||||
Logger.analyticsSdk('tombstone 写入失败: $e');
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
Logger.analyticsSdk('缓存移除操作异常: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> clearCache() async {
|
||||
if (_cacheFilePath == null) return;
|
||||
try {
|
||||
await _cacheLock.synchronized(() async {
|
||||
try {
|
||||
final file = File(_cacheFilePath!);
|
||||
if (await file.exists()) await file.delete();
|
||||
if (_tombstoneFilePath != null) {
|
||||
final tombstone = File(_tombstoneFilePath!);
|
||||
if (await tombstone.exists()) await tombstone.delete();
|
||||
}
|
||||
} catch (e) {
|
||||
Logger.analyticsSdk('缓存清除失败: $e');
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
Logger.analyticsSdk('缓存清除操作异常: $e');
|
||||
}
|
||||
}
|
||||
|
||||
void flushBuffer() {
|
||||
if (_cacheFilePath == null || _cacheBuffer.isEmpty) return;
|
||||
_cacheFlushTimer?.cancel();
|
||||
_cacheFlushTimer = null;
|
||||
final batch = List<Map<String, dynamic>>.from(_cacheBuffer);
|
||||
_cacheBuffer.clear();
|
||||
Future.microtask(() async {
|
||||
await _saveBatchToCache(batch);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> flushBufferAsync() async {
|
||||
if (_cacheFilePath == null || _cacheBuffer.isEmpty) return;
|
||||
_cacheFlushTimer?.cancel();
|
||||
_cacheFlushTimer = null;
|
||||
final batch = List<Map<String, dynamic>>.from(_cacheBuffer);
|
||||
_cacheBuffer.clear();
|
||||
await _saveBatchToCache(batch);
|
||||
}
|
||||
|
||||
/// 发送前记录 batch 中的 event_id,便于诊断未确认请求。
|
||||
///
|
||||
/// 崩溃重启后这些事件会与缓存一起恢复并重试,采用 at-least-once
|
||||
/// 语义;服务端应按 event_id 去重。
|
||||
Future<void> markInFlight(List<Map<String, dynamic>> batch) async {
|
||||
if (_inFlightFilePath == null) return;
|
||||
final ids = batch
|
||||
.map((e) => e['event_id'])
|
||||
.whereType<String>()
|
||||
.where((id) => id.isNotEmpty && !id.contains('\n'))
|
||||
.toList();
|
||||
if (ids.isEmpty) return;
|
||||
try {
|
||||
await File(_inFlightFilePath!).writeAsString('${ids.join('\n')}\n');
|
||||
} catch (e) {
|
||||
Logger.analyticsSdk('in-flight 标记写入失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// 清除 in-flight 文件(上报完成或明确失败后调用,幂等)。
|
||||
Future<void> clearInFlight() async {
|
||||
if (_inFlightFilePath == null) return;
|
||||
try {
|
||||
final file = File(_inFlightFilePath!);
|
||||
if (await file.exists()) await file.delete();
|
||||
} catch (e) {
|
||||
Logger.analyticsSdk('in-flight 文件清除失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 私有方法 ====================
|
||||
|
||||
Future<void> _trimCacheFileIfNeeded(File file) async {
|
||||
try {
|
||||
final len = await file.length();
|
||||
if (len <= SdkConfig.maxCacheBytes) return;
|
||||
final lines = await file.readAsLines();
|
||||
int total = 0;
|
||||
int startIndex = 0;
|
||||
for (int i = lines.length - 1; i >= 0; i--) {
|
||||
total += utf8.encode(lines[i]).length + 1;
|
||||
if (total > SdkConfig.maxCacheBytes) {
|
||||
startIndex = i + 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (startIndex <= 0) return;
|
||||
final kept = lines.sublist(startIndex);
|
||||
await file.writeAsString(kept.isEmpty ? '' : '${kept.join('\n')}\n');
|
||||
Logger.analyticsSdk(
|
||||
'缓存文件超过 ${SdkConfig.maxCacheBytes} 字节,已裁剪掉前 $startIndex 条');
|
||||
} catch (e) {
|
||||
Logger.analyticsSdk('缓存文件裁剪失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _saveBatchToCache(List<Map<String, dynamic>> batch) async {
|
||||
if (_cacheFilePath == null || batch.isEmpty) return;
|
||||
try {
|
||||
await _cacheLock.synchronized(() async {
|
||||
try {
|
||||
final file = File(_cacheFilePath!);
|
||||
final lines = batch
|
||||
.map((item) {
|
||||
try {
|
||||
return jsonEncode(item);
|
||||
} catch (e) {
|
||||
Logger.analyticsSdk('单个事件编码失败,跳过: $e');
|
||||
return null;
|
||||
}
|
||||
})
|
||||
.where((line) => line != null)
|
||||
.join('\n');
|
||||
if (lines.isNotEmpty) {
|
||||
await file.writeAsString('$lines\n', mode: FileMode.append);
|
||||
await _trimCacheFileIfNeeded(file);
|
||||
}
|
||||
} catch (e) {
|
||||
Logger.analyticsSdk('批量缓存写入失败: $e');
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
Logger.analyticsSdk('缓存保存异常: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<Set<String>> _loadTombstoneIds() async {
|
||||
if (_tombstoneFilePath == null) return {};
|
||||
try {
|
||||
final tombstone = File(_tombstoneFilePath!);
|
||||
if (!await tombstone.exists()) return {};
|
||||
final content = await tombstone.readAsString();
|
||||
return content.split('\n').where((l) => l.isNotEmpty).toSet();
|
||||
} catch (e) {
|
||||
Logger.analyticsSdk('tombstone 读取失败: $e');
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
Future<Set<String>> _loadInFlightIds() async {
|
||||
if (_inFlightFilePath == null) return {};
|
||||
try {
|
||||
final file = File(_inFlightFilePath!);
|
||||
if (!await file.exists()) return {};
|
||||
final content = await file.readAsString();
|
||||
return content.split('\n').where((l) => l.isNotEmpty).toSet();
|
||||
} catch (e) {
|
||||
Logger.analyticsSdk('in-flight 文件读取失败: $e');
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _compactCache(String tombstoneContent) async {
|
||||
if (_cacheFilePath == null || _tombstoneFilePath == null) return;
|
||||
try {
|
||||
final tombstonedIds =
|
||||
tombstoneContent.split('\n').where((l) => l.isNotEmpty).toSet();
|
||||
|
||||
final cacheFile = File(_cacheFilePath!);
|
||||
if (!await cacheFile.exists()) {
|
||||
await File(_tombstoneFilePath!).delete();
|
||||
return;
|
||||
}
|
||||
|
||||
final lines = await cacheFile.readAsLines();
|
||||
final kept = <String>[];
|
||||
int removed = 0;
|
||||
|
||||
for (final line in lines) {
|
||||
if (line.trim().isEmpty) continue;
|
||||
try {
|
||||
final decoded = jsonDecode(line);
|
||||
if (decoded is Map<String, dynamic>) {
|
||||
final id = decoded['event_id'];
|
||||
if (id != null && tombstonedIds.contains(id)) {
|
||||
removed++;
|
||||
} else {
|
||||
kept.add(line);
|
||||
}
|
||||
} else {
|
||||
kept.add(line);
|
||||
}
|
||||
} catch (_) {
|
||||
kept.add(line);
|
||||
}
|
||||
}
|
||||
|
||||
if (kept.isEmpty) {
|
||||
await cacheFile.delete();
|
||||
} else {
|
||||
await cacheFile.writeAsString('${kept.join('\n')}\n');
|
||||
}
|
||||
try {
|
||||
await File(_tombstoneFilePath!).delete();
|
||||
} catch (e) {
|
||||
Logger.analyticsSdk('tombstone 删除失败(可忽略,下次压缩会重试): $e');
|
||||
}
|
||||
|
||||
Logger.analyticsSdk('缓存压缩完成:删除 $removed 条,保留 ${kept.length} 条');
|
||||
} catch (e) {
|
||||
Logger.analyticsSdk('缓存压缩失败: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import 'dart:collection';
|
||||
|
||||
/// Web 平台事件持久化:内存 only,所有持久化操作为空实现。
|
||||
/// Web 会话通常短暂,不需要跨页面持久化事件。
|
||||
class EventPersistenceImpl {
|
||||
Future<void> init() async {}
|
||||
|
||||
String? get debugReportEventLogFilePath => null;
|
||||
|
||||
void appendDebugReportEvent(Map<String, dynamic> event) {}
|
||||
|
||||
void bufferEvent(Map<String, dynamic> event) {}
|
||||
|
||||
void persistCriticalEvent(Map<String, dynamic> event) {}
|
||||
|
||||
Future<List<Map<String, dynamic>>> loadEvents({
|
||||
required Queue<Map<String, dynamic>> queue,
|
||||
required int maxQueueSize,
|
||||
required int maxCacheLines,
|
||||
required bool Function(String) isEventTypeEnabled,
|
||||
}) async =>
|
||||
const [];
|
||||
|
||||
Future<void> removeEvents(List<Map<String, dynamic>> events) async {}
|
||||
|
||||
Future<void> markInFlight(List<Map<String, dynamic>> events) async {}
|
||||
|
||||
Future<void> clearInFlight() async {}
|
||||
|
||||
Future<void> clearCache() async {}
|
||||
|
||||
void flushBuffer() {}
|
||||
|
||||
Future<void> flushBufferAsync() async {}
|
||||
}
|
||||
@@ -0,0 +1,552 @@
|
||||
import 'dart:async';
|
||||
import 'dart:collection';
|
||||
import 'dart:convert';
|
||||
import 'package:analytics_sdk/manager/event_persistence.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:analytics_sdk/config/sdk_config.dart';
|
||||
import 'package:analytics_sdk/manager/event_type_config_manager.dart';
|
||||
import 'package:analytics_sdk/manager/session_behavior_manager.dart';
|
||||
import 'package:analytics_sdk/manager/session_manager.dart';
|
||||
import 'package:analytics_sdk/manager/session_metrics_manager.dart';
|
||||
import 'package:analytics_sdk/manager/session_sensor_sampler.dart';
|
||||
import 'package:analytics_sdk/manager/sdk_context.dart';
|
||||
import 'package:analytics_sdk/utils/logger.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:synchronized/synchronized.dart';
|
||||
|
||||
/// 事件处理与上报管理器
|
||||
///
|
||||
/// 负责:
|
||||
/// - 维护事件队列
|
||||
/// - 本地持久化(缓存文件)
|
||||
/// - 批量上报 & 指数退避重试
|
||||
/// - 定时上报定时器
|
||||
///
|
||||
/// 对外由 `AnalyticsSdk` 持有并调用,不直接暴露给业务方。
|
||||
class EventProcessor {
|
||||
String? _reportUrl;
|
||||
final Queue<Map<String, dynamic>> _eventQueue = Queue<Map<String, dynamic>>();
|
||||
|
||||
Timer? _uploadTimer;
|
||||
|
||||
final EventPersistenceImpl _persistence = EventPersistenceImpl();
|
||||
|
||||
int _backoffLevel = 0;
|
||||
Timer? _retryTimer;
|
||||
|
||||
/// JSON 过大时折半缩小批次;成功后重置为 null(恢复用 maxBatchSize)
|
||||
int? _overrideBatchSize;
|
||||
|
||||
http.Client? _httpClient;
|
||||
|
||||
http.Client get _client => _httpClient ??= http.Client();
|
||||
|
||||
/// 上报锁,防止并发上报
|
||||
bool _isUploading = false;
|
||||
final Lock _uploadLock = Lock();
|
||||
|
||||
/// event_id 防抖:记录最近见到该 id 的时间,窗口内重复则丢弃
|
||||
final Map<String, DateTime> _recentEventIds = {};
|
||||
|
||||
/// 防抖窗口:1000ms 秒级时间戳粒度 + 100ms 时钟抖动容差。
|
||||
/// event_id 的哈希因子包含秒级 client_ts,同秒内同事件会产生同一 id;
|
||||
/// 额外 100ms 用于覆盖调用链路上的微小时间偏移,避免临界点误判。
|
||||
static const _dedupWindow = Duration(milliseconds: 1100);
|
||||
|
||||
/// 测试包用:最近一次上报结果
|
||||
bool? lastUploadSuccess;
|
||||
String? lastUploadError;
|
||||
DateTime? lastUploadTime;
|
||||
|
||||
/// 是否已配置可用的上报 URL
|
||||
bool get hasValidReportUrl =>
|
||||
_reportUrl != null && _reportUrl!.isNotEmpty == true;
|
||||
|
||||
/// 测试包用:队列中待上报事件数
|
||||
int get queueLength => _eventQueue.length;
|
||||
|
||||
/// 测试包用:当前上报地址(脱敏)
|
||||
String? get debugReportUrl => _reportUrl;
|
||||
|
||||
/// 测试包用:完整事件 JSONL 调试文件路径(仅 Native debug 包)
|
||||
String? get debugReportEventLogPath =>
|
||||
kDebugMode && SdkConfig.enableDebugReportLog
|
||||
? _persistence.debugReportEventLogFilePath
|
||||
: null;
|
||||
|
||||
/// 更新上报 URL(域名管理器回调时调用)
|
||||
void updateReportUrl(String? reportUrl) {
|
||||
_reportUrl = reportUrl;
|
||||
}
|
||||
|
||||
/// 启动定时上报定时器
|
||||
void startAutoUploadTimer() {
|
||||
_uploadTimer?.cancel();
|
||||
_uploadTimer = Timer.periodic(SdkConfig.uploadInterval, (_) {
|
||||
try {
|
||||
uploadBatch().catchError((e) {
|
||||
Logger.analyticsSdk('定时上报 Future 异常: $e');
|
||||
});
|
||||
} catch (e) {
|
||||
Logger.analyticsSdk('定时上报异常: $e');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// 停止定时上报定时器
|
||||
void stopAutoUploadTimer() {
|
||||
_uploadTimer?.cancel();
|
||||
_uploadTimer = null;
|
||||
}
|
||||
|
||||
/// 重置退避和上报状态(SDK 重新初始化时调用)
|
||||
void resetState() {
|
||||
_backoffLevel = 0;
|
||||
_overrideBatchSize = null;
|
||||
_retryTimer?.cancel();
|
||||
_retryTimer = null;
|
||||
lastUploadSuccess = null;
|
||||
lastUploadError = null;
|
||||
lastUploadTime = null;
|
||||
_isUploading = false;
|
||||
_recentEventIds.clear();
|
||||
}
|
||||
|
||||
/// 事件入队:
|
||||
/// - 负责队列容量检查
|
||||
/// - 触发必要的上报
|
||||
/// - 写入本地缓存
|
||||
///
|
||||
/// 返回值:
|
||||
/// - `true`:事件成功进入队列
|
||||
/// - `false`:因队列已满等原因被丢弃
|
||||
bool enqueueEvent(Map<String, dynamic> json) {
|
||||
// 队列容量检查,防止内存无限增长
|
||||
if (_eventQueue.length >= SdkConfig.maxQueueSize) {
|
||||
Logger.analyticsSdk('警告:事件队列已满(${_eventQueue.length}),尝试立即上报');
|
||||
// 尝试立即上报以释放队列空间(不 await)
|
||||
try {
|
||||
uploadBatch().catchError((e) {
|
||||
Logger.analyticsSdk('立即上报 Future 异常: $e');
|
||||
});
|
||||
} catch (e) {
|
||||
Logger.analyticsSdk('立即上报异常: $e');
|
||||
}
|
||||
// 队列已满时直接丢弃新事件
|
||||
return false;
|
||||
}
|
||||
|
||||
// event_id 防抖:1100ms 窗口内同一 id 只保留第一条
|
||||
final eventId = json['event_id'] as String?;
|
||||
if (eventId != null) {
|
||||
final now = DateTime.now();
|
||||
_recentEventIds.removeWhere((_, t) => now.difference(t) > _dedupWindow);
|
||||
final lastSeen = _recentEventIds[eventId];
|
||||
if (lastSeen != null && now.difference(lastSeen) <= _dedupWindow) {
|
||||
Logger.analyticsSdk('event_id 防抖,已丢弃: $eventId');
|
||||
return false;
|
||||
}
|
||||
_recentEventIds[eventId] = now;
|
||||
}
|
||||
|
||||
_eventQueue.add(json);
|
||||
SessionManager.instance.recordActivityForEvent(json);
|
||||
SessionMetricsManager.instance.recordAcceptedEvent(json);
|
||||
SessionBehaviorManager.instance.recordAcceptedEvent(json);
|
||||
SessionSensorSampler.instance.recordAcceptedEvent(json);
|
||||
if (json['event'] == 'app_session_summary') {
|
||||
_persistence.persistCriticalEvent(json);
|
||||
Logger.analyticsSdk(
|
||||
'app_session_summary 已同步写入本地缓存: ${json['event_id'] ?? ''}');
|
||||
} else {
|
||||
_persistence.bufferEvent(json);
|
||||
}
|
||||
_writeDebugReportEvent(json);
|
||||
_logAcceptedEvent(json);
|
||||
_flushIfNeeded();
|
||||
return true;
|
||||
}
|
||||
|
||||
/// 当队列长度达到阈值时触发上报
|
||||
void _flushIfNeeded() {
|
||||
if (_eventQueue.length >= SdkConfig.autoUploadThreshold) {
|
||||
uploadBatch().catchError((e) {
|
||||
Logger.analyticsSdk('阈值触发上报 Future 异常: $e');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// 强制立即上报
|
||||
/// 该方法永远不会抛出异常
|
||||
Future<void> flush() async {
|
||||
try {
|
||||
await uploadBatch();
|
||||
} catch (e) {
|
||||
Logger.analyticsSdk('flush() 发生异常,已安全处理: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// 批量上报队列中的事件
|
||||
Future<void> uploadBatch() async {
|
||||
if (_eventQueue.isEmpty || _reportUrl == null) return;
|
||||
|
||||
return _uploadLock.synchronized(() async {
|
||||
if (_eventQueue.isEmpty || _reportUrl == null || _isUploading) return;
|
||||
|
||||
_isUploading = true;
|
||||
// 限制单次上报的批次大小,避免请求过大导致超时或内存问题
|
||||
// _overrideBatchSize 在 JSON 过大时折半缩小,上报成功后重置
|
||||
final maxBatch = _overrideBatchSize ?? SdkConfig.maxBatchSize;
|
||||
final batchSize =
|
||||
_eventQueue.length > maxBatch ? maxBatch : _eventQueue.length;
|
||||
|
||||
// 安全检查:确保批次大小有效
|
||||
if (batchSize <= 0) {
|
||||
Logger.analyticsSdk('批次大小无效: $batchSize');
|
||||
_isUploading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// 取出批次但不从队列移除,上报成功后再移除(at-least-once 语义)
|
||||
final batch = List<Map<String, dynamic>>.from(
|
||||
_eventQueue.take(batchSize),
|
||||
);
|
||||
|
||||
try {
|
||||
// 安全检查:确保批次不为空
|
||||
if (batch.isEmpty) {
|
||||
Logger.analyticsSdk('批次为空,跳过上报');
|
||||
return;
|
||||
}
|
||||
|
||||
final requestBatch = batch
|
||||
.map(_ensureEventSignatureBeforeUpload)
|
||||
.toList(growable: false);
|
||||
|
||||
// 在后台线程执行 JSON 编码,避免阻塞主线程
|
||||
final batchJson = await _encodeBatchAsync(requestBatch);
|
||||
|
||||
// 检查编码结果
|
||||
if (batchJson == '[]' || batchJson.isEmpty) {
|
||||
Logger.analyticsSdk('JSON 编码失败或结果为空');
|
||||
_scheduleRetry();
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查 JSON 大小,避免请求过大
|
||||
final jsonSize = utf8.encode(batchJson).length;
|
||||
if (jsonSize > SdkConfig.maxJsonSize) {
|
||||
Logger.analyticsSdk(
|
||||
'警告:JSON 大小过大 (${(jsonSize / 1024 / 1024).toStringAsFixed(2)}MB),缩小批次重试');
|
||||
if (batchSize > 1) {
|
||||
// 折半批次后重试,避免同一批次反复触发 JSON 过大
|
||||
_overrideBatchSize = (batchSize / 2).ceil();
|
||||
_isUploading = false;
|
||||
Future.microtask(() {
|
||||
uploadBatch().catchError((e) {
|
||||
Logger.analyticsSdk('缩小批次后重试上报 Future 异常: $e');
|
||||
});
|
||||
});
|
||||
} else {
|
||||
// 单条事件过大,移除该事件避免阻塞队列
|
||||
if (_eventQueue.isNotEmpty) {
|
||||
_eventQueue.removeFirst();
|
||||
}
|
||||
Logger.analyticsSdk(
|
||||
'警告:单条事件过大 (${(jsonSize / 1024).toStringAsFixed(2)}KB),已丢弃');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// 解析 URL,添加错误处理
|
||||
Uri uri;
|
||||
try {
|
||||
uri = Uri.parse(_reportUrl!);
|
||||
if (!uri.hasScheme || (!uri.scheme.startsWith('http'))) {
|
||||
throw const FormatException('无效的 URL scheme');
|
||||
}
|
||||
} catch (e) {
|
||||
Logger.analyticsSdk('URL 解析失败: $_reportUrl, 错误: $e');
|
||||
_scheduleRetry();
|
||||
return;
|
||||
}
|
||||
|
||||
// 终止窗口内也要保证批次已落盘:否则请求已开始但进程被杀时,
|
||||
// in-flight 文件虽存在,主缓存却没有这批事件可供重试。
|
||||
// 服务端通过 event_id 去重,客户端采用 at-least-once 投递。
|
||||
await _persistence.flushBufferAsync();
|
||||
await _persistence.markInFlight(requestBatch);
|
||||
|
||||
Logger.analyticsSdk(
|
||||
'上报中:${requestBatch.length} 条事件,JSON大小: ${(jsonSize / 1024).toStringAsFixed(2)}KB(已落盘)');
|
||||
|
||||
// 复用 http.Client,利用连接池
|
||||
final response = await _client
|
||||
.post(
|
||||
uri,
|
||||
headers: {
|
||||
'Content-Type': 'application/json; charset=utf-8',
|
||||
},
|
||||
body: batchJson,
|
||||
)
|
||||
.timeout(SdkConfig.totalRequestTimeout);
|
||||
final statusCode = response.statusCode;
|
||||
if (statusCode == 200) {
|
||||
// 上报成功,按 event_id 从队列中移除已上报的事件
|
||||
final reportedIds =
|
||||
batch.map((e) => e['event_id']).whereType<String>().toSet();
|
||||
if (reportedIds.isNotEmpty) {
|
||||
_eventQueue
|
||||
.removeWhere((e) => reportedIds.contains(e['event_id']));
|
||||
} else {
|
||||
// event_id 全缺失时回退到按数量移除(兜底)
|
||||
for (int i = 0; i < batchSize && _eventQueue.isNotEmpty; i++) {
|
||||
_eventQueue.removeFirst();
|
||||
}
|
||||
}
|
||||
lastUploadSuccess = true;
|
||||
lastUploadError = null;
|
||||
lastUploadTime = DateTime.now();
|
||||
_overrideBatchSize = null; // 上报成功,恢复正常批次大小
|
||||
_resetBackoff();
|
||||
await _persistence.removeEvents(requestBatch);
|
||||
final confirmedEvents = requestBatch
|
||||
.map((event) =>
|
||||
'${event['event'] ?? 'unknown'}(${event['event_id'] ?? 'missing_event_id'})')
|
||||
.join(', ');
|
||||
Logger.analyticsSdk(
|
||||
'上报成功: ${requestBatch.length} 条事件,HTTP $statusCode 批次确认,'
|
||||
'已写入 tombstone;确认事件: [$confirmedEvents]');
|
||||
} else {
|
||||
lastUploadSuccess = false;
|
||||
lastUploadError = 'HTTP $statusCode';
|
||||
lastUploadTime = DateTime.now();
|
||||
_scheduleRetry();
|
||||
if (statusCode >= 400 && statusCode < 500) {
|
||||
Logger.analyticsSdk('上报失败: 客户端错误,状态码: $statusCode(可能是数据格式问题)');
|
||||
} else if (statusCode >= 500) {
|
||||
Logger.analyticsSdk('上报失败: 服务器错误,状态码: $statusCode(服务器问题,将重试)');
|
||||
} else {
|
||||
Logger.analyticsSdk('上报失败: 未知状态码 $statusCode');
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
lastUploadSuccess = false;
|
||||
lastUploadError = e is TimeoutException
|
||||
? '请求超时'
|
||||
: e is http.ClientException
|
||||
? '网络连接失败'
|
||||
: e.toString().length > 30
|
||||
? '${e.toString().substring(0, 30)}...'
|
||||
: e.toString();
|
||||
lastUploadTime = DateTime.now();
|
||||
_scheduleRetry();
|
||||
if (e is TimeoutException) {
|
||||
Logger.analyticsSdk('上报异常: 请求超时 - ${e.message ?? "未知超时"}',
|
||||
level: LogLevel.error);
|
||||
} else if (e is http.ClientException) {
|
||||
Logger.analyticsSdk('上报异常: 网络连接失败 - ${e.message}',
|
||||
level: LogLevel.error);
|
||||
} else {
|
||||
final errorMessage = e.toString();
|
||||
Logger.analyticsSdk('上报异常: $errorMessage', level: LogLevel.error);
|
||||
}
|
||||
} finally {
|
||||
// 无论成功、失败还是异常,确保 in-flight 文件被清除(幂等)
|
||||
try {
|
||||
await _persistence.clearInFlight();
|
||||
} catch (_) {}
|
||||
}
|
||||
} finally {
|
||||
_isUploading = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Map<String, dynamic> _ensureEventSignatureBeforeUpload(
|
||||
Map<String, dynamic> event,
|
||||
) {
|
||||
final eventSig = event['event_sig'];
|
||||
if (eventSig is String && eventSig.trim().isNotEmpty) {
|
||||
return event;
|
||||
}
|
||||
|
||||
final signed = SdkContext.attachDeviceEnvIfNeeded(event);
|
||||
final signedEventSig = signed['event_sig'];
|
||||
if (signedEventSig is String && signedEventSig.trim().isNotEmpty) {
|
||||
return signed;
|
||||
}
|
||||
return event;
|
||||
}
|
||||
|
||||
void _logAcceptedEvent(Map<String, dynamic> json) {
|
||||
if (!SdkConfig.enableDebugReportLog) return;
|
||||
try {
|
||||
Logger.analyticsSdkDeveloperLog(
|
||||
'accepted event json: ${jsonEncode(json)}',
|
||||
);
|
||||
} catch (e) {
|
||||
Logger.analyticsSdk('事件日志打印失败: $e', level: LogLevel.warn);
|
||||
}
|
||||
}
|
||||
|
||||
void _writeDebugReportEvent(Map<String, dynamic> json) {
|
||||
if (!kDebugMode || !SdkConfig.enableDebugReportLog) return;
|
||||
try {
|
||||
_persistence.appendDebugReportEvent(json);
|
||||
} catch (e) {
|
||||
Logger.analyticsSdk('debug 完整事件 JSONL 记录失败: $e', level: LogLevel.warn);
|
||||
}
|
||||
}
|
||||
|
||||
/// JSON 编码的静态方法,用于 compute isolate
|
||||
static String _encodeBatchJson(List<Map<String, dynamic>> batch) {
|
||||
return jsonEncode(batch);
|
||||
}
|
||||
|
||||
/// 在后台 isolate 中异步执行 JSON 编码,避免阻塞主线程
|
||||
Future<String> _encodeBatchAsync(List<Map<String, dynamic>> batch) async {
|
||||
try {
|
||||
if (batch.isEmpty) {
|
||||
return '[]';
|
||||
}
|
||||
if (batch.length <= SdkConfig.isolateEncodingThreshold) {
|
||||
return jsonEncode(batch);
|
||||
}
|
||||
try {
|
||||
return await compute(_encodeBatchJson, batch);
|
||||
} catch (e) {
|
||||
Logger.analyticsSdk('compute 失败,回退到主线程编码: $e');
|
||||
return jsonEncode(batch);
|
||||
}
|
||||
} catch (e) {
|
||||
Logger.analyticsSdk('JSON 编码失败: $e');
|
||||
return '[]';
|
||||
}
|
||||
}
|
||||
|
||||
/// 安排指数退避重试(事件仍在队列中,无需重新入队)
|
||||
void _scheduleRetry() {
|
||||
_backoffLevel = (_backoffLevel + 1).clamp(0, SdkConfig.maxBackoffLevel);
|
||||
_retryTimer?.cancel();
|
||||
final delay = _nextDelay();
|
||||
_retryTimer = Timer(delay, () {
|
||||
try {
|
||||
Logger.analyticsSdk('指数退避重试 (延迟 ${delay.inSeconds}s)');
|
||||
uploadBatch();
|
||||
} catch (e) {
|
||||
Logger.analyticsSdk('重试上报异常: $e');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// 计算下次延迟时间(指数退避 + 上限)
|
||||
Duration _nextDelay() {
|
||||
final exponential = SdkConfig.baseRetryDelay * pow(2, _backoffLevel);
|
||||
return exponential > SdkConfig.maxRetryDelay
|
||||
? SdkConfig.maxRetryDelay
|
||||
: exponential;
|
||||
}
|
||||
|
||||
/// 上报成功时重置退避状态
|
||||
void _resetBackoff() {
|
||||
if (_backoffLevel > 0) {
|
||||
_backoffLevel = 0;
|
||||
_retryTimer?.cancel();
|
||||
_retryTimer = null;
|
||||
Logger.analyticsSdk('上报成功,退避机制已重置');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> initPersistence() async {
|
||||
try {
|
||||
await _persistence.init();
|
||||
final debugPath = debugReportEventLogPath;
|
||||
if (debugPath != null && debugPath.isNotEmpty) {
|
||||
Logger.analyticsSdk('debug 完整事件 JSONL 文件: $debugPath');
|
||||
}
|
||||
} catch (e) {
|
||||
Logger.analyticsSdk('持久化初始化失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> loadCachedEvents() async {
|
||||
try {
|
||||
final recovered = await _persistence.loadEvents(
|
||||
queue: _eventQueue,
|
||||
maxQueueSize: SdkConfig.maxQueueSize,
|
||||
maxCacheLines: SdkConfig.maxCacheLines,
|
||||
isEventTypeEnabled: EventTypeConfigManager.instance.isEventTypeEnabled,
|
||||
);
|
||||
|
||||
if (recovered.isNotEmpty) {
|
||||
final availableSpace = SdkConfig.maxQueueSize - _eventQueue.length;
|
||||
if (availableSpace > 0) {
|
||||
final eventsToRecover = recovered.length > availableSpace
|
||||
? recovered.sublist(0, availableSpace)
|
||||
: recovered;
|
||||
_eventQueue.addAll(eventsToRecover);
|
||||
Logger.analyticsSdk('从缓存恢复 ${eventsToRecover.length} 条事件');
|
||||
} else {
|
||||
Logger.analyticsSdk('警告:队列已满,无法恢复缓存事件');
|
||||
}
|
||||
}
|
||||
|
||||
_flushIfNeeded();
|
||||
} catch (e) {
|
||||
Logger.analyticsSdk('缓存加载失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> clearCache() async {
|
||||
await _persistence.clearCache();
|
||||
}
|
||||
|
||||
/// 释放资源(定时器、HttpClient、缓存 buffer 等)
|
||||
void dispose() {
|
||||
try {
|
||||
stopAutoUploadTimer();
|
||||
_retryTimer?.cancel();
|
||||
_retryTimer = null;
|
||||
_isUploading = false;
|
||||
_persistence.flushBuffer();
|
||||
_httpClient?.close();
|
||||
_httpClient = null;
|
||||
} catch (e) {
|
||||
Logger.analyticsSdk('EventProcessor.dispose 异常: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// 测试专用入口:触发 tombstone 写入以供单元测试验证缓存移除行为
|
||||
@visibleForTesting
|
||||
Future<void> removeEventsFromCacheForTest(
|
||||
List<Map<String, dynamic>> events) =>
|
||||
_persistence.removeEvents(events);
|
||||
|
||||
/// 测试专用入口:注入 HTTP client 以验证最终请求体。
|
||||
@visibleForTesting
|
||||
void setHttpClientForTest(http.Client client) {
|
||||
_httpClient?.close();
|
||||
_httpClient = client;
|
||||
}
|
||||
|
||||
/// 异步释放资源(先 await 缓存刷写,再释放其他资源)
|
||||
///
|
||||
/// 推荐在 SDK dispose 时使用此方法替代 [dispose],确保缓存 buffer 写入磁盘。
|
||||
Future<void> disposeAsync() async {
|
||||
try {
|
||||
stopAutoUploadTimer();
|
||||
_retryTimer?.cancel();
|
||||
_retryTimer = null;
|
||||
_isUploading = false;
|
||||
await _persistence.flushBufferAsync();
|
||||
_httpClient?.close();
|
||||
_httpClient = null;
|
||||
} catch (e) {
|
||||
Logger.analyticsSdk('EventProcessor.disposeAsync 异常: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
import 'dart:convert';
|
||||
import 'package:analytics_sdk/config/sdk_config.dart';
|
||||
import 'package:analytics_sdk/utils/logger.dart';
|
||||
import 'package:analytics_sdk/utils/platform_storage.dart';
|
||||
|
||||
/// 事件类型配置管理器:解析并缓存业务方传入的事件类型配置
|
||||
class EventTypeConfigManager {
|
||||
static final EventTypeConfigManager instance =
|
||||
EventTypeConfigManager._internal();
|
||||
|
||||
factory EventTypeConfigManager() => instance;
|
||||
|
||||
EventTypeConfigManager._internal();
|
||||
|
||||
Set<String> _enabledEventTypes = {};
|
||||
|
||||
/// 获取已启用的事件类型集合
|
||||
Set<String> get enabledEventTypes => Set.unmodifiable(_enabledEventTypes);
|
||||
|
||||
/// 检查某个事件类型是否被启用
|
||||
/// 如果配置为空,默认允许所有事件上报(向后兼容)
|
||||
bool isEventTypeEnabled(String eventType) {
|
||||
if (_enabledEventTypes.isEmpty) return true;
|
||||
return _enabledEventTypes.contains(eventType);
|
||||
}
|
||||
|
||||
/// 传入已解密的事件类型配置 JSON 字符串,解析后写入本地缓存
|
||||
///
|
||||
/// 支持格式:
|
||||
/// - 字符串数组:`["app_page_view", "ad_impression", ...]`
|
||||
/// - JSON 对象:`{"enabled_event_types": [...]}`
|
||||
///
|
||||
/// 返回解析到的事件类型数量
|
||||
Future<int> initWithConfig(String decryptedJson) async {
|
||||
try {
|
||||
final eventTypes = _parseEventTypes(decryptedJson);
|
||||
if (eventTypes.isNotEmpty) {
|
||||
_enabledEventTypes = eventTypes;
|
||||
await _saveConfigToCache(eventTypes);
|
||||
Logger.eventTypeConfigManager(
|
||||
'事件类型配置已更新,共 ${eventTypes.length} 个: ${eventTypes.join(", ")}');
|
||||
} else {
|
||||
Logger.eventTypeConfigManager('解析结果为空,保持原有配置');
|
||||
}
|
||||
return eventTypes.length;
|
||||
} catch (e) {
|
||||
Logger.eventTypeConfigManager('initWithConfig 异常: $e');
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/// 从本地缓存加载配置(若无缓存则保持 allow-all 状态)
|
||||
Future<void> loadCachedConfig() async {
|
||||
try {
|
||||
await _loadCachedConfig();
|
||||
} catch (e) {
|
||||
Logger.eventTypeConfigManager('加载缓存配置异常: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// 清理资源
|
||||
void dispose() {
|
||||
_enabledEventTypes.clear();
|
||||
}
|
||||
|
||||
/// 解析 JSON 字符串,提取事件类型列表(供外部验证用,如 validateEncryptedConfig)
|
||||
///
|
||||
/// 返回 List<String>,解析失败或结果为空则返回空列表,不抛出异常。
|
||||
static List<String> parseForValidation(String json) {
|
||||
try {
|
||||
dynamic decoded = jsonDecode(json);
|
||||
if (decoded is String && decoded.trim().isNotEmpty) {
|
||||
try {
|
||||
decoded = jsonDecode(decoded);
|
||||
} catch (_) {}
|
||||
}
|
||||
if (decoded is List) {
|
||||
return (decoded as List)
|
||||
.map<String>((e) => e.toString().trim())
|
||||
.where((e) => e.isNotEmpty)
|
||||
.toList();
|
||||
} else if (decoded is Map<String, dynamic>) {
|
||||
final list = decoded['enabled_event_types'] as List? ??
|
||||
decoded['event_types'] as List? ??
|
||||
decoded['data'] as List?;
|
||||
if (list != null) {
|
||||
return list
|
||||
.map<String>((e) => e.toString().trim())
|
||||
.where((e) => e.isNotEmpty)
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
return [];
|
||||
}
|
||||
|
||||
// ==================== 私有方法 ====================
|
||||
|
||||
/// 解析 JSON 字符串,提取事件类型列表
|
||||
Set<String> _parseEventTypes(String json) {
|
||||
try {
|
||||
dynamic decoded = jsonDecode(json);
|
||||
// 兼容外层为字符串的格式:"[\"event_type\", ...]"
|
||||
if (decoded is String && decoded.trim().isNotEmpty) {
|
||||
try {
|
||||
decoded = jsonDecode(decoded);
|
||||
} catch (_) {}
|
||||
}
|
||||
if (decoded is List) {
|
||||
return (decoded as List)
|
||||
.map<String>((e) => e.toString().trim())
|
||||
.where((e) => e.isNotEmpty)
|
||||
.toSet();
|
||||
} else if (decoded is Map<String, dynamic>) {
|
||||
final list = decoded['enabled_event_types'] as List? ??
|
||||
decoded['event_types'] as List? ??
|
||||
decoded['data'] as List?;
|
||||
if (list != null) {
|
||||
return list
|
||||
.map<String>((e) => e.toString().trim())
|
||||
.where((e) => e.isNotEmpty)
|
||||
.toSet();
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
Logger.eventTypeConfigManager('事件类型 JSON 解析失败: $e');
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
Future<void> _saveConfigToCache(Set<String> eventTypes) async {
|
||||
try {
|
||||
await PlatformStorage.write(
|
||||
SdkConfig.eventTypeConfigCacheFileName,
|
||||
jsonEncode({
|
||||
'enabled_event_types': eventTypes.toList(),
|
||||
'updated_at': DateTime.now().toIso8601String(),
|
||||
}),
|
||||
);
|
||||
Logger.eventTypeConfigManager('配置已保存到缓存');
|
||||
} catch (e) {
|
||||
Logger.eventTypeConfigManager('保存缓存失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadCachedConfig() async {
|
||||
try {
|
||||
final content =
|
||||
await PlatformStorage.read(SdkConfig.eventTypeConfigCacheFileName);
|
||||
if (content == null) {
|
||||
Logger.eventTypeConfigManager('缓存文件不存在');
|
||||
return;
|
||||
}
|
||||
final json = jsonDecode(content) as Map<String, dynamic>;
|
||||
final list = json['enabled_event_types'];
|
||||
if (list is List) {
|
||||
_enabledEventTypes = (list as List)
|
||||
.map<String>((e) => e.toString().trim())
|
||||
.where((e) => e.isNotEmpty)
|
||||
.toSet();
|
||||
Logger.eventTypeConfigManager(
|
||||
'从缓存加载 ${_enabledEventTypes.length} 个事件类型');
|
||||
}
|
||||
} catch (e) {
|
||||
Logger.eventTypeConfigManager('加载缓存失败: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
Future<Map<String, dynamic>> collectNetworkStateFields() async {
|
||||
return const {};
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
// ignore: avoid_web_libraries_in_flutter
|
||||
import 'dart:html' as html;
|
||||
|
||||
import 'package:analytics_sdk/utils/js_interop_compat.dart';
|
||||
|
||||
Future<Map<String, dynamic>> collectNetworkStateFields() async {
|
||||
final connection = _connection(html.window.navigator);
|
||||
if (connection == null) return const {};
|
||||
final fields = <String, dynamic>{};
|
||||
fields['connection_type'] = _string(connection, 'type', 'unknown');
|
||||
fields['effective_type'] = _effectiveType(connection);
|
||||
fields['downlink'] = _downlinkKbps(connection);
|
||||
fields['rtt'] = _number(connection, 'rtt', -1).round();
|
||||
fields.removeWhere((key, value) {
|
||||
if (value is String) return value.trim().isEmpty;
|
||||
if (value is num) return value < 0;
|
||||
return value == null;
|
||||
});
|
||||
return fields;
|
||||
}
|
||||
|
||||
Object? _connection(html.Navigator navigator) {
|
||||
for (final key in ['connection', 'mozConnection', 'webkitConnection']) {
|
||||
try {
|
||||
if (hasJsProperty(navigator, key)) {
|
||||
return getJsProperty(navigator, key);
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
String _string(Object? object, String key, String fallback) {
|
||||
if (object == null) return fallback;
|
||||
try {
|
||||
final value = getJsProperty(object, key);
|
||||
final string = value?.toString().trim() ?? '';
|
||||
return string.isEmpty ? fallback : string;
|
||||
} catch (_) {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
num _number(Object? object, String key, num fallback) {
|
||||
if (object == null) return fallback;
|
||||
try {
|
||||
final value = getJsProperty(object, key);
|
||||
if (value is num && value.isFinite) return value;
|
||||
return num.tryParse(value?.toString() ?? '') ?? fallback;
|
||||
} catch (_) {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
String _effectiveType(Object? connection) {
|
||||
final value = _string(connection, 'effectiveType', 'unknown').toLowerCase();
|
||||
if (value == 'slow-2g') return '2g';
|
||||
const allowed = {'2g', '3g', '4g', '5g'};
|
||||
return allowed.contains(value) ? value : 'unknown';
|
||||
}
|
||||
|
||||
int _downlinkKbps(Object? connection) {
|
||||
final downlink = _number(connection, 'downlink', -1);
|
||||
return downlink >= 0 ? (downlink * 1000).round() : -1;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export 'page_click_auto_fields_stub.dart'
|
||||
if (dart.library.html) 'page_click_auto_fields_web.dart';
|
||||
@@ -0,0 +1,33 @@
|
||||
class ClickDocumentMetrics {
|
||||
final int clickDocumentX;
|
||||
final int clickDocumentY;
|
||||
final int pageWidth;
|
||||
final int pageHeight;
|
||||
final int scrollTop;
|
||||
final int scrollLeft;
|
||||
|
||||
const ClickDocumentMetrics({
|
||||
required this.clickDocumentX,
|
||||
required this.clickDocumentY,
|
||||
required this.pageWidth,
|
||||
required this.pageHeight,
|
||||
required this.scrollTop,
|
||||
required this.scrollLeft,
|
||||
});
|
||||
|
||||
static const unknown = ClickDocumentMetrics(
|
||||
clickDocumentX: -1,
|
||||
clickDocumentY: -1,
|
||||
pageWidth: -1,
|
||||
pageHeight: -1,
|
||||
scrollTop: -1,
|
||||
scrollLeft: -1,
|
||||
);
|
||||
}
|
||||
|
||||
ClickDocumentMetrics currentClickDocumentMetrics({
|
||||
required int clickViewportX,
|
||||
required int clickViewportY,
|
||||
}) {
|
||||
return ClickDocumentMetrics.unknown;
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
// ignore: avoid_web_libraries_in_flutter
|
||||
import 'dart:html' as html;
|
||||
|
||||
class ClickDocumentMetrics {
|
||||
final int clickDocumentX;
|
||||
final int clickDocumentY;
|
||||
final int pageWidth;
|
||||
final int pageHeight;
|
||||
final int scrollTop;
|
||||
final int scrollLeft;
|
||||
|
||||
const ClickDocumentMetrics({
|
||||
required this.clickDocumentX,
|
||||
required this.clickDocumentY,
|
||||
required this.pageWidth,
|
||||
required this.pageHeight,
|
||||
required this.scrollTop,
|
||||
required this.scrollLeft,
|
||||
});
|
||||
|
||||
static const unknown = ClickDocumentMetrics(
|
||||
clickDocumentX: -1,
|
||||
clickDocumentY: -1,
|
||||
pageWidth: -1,
|
||||
pageHeight: -1,
|
||||
scrollTop: -1,
|
||||
scrollLeft: -1,
|
||||
);
|
||||
}
|
||||
|
||||
ClickDocumentMetrics currentClickDocumentMetrics({
|
||||
required int clickViewportX,
|
||||
required int clickViewportY,
|
||||
}) {
|
||||
try {
|
||||
final documentElement = html.document.documentElement;
|
||||
final body = html.document.body;
|
||||
final pageWidth = _maxPositive([
|
||||
documentElement?.scrollWidth,
|
||||
body?.scrollWidth,
|
||||
documentElement?.clientWidth,
|
||||
body?.clientWidth,
|
||||
]);
|
||||
final pageHeight = _maxPositive([
|
||||
documentElement?.scrollHeight,
|
||||
body?.scrollHeight,
|
||||
documentElement?.clientHeight,
|
||||
body?.clientHeight,
|
||||
]);
|
||||
if (pageWidth <= 0 || pageHeight <= 0) {
|
||||
return ClickDocumentMetrics.unknown;
|
||||
}
|
||||
|
||||
final scrollLeft = _nonNegative(html.window.pageXOffset.round());
|
||||
final scrollTop = _nonNegative(html.window.pageYOffset.round());
|
||||
return ClickDocumentMetrics(
|
||||
clickDocumentX: _nonNegative(clickViewportX + scrollLeft),
|
||||
clickDocumentY: _nonNegative(clickViewportY + scrollTop),
|
||||
pageWidth: pageWidth,
|
||||
pageHeight: pageHeight,
|
||||
scrollTop: scrollTop,
|
||||
scrollLeft: scrollLeft,
|
||||
);
|
||||
} catch (_) {
|
||||
return ClickDocumentMetrics.unknown;
|
||||
}
|
||||
}
|
||||
|
||||
int _maxPositive(List<int?> values) {
|
||||
var max = -1;
|
||||
for (final value in values) {
|
||||
if (value != null && value > max) max = value;
|
||||
}
|
||||
return max > 0 ? max : -1;
|
||||
}
|
||||
|
||||
int _nonNegative(int value) => value < 0 ? 0 : value;
|
||||
@@ -0,0 +1,177 @@
|
||||
// lib/utils/page_name_mapper.dart
|
||||
|
||||
import 'package:analytics_sdk/utils/logger.dart';
|
||||
|
||||
/// 统一的页面 pageKey → pageName 映射管理器
|
||||
/// 支持静态默认映射 + 动态添加/覆盖(业务方可在初始化时自定义)
|
||||
///
|
||||
/// ## 两种注册格式
|
||||
///
|
||||
/// **老格式(精确匹配)**:值为字符串,pageKey 与路由名称完全一致时命中
|
||||
/// ```dart
|
||||
/// PageNameMapper.addMappings({'home': '首页'});
|
||||
/// ```
|
||||
///
|
||||
/// **新格式(支持 `:param` 动态路由)**:值为 `{'key': ..., 'name': ...}`
|
||||
/// - `key`:上报时使用的稳定 pageKey(替换掉含真实 ID 的原始路由名)
|
||||
/// - `name`:页面展示名称
|
||||
/// ```dart
|
||||
/// PageNameMapper.addMappings({
|
||||
/// '/video/:id': {'key': 'video_detail', 'name': '视频详情'},
|
||||
/// });
|
||||
/// ```
|
||||
class PageNameMapper {
|
||||
// 默认映射表(SDK 内置,精确匹配)
|
||||
static const Map<String, String> _defaultMap = {};
|
||||
|
||||
// 老格式:精确匹配映射表(路由名 → pageName)
|
||||
static final Map<String, String> _customMap = {};
|
||||
|
||||
// 新格式::param 模式映射表(pattern → ResolvedPageInfo)
|
||||
static final Map<String, ResolvedPageInfo> _patternMap = {};
|
||||
|
||||
// 新格式 resolvedKey 缓存(resolvedKey → pageName),与 _customMap 隔离
|
||||
// 供 getPageName 在 currentPageKey 已被解析为稳定 key 时反查 pageName
|
||||
static final Map<String, String> _resolvedKeyMap = {};
|
||||
|
||||
static const String _tag = 'PageNameMapper';
|
||||
|
||||
/// 去除 pageKey 前导斜杠,结果为空时保留原值(如纯 "/" 保持为 "/")
|
||||
static String normalizeKey(String pageKey) {
|
||||
if (!pageKey.startsWith('/')) return pageKey;
|
||||
final stripped = pageKey.substring(1);
|
||||
return stripped.isEmpty ? pageKey : stripped;
|
||||
}
|
||||
|
||||
/// 获取页面名称(老格式精确匹配,行为与之前完全一致)
|
||||
///
|
||||
/// 同时查 _resolvedKeyMap,确保新格式解析后的稳定 key 也能反查到 pageName
|
||||
static String getPageName(String pageKey) {
|
||||
final key = normalizeKey(pageKey);
|
||||
return _customMap[key] ?? _resolvedKeyMap[key] ?? _defaultMap[key] ?? key;
|
||||
}
|
||||
|
||||
/// 解析路由名称,返回上报用的稳定 [ResolvedPageInfo]。
|
||||
///
|
||||
/// 查找优先级:
|
||||
/// 1. 老格式精确匹配(_customMap / _defaultMap)
|
||||
/// 2. 新格式精确匹配(_patternMap)
|
||||
/// 3. 新格式 `:param` 通配匹配(_patternMap)
|
||||
/// 4. Fallback:key = name = 归一化后的路由名
|
||||
static ResolvedPageInfo resolvePageInfo(String routeName) {
|
||||
final normalized = normalizeKey(routeName);
|
||||
|
||||
// 1. 老格式精确匹配(只查 _customMap / _defaultMap,不查 _resolvedKeyMap)
|
||||
final directName = _customMap[normalized] ?? _defaultMap[normalized];
|
||||
if (directName != null) return ResolvedPageInfo(normalized, directName);
|
||||
|
||||
// 2. 新格式精确匹配
|
||||
final exactPattern = _patternMap[normalized];
|
||||
if (exactPattern != null) return exactPattern;
|
||||
|
||||
// 3. 新格式 :param 通配匹配
|
||||
for (final entry in _patternMap.entries) {
|
||||
if (_matchesPattern(entry.key, normalized)) {
|
||||
return entry.value;
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Fallback
|
||||
return ResolvedPageInfo(normalized, normalized);
|
||||
}
|
||||
|
||||
/// 动态添加或覆盖单个映射
|
||||
///
|
||||
/// [value] 可为字符串(老格式)或 `{'key': String, 'name': String}`(新格式)
|
||||
static void addMapping(String routePattern, dynamic value) {
|
||||
final normalized = normalizeKey(routePattern);
|
||||
if (value is String) {
|
||||
_customMap[normalized] = value;
|
||||
} else if (value is Map) {
|
||||
// ── 风险 1 修复:严格校验 key / name 字段,避免运行时 crash ──
|
||||
final resolvedKey = value['key'];
|
||||
final name = value['name'];
|
||||
if (resolvedKey is! String || resolvedKey.isEmpty) {
|
||||
Logger.warn(_tag, 'addMapping("$routePattern") 的 key 字段无效或为空,已忽略');
|
||||
return;
|
||||
}
|
||||
if (name is! String || name.isEmpty) {
|
||||
Logger.warn(_tag, 'addMapping("$routePattern") 的 name 字段无效或为空,已忽略');
|
||||
return;
|
||||
}
|
||||
|
||||
// ── 风险 3 修复:检测与已注册 pattern 的歧义冲突 ──
|
||||
if (_hasConflictingPattern(normalized)) {
|
||||
Logger.warn(
|
||||
_tag,
|
||||
'addMapping("$routePattern") 与已注册的某个 pattern 存在歧义,'
|
||||
'将按注册顺序匹配,请检查是否重复注册',
|
||||
);
|
||||
}
|
||||
|
||||
_patternMap[normalized] = ResolvedPageInfo(resolvedKey, name);
|
||||
// ── 风险 2 修复:写入独立的 _resolvedKeyMap,不污染 _customMap ──
|
||||
_resolvedKeyMap[resolvedKey] = name;
|
||||
}
|
||||
}
|
||||
|
||||
/// 批量动态添加或覆盖映射,兼容老格式与新格式混传
|
||||
static void addMappings(Map<String, dynamic> mappings) {
|
||||
for (final entry in mappings.entries) {
|
||||
addMapping(entry.key, entry.value);
|
||||
}
|
||||
}
|
||||
|
||||
/// 清空自定义映射(测试或重置用)
|
||||
static void clearCustom() {
|
||||
_customMap.clear();
|
||||
_patternMap.clear();
|
||||
_resolvedKeyMap.clear();
|
||||
}
|
||||
|
||||
/// 判断路由名称是否匹配某个 :param 模式
|
||||
static bool _matchesPattern(String pattern, String routeName) {
|
||||
final patternParts = pattern.split('/');
|
||||
final routeParts = routeName.split('/');
|
||||
if (patternParts.length != routeParts.length) return false;
|
||||
for (int i = 0; i < patternParts.length; i++) {
|
||||
if (patternParts[i].startsWith(':')) continue; // 通配符,跳过
|
||||
if (patternParts[i] != routeParts[i]) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/// 检测新 pattern 是否与已注册的 pattern 存在歧义(可能命中同一路由)
|
||||
static bool _hasConflictingPattern(String newPattern) {
|
||||
for (final existing in _patternMap.keys) {
|
||||
if (existing == newPattern) continue; // 相同 pattern,属于覆盖,不是冲突
|
||||
if (_patternsConflict(existing, newPattern)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// 判断两个 pattern 是否可能匹配同一条路由
|
||||
static bool _patternsConflict(String p1, String p2) {
|
||||
final parts1 = p1.split('/');
|
||||
final parts2 = p2.split('/');
|
||||
if (parts1.length != parts2.length) return false;
|
||||
for (int i = 0; i < parts1.length; i++) {
|
||||
final isParam1 = parts1[i].startsWith(':');
|
||||
final isParam2 = parts2[i].startsWith(':');
|
||||
// 两段都是固定字面量且不相等 → 不可能同时匹配
|
||||
if (!isParam1 && !isParam2 && parts1[i] != parts2[i]) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/// [PageNameMapper.resolvePageInfo] 的返回值:路由解析后的稳定标识
|
||||
class ResolvedPageInfo {
|
||||
/// 上报用的稳定 pageKey(新格式下为业务方自定义 key,老格式下为归一化路由名)
|
||||
final String key;
|
||||
|
||||
/// 页面展示名称
|
||||
final String name;
|
||||
|
||||
const ResolvedPageInfo(this.key, this.name);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import 'package:analytics_sdk/utils/logger.dart';
|
||||
|
||||
/// 校验页面生命周期 Observer 的接入状态,并避免重复刷屏。
|
||||
class PageObserverRegistrationGuard {
|
||||
bool _missingWarningLogged = false;
|
||||
bool _duplicateWarningLogged = false;
|
||||
|
||||
void reset() {
|
||||
_missingWarningLogged = false;
|
||||
_duplicateWarningLogged = false;
|
||||
}
|
||||
|
||||
void check({
|
||||
required bool pageObserverAttached,
|
||||
required bool sessionObserverAttached,
|
||||
}) {
|
||||
if (!pageObserverAttached && !sessionObserverAttached) {
|
||||
if (_missingWarningLogged) return;
|
||||
_missingWarningLogged = true;
|
||||
Logger.pageLifecycleObserver(
|
||||
'未检测到页面 Observer:请在 MaterialApp.navigatorObservers 中注册 '
|
||||
'AnalyticsSdk.instance.pageObserver 或 '
|
||||
'AnalyticsSdk.instance.sessionObserver,二选一。否则 '
|
||||
'app_session_summary 的 page_stay_list 可能不完整。',
|
||||
level: LogLevel.warn,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (pageObserverAttached && sessionObserverAttached) {
|
||||
if (_duplicateWarningLogged) return;
|
||||
_duplicateWarningLogged = true;
|
||||
Logger.pageLifecycleObserver(
|
||||
'检测到 pageObserver 与 sessionObserver 同时注册:两者必须二选一。'
|
||||
'SDK 已对页面停留和自动 app_page_view 做幂等保护,'
|
||||
'但请尽快修正接入配置。',
|
||||
level: LogLevel.warn,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export 'page_view_auto_fields_stub.dart'
|
||||
if (dart.library.html) 'page_view_auto_fields_web.dart';
|
||||
@@ -0,0 +1 @@
|
||||
String currentPageUrl() => '';
|
||||
@@ -0,0 +1,27 @@
|
||||
// ignore: avoid_web_libraries_in_flutter
|
||||
import 'dart:html' as html;
|
||||
|
||||
String currentPageUrl() {
|
||||
try {
|
||||
final uri = Uri.tryParse(html.window.location.href);
|
||||
if (uri == null) return '';
|
||||
final buffer = StringBuffer();
|
||||
if (uri.scheme.isNotEmpty) {
|
||||
buffer
|
||||
..write(uri.scheme)
|
||||
..write('://');
|
||||
}
|
||||
if (uri.host.isNotEmpty) {
|
||||
buffer.write(uri.host);
|
||||
}
|
||||
if (uri.hasPort) {
|
||||
buffer.write(':${uri.port}');
|
||||
}
|
||||
if (uri.path.isNotEmpty) {
|
||||
buffer.write(uri.path);
|
||||
}
|
||||
return buffer.toString();
|
||||
} catch (_) {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import 'package:analytics_sdk/utils/platform_storage.dart';
|
||||
|
||||
class SdkActivationStore {
|
||||
SdkActivationStore._();
|
||||
|
||||
static const String _storageKey = 'analytics_sdk_activation_time.txt';
|
||||
|
||||
static Future<int> getOrCreateActivationTime() async {
|
||||
try {
|
||||
final cached = await PlatformStorage.read(_storageKey);
|
||||
final cachedTs = int.tryParse(cached?.trim() ?? '');
|
||||
if (cachedTs != null && cachedTs > 0) return cachedTs;
|
||||
|
||||
final now = DateTime.now().millisecondsSinceEpoch ~/ 1000;
|
||||
await PlatformStorage.write(_storageKey, now.toString());
|
||||
return now;
|
||||
} catch (_) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,439 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:analytics_sdk/utils/device_fingerprint_util.dart';
|
||||
import 'package:analytics_sdk/utils/logger.dart';
|
||||
import 'package:crypto/crypto.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
/// SDK 全局上下文:持有所有公共字段、提供 event_id 计算。
|
||||
///
|
||||
/// 作为 SDK 内部的单一状态来源(SSOT),所有事件构造时从此处读取公共字段。
|
||||
/// 不对外暴露,接入方无需直接使用。
|
||||
class SdkContext {
|
||||
/// SDK 版本号(写死,与 pubspec.yaml 保持一致)
|
||||
static const String kSdkVersion = '1.4.6';
|
||||
|
||||
/// appVersion 公共字段默认值。
|
||||
/// 接入方未传 `appVersion`、或 release 模式下传入非法值时均使用该值,
|
||||
/// 保证事件 payload 与 `AnalyticsSdk.getParams()` 返回值保持一致。
|
||||
static const String kDefaultAppVersion = '1.0.0';
|
||||
|
||||
/// 客户端事件签名规则版本。
|
||||
static const String kEventSignatureVersion = '2.0.0';
|
||||
|
||||
static String _eventSignatureHmacKey = '';
|
||||
static String _eventSignatureVersion = kEventSignatureVersion;
|
||||
|
||||
static String? _appId;
|
||||
static String? _channel;
|
||||
static String? _uid;
|
||||
static String? _appVersion;
|
||||
static String? _device;
|
||||
static String? _deviceId;
|
||||
static String? _deviceBrand;
|
||||
static String? _deviceModel;
|
||||
static String? _userAgent;
|
||||
static String? _systemName;
|
||||
static String? _systemVersion;
|
||||
static String? _deviceFingerprint;
|
||||
static String _fingerprintVersion = DeviceFingerprintUtil.kVersion;
|
||||
static Map<String, dynamic> _environmentFields = _defaultEnvironmentFields();
|
||||
|
||||
/// SID 提供者(由 AnalyticsSdk.init() 注入)。
|
||||
/// 调用时同时返回当前 SID 并记录用户行为,避免 entity 层直接依赖 SessionManager。
|
||||
static String Function()? sidProvider;
|
||||
|
||||
/// 配置公共字段
|
||||
///
|
||||
/// 所有参数均为可选,非 null 值才会覆盖现有值。
|
||||
/// 通常在 [AnalyticsSdk.init] 中调用,也可在 init 之后按需更新。
|
||||
/// deviceFingerprint / fingerprintVersion 不再支持从外部传入。
|
||||
static void configure({
|
||||
String? appId,
|
||||
String? channel,
|
||||
String? uid,
|
||||
String? appVersion,
|
||||
String? device,
|
||||
String? deviceId,
|
||||
String? deviceBrand,
|
||||
String? deviceModel,
|
||||
String? userAgent,
|
||||
String? systemName,
|
||||
String? systemVersion,
|
||||
String? deviceFingerprint,
|
||||
String? fingerprintVersion,
|
||||
Map<String, dynamic>? environmentFields,
|
||||
}) {
|
||||
if (appId != null) _appId = appId;
|
||||
if (channel != null) _channel = channel;
|
||||
if (uid != null) _uid = uid;
|
||||
if (appVersion != null) {
|
||||
if (RegExp(r'^\d+\.\d+\.\d+$').hasMatch(appVersion)) {
|
||||
_appVersion = appVersion;
|
||||
} else if (kDebugMode) {
|
||||
Logger.analyticsSdk('appVersion 格式不正确,应为 x.y.z,当前值:$appVersion,已忽略',
|
||||
level: LogLevel.error);
|
||||
} else {
|
||||
_appVersion = kDefaultAppVersion;
|
||||
}
|
||||
}
|
||||
if (device != null) _device = device;
|
||||
if (deviceId != null) _deviceId = deviceId;
|
||||
if (deviceBrand != null) _deviceBrand = deviceBrand;
|
||||
if (deviceModel != null) _deviceModel = deviceModel;
|
||||
if (userAgent != null) _userAgent = userAgent;
|
||||
if (systemName != null) _systemName = systemName;
|
||||
if (systemVersion != null) _systemVersion = systemVersion;
|
||||
if (environmentFields != null) {
|
||||
_environmentFields = {
|
||||
..._defaultEnvironmentFields(),
|
||||
..._normalizeEnvironmentFields(environmentFields),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// SDK 内部调用:重置自动生成的设备指纹状态。
|
||||
static void resetGeneratedDeviceFingerprint() {
|
||||
_deviceFingerprint = null;
|
||||
_fingerprintVersion = DeviceFingerprintUtil.kVersion;
|
||||
}
|
||||
|
||||
/// SDK 内部调用:写入已生成的设备指纹。
|
||||
static void configureGeneratedDeviceFingerprint(String deviceFingerprint) {
|
||||
final normalized = deviceFingerprint.trim();
|
||||
_deviceFingerprint = normalized.isEmpty ? null : normalized;
|
||||
_fingerprintVersion = DeviceFingerprintUtil.kVersion;
|
||||
}
|
||||
|
||||
/// 更新用户 ID(登录或切换账号时调用)
|
||||
static void setUid(String newUid) {
|
||||
_uid = newUid;
|
||||
}
|
||||
|
||||
/// 更新渠道(渠道变更时调用)
|
||||
static void setChannel(String channel) {
|
||||
_channel = channel;
|
||||
}
|
||||
|
||||
/// 增量更新系统、网络、硬件和浏览器环境快照字段。
|
||||
///
|
||||
/// 用于网络、电量、充电状态等运行期可能变化的字段刷新。
|
||||
static void updateEnvironmentFields(Map<String, dynamic> fields) {
|
||||
if (fields.isEmpty) return;
|
||||
_environmentFields = {
|
||||
..._environmentFields,
|
||||
..._normalizeEnvironmentFields(fields),
|
||||
};
|
||||
}
|
||||
|
||||
/// 配置客户端事件签名密钥。
|
||||
///
|
||||
/// 密钥来自加密配置解密后的 `signKey`,不写入事件、payload、日志或缓存。
|
||||
static void configureEventSignature({
|
||||
String? signKey,
|
||||
String? version,
|
||||
}) {
|
||||
if (signKey != null) {
|
||||
_eventSignatureHmacKey = signKey.trim();
|
||||
}
|
||||
final normalizedVersion = version?.trim();
|
||||
if (normalizedVersion != null && normalizedVersion.isNotEmpty) {
|
||||
_eventSignatureVersion = normalizedVersion;
|
||||
}
|
||||
}
|
||||
|
||||
/// 重置所有公共字段为 null(SDK 重新初始化时使用)
|
||||
///
|
||||
/// 与 [configure] 不同,此方法会将所有字段清为 null,
|
||||
/// 而 configure 中传 null 表示"不修改"。
|
||||
static void reset() {
|
||||
_appId = null;
|
||||
_channel = null;
|
||||
_uid = null;
|
||||
_appVersion = null;
|
||||
_device = null;
|
||||
_deviceId = null;
|
||||
_deviceBrand = null;
|
||||
_deviceModel = null;
|
||||
_userAgent = null;
|
||||
_systemName = null;
|
||||
_systemVersion = null;
|
||||
_deviceFingerprint = null;
|
||||
_fingerprintVersion = DeviceFingerprintUtil.kVersion;
|
||||
_environmentFields = _defaultEnvironmentFields();
|
||||
_eventSignatureHmacKey = '';
|
||||
_eventSignatureVersion = kEventSignatureVersion;
|
||||
sidProvider = null;
|
||||
}
|
||||
|
||||
// ──────── Getters(调试 / 外部复用) ────────────────────────────────────
|
||||
|
||||
static String? get appId => _appId;
|
||||
static String? get channel => _channel;
|
||||
static String? get uid => _uid;
|
||||
static String? get appVersion => _appVersion;
|
||||
static String? get deviceId => _deviceId;
|
||||
static String? get deviceBrand => _deviceBrand;
|
||||
static String? get deviceModel => _deviceModel;
|
||||
static String get deviceFingerprint => _deviceFingerprint ?? '';
|
||||
static String get fingerprintVersion => _fingerprintVersion;
|
||||
static Map<String, dynamic> get environmentFields =>
|
||||
Map<String, dynamic>.from(_environmentFields);
|
||||
|
||||
/// 获取设备公共字段(供产品方在服务端上报事件时复用)
|
||||
///
|
||||
/// 返回的 Map 包含:sid、device、device_id、device_brand、device_model、
|
||||
/// user_agent、system_name、system_version、device_fingerprint、fp_version。
|
||||
/// 未配置的字段返回空字符串。
|
||||
static Map<String, String> getDeviceCommonFields() {
|
||||
String sid = '';
|
||||
try {
|
||||
sid = sidProvider?.call() ?? '';
|
||||
} catch (_) {
|
||||
// 未初始化或 SID 获取异常时保持为空,确保该读取 API 不抛异常。
|
||||
}
|
||||
|
||||
return {
|
||||
'sid': sid,
|
||||
'device': _device ?? '',
|
||||
'device_id': _deviceId ?? '',
|
||||
'device_brand': _deviceBrand ?? '',
|
||||
'device_model': _deviceModel ?? '',
|
||||
'user_agent': _userAgent ?? '',
|
||||
'system_name': _systemName ?? '',
|
||||
'system_version': _systemVersion ?? '',
|
||||
'device_fingerprint': _deviceFingerprint ?? '',
|
||||
'fp_version': _fingerprintVersion,
|
||||
};
|
||||
}
|
||||
|
||||
/// 获取系统、网络、硬件和浏览器环境快照字段。
|
||||
///
|
||||
/// 仅供 SDK 内部构建 `sdk_init` 启动快照和少量汇总事件使用,普通事件公共字段
|
||||
/// 不再重复携带这些环境快照。
|
||||
static Map<String, dynamic> getEnvironmentSnapshotFields() {
|
||||
return Map<String, dynamic>.from(_environmentFields);
|
||||
}
|
||||
|
||||
/// 给 SDK 客户端事件补充外层签名字段。
|
||||
///
|
||||
/// 历史版本曾把轻量环境签名写入 payload;这里会清理这些保留字段,
|
||||
/// 避免同一事件同时出现新旧两套签名。
|
||||
static Map<String, dynamic> attachDeviceEnvIfNeeded(
|
||||
Map<String, dynamic> event,
|
||||
) {
|
||||
final eventName = event['event']?.toString() ?? '';
|
||||
final isServerOnlyEvent = _serverOnlyEvents.contains(eventName);
|
||||
final result = Map<String, dynamic>.from(event);
|
||||
final existingPayload = result['payload'];
|
||||
final existingDeviceEnv = result.remove('device_env');
|
||||
|
||||
if (existingPayload is Map || existingPayload == null) {
|
||||
final payload = <String, dynamic>{
|
||||
if (existingPayload is Map)
|
||||
...Map<String, dynamic>.from(existingPayload),
|
||||
if (existingDeviceEnv is Map)
|
||||
...Map<String, dynamic>.from(existingDeviceEnv),
|
||||
};
|
||||
payload.removeWhere(
|
||||
(key, _) => _reservedPayloadSignatureFields.contains(key));
|
||||
result['payload'] = payload;
|
||||
}
|
||||
|
||||
if (!isServerOnlyEvent && _eventSignatureHmacKey.isNotEmpty) {
|
||||
final eventForSignature = Map<String, dynamic>.from(result);
|
||||
result['event_sig_version'] = _eventSignatureVersion;
|
||||
result['event_sig'] = _eventSignature(eventForSignature);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/// 生成公共字段 Map
|
||||
///
|
||||
/// 包含所有客户端事件上报所需的公共字段:
|
||||
/// event、channel、app_id、uid、sid、client_ts、
|
||||
/// device、device_id、device_brand、device_model、
|
||||
/// user_agent、system_name、system_version、
|
||||
/// device_fingerprint、fp_version、sdk_version、app_version。
|
||||
///
|
||||
/// 本方法永远不会抛出异常。
|
||||
static Map<String, dynamic> generateCommonFields(String event) {
|
||||
final now = DateTime.now();
|
||||
final clientTs = now.millisecondsSinceEpoch ~/ 1000;
|
||||
|
||||
String sid;
|
||||
try {
|
||||
sid = sidProvider?.call() ?? 'fallback_${now.millisecondsSinceEpoch}';
|
||||
} catch (_) {
|
||||
sid = 'fallback_${now.millisecondsSinceEpoch}';
|
||||
}
|
||||
|
||||
return {
|
||||
'event': event,
|
||||
'channel': _channel ?? '',
|
||||
'app_id': _appId ?? '',
|
||||
'uid': _uid ?? '',
|
||||
'sid': sid,
|
||||
'client_ts': clientTs,
|
||||
'device': _device ?? '',
|
||||
'device_id': _deviceId ?? '',
|
||||
'device_brand': _deviceBrand ?? '',
|
||||
'device_model': _deviceModel ?? '',
|
||||
'user_agent': _userAgent ?? '',
|
||||
'system_name': _systemName ?? '',
|
||||
'system_version': _systemVersion ?? '',
|
||||
'device_fingerprint': _deviceFingerprint ?? '',
|
||||
'fp_version': _fingerprintVersion,
|
||||
'sdk_version': kSdkVersion,
|
||||
'app_version': _appVersion ?? kDefaultAppVersion,
|
||||
};
|
||||
}
|
||||
|
||||
/// 根据字段列表生成 event_id
|
||||
///
|
||||
/// 计算规则:
|
||||
/// 1. 对每个字段值单独做 MD5
|
||||
/// 2. 将所有 MD5 用 `|` 拼接
|
||||
/// 3. 对拼接结果再做一次 MD5,作为最终 event_id
|
||||
///
|
||||
/// **注意**:`device_fingerprint` 不参与 event_id 计算。
|
||||
/// 本方法永远不会抛出异常,失败时返回基于时间戳的备选 ID。
|
||||
static String generateEventId(List<String> fields) {
|
||||
try {
|
||||
final fieldDigests = <String>[];
|
||||
for (final value in fields) {
|
||||
final v = value;
|
||||
final bytes = utf8.encode(v);
|
||||
final digest = md5.convert(bytes);
|
||||
fieldDigests.add(digest.toString());
|
||||
}
|
||||
|
||||
final joined = fieldDigests.join('|');
|
||||
final finalDigest = md5.convert(utf8.encode(joined));
|
||||
return finalDigest.toString();
|
||||
} catch (e) {
|
||||
try {
|
||||
final content = fields.join('|');
|
||||
return 'h${(content.hashCode & 0x7FFFFFFF).toRadixString(16)}';
|
||||
} catch (_) {
|
||||
return 't${DateTime.now().millisecondsSinceEpoch}';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static Map<String, dynamic> _defaultEnvironmentFields() => {
|
||||
'language': '',
|
||||
'timezone': '',
|
||||
'package_name': '',
|
||||
'screen_width': -1,
|
||||
'screen_height': -1,
|
||||
'screen_dpi': -1,
|
||||
'device_pixel_ratio': -1,
|
||||
'orientation': '',
|
||||
'screen_brightness': -1,
|
||||
'volume': -1,
|
||||
'is_screen_on': -1,
|
||||
'network_type': 'unknown',
|
||||
'battery_level': -1,
|
||||
'is_charging': -1,
|
||||
'battery_state': 'unknown',
|
||||
'cpu_cores': -1,
|
||||
'device_memory_gb': -1,
|
||||
'browser_name': '',
|
||||
'browser_version': '',
|
||||
'cookie_enabled': -1,
|
||||
'local_storage_enabled': -1,
|
||||
'touch_support': -1,
|
||||
'max_touch_points': -1,
|
||||
'color_depth': -1,
|
||||
'is_pwa': -1,
|
||||
};
|
||||
|
||||
static Map<String, dynamic> _normalizeEnvironmentFields(
|
||||
Map<String, dynamic> fields,
|
||||
) {
|
||||
final normalized = Map<String, dynamic>.from(fields);
|
||||
if (normalized.containsKey('device_pixel_ratio')) {
|
||||
normalized['device_pixel_ratio'] =
|
||||
_normalizeDevicePixelRatio(normalized['device_pixel_ratio']);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
static int _normalizeDevicePixelRatio(dynamic value) {
|
||||
if (value == null) return -1;
|
||||
final ratio =
|
||||
value is num ? value.toDouble() : double.tryParse(value.toString());
|
||||
if (ratio == null || !ratio.isFinite) return -1;
|
||||
if (ratio > 10) return ratio.round();
|
||||
return (ratio * 100).round();
|
||||
}
|
||||
|
||||
static const Set<String> _serverOnlyEvents = {
|
||||
'user_register',
|
||||
'user_login',
|
||||
'realtime_online',
|
||||
'order_created',
|
||||
'order_paid',
|
||||
'coin_consume',
|
||||
'video_status_change',
|
||||
};
|
||||
|
||||
static const Set<String> _reservedPayloadSignatureFields = {
|
||||
'sdk_type',
|
||||
'client_type',
|
||||
'id_type',
|
||||
'event_sig',
|
||||
'event_sig_version',
|
||||
'security_key_version',
|
||||
'sign_algorithm',
|
||||
'sign_ts',
|
||||
};
|
||||
|
||||
static String _eventSignature(
|
||||
Map<String, dynamic> event,
|
||||
) {
|
||||
try {
|
||||
if (_eventSignatureHmacKey.isEmpty) return '';
|
||||
final factors = [
|
||||
event['event']?.toString() ?? '',
|
||||
event['event_id']?.toString() ?? '',
|
||||
event['app_id']?.toString() ?? '',
|
||||
event['client_ts']?.toString() ?? '',
|
||||
event['device_fingerprint']?.toString() ?? '',
|
||||
];
|
||||
final signatureInput = factors.join('|');
|
||||
final signature = Hmac(sha256, utf8.encode(_eventSignatureHmacKey))
|
||||
.convert(utf8.encode(signatureInput))
|
||||
.toString();
|
||||
Logger.analyticsSdk(
|
||||
'event_sig input="$signatureInput" '
|
||||
'event_sig=$signature '
|
||||
'event_sig_version=$_eventSignatureVersion',
|
||||
);
|
||||
return signature;
|
||||
} catch (_) {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/// 从公共字段 Map 和业务特有字段列表生成 event_id。
|
||||
///
|
||||
/// 本方法永远不会抛出异常。
|
||||
static String generateEventIdFromCommonFields(
|
||||
Map<String, dynamic> commonFields, List<String> extraFields) {
|
||||
return generateEventId([
|
||||
commonFields['event'] as String? ?? '',
|
||||
commonFields['app_id'] as String? ?? '',
|
||||
commonFields['channel'] as String? ?? '',
|
||||
commonFields['uid'] as String? ?? '',
|
||||
commonFields['device'] as String? ?? '',
|
||||
commonFields['device_id'] as String? ?? '',
|
||||
commonFields['device_brand'] as String? ?? '',
|
||||
commonFields['device_model'] as String? ?? '',
|
||||
(commonFields['client_ts'] ?? '').toString(),
|
||||
commonFields['sid'] as String? ?? '',
|
||||
...extraFields,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
/// Builds the sdk_init payload from the SDK common fields plus platform-only
|
||||
/// fields. Web and app clients intentionally carry different field families.
|
||||
class SdkInitPayloadBuilder {
|
||||
SdkInitPayloadBuilder._();
|
||||
|
||||
static Map<String, dynamic> build({
|
||||
required Map<String, dynamic> commonFields,
|
||||
required bool isWeb,
|
||||
Map<String, dynamic> platformFields = const {},
|
||||
}) {
|
||||
final fields = {
|
||||
...commonFields,
|
||||
...platformFields,
|
||||
};
|
||||
|
||||
final osFamily = _osFamily(_string(fields['system_name'], 'unknown'));
|
||||
final payload = <String, dynamic>{
|
||||
'platform': isWeb ? 'web' : 'flutter',
|
||||
'os_family': osFamily,
|
||||
'device_type': _deviceType(_string(fields['device'], '')),
|
||||
'language': _string(fields['language'], 'unknown'),
|
||||
'timezone': _string(fields['timezone'], 'unknown'),
|
||||
'network_type': _string(fields['network_type'], 'unknown'),
|
||||
'screen_width': _int(fields['screen_width'], -1),
|
||||
'screen_height': _int(fields['screen_height'], -1),
|
||||
'screen_dpi': _int(fields['screen_dpi'], -1),
|
||||
'device_pixel_ratio': _devicePixelRatio(fields['device_pixel_ratio']),
|
||||
'orientation': _string(fields['orientation'], 'unknown'),
|
||||
'cpu_cores': _int(fields['cpu_cores'], -1),
|
||||
'device_memory_gb': _int(fields['device_memory_gb'], -1),
|
||||
'is_debug': kDebugMode ? 1 : 0,
|
||||
'flutter_build_mode': _string(fields['flutter_build_mode'], _buildMode()),
|
||||
'touch_support': _int(fields['touch_support'], -1),
|
||||
'max_touch_points': _int(fields['max_touch_points'], -1),
|
||||
};
|
||||
|
||||
if (isWeb) {
|
||||
payload.addAll(_webPayload(fields));
|
||||
} else if (osFamily == 'android') {
|
||||
payload.addAll(_androidPayload(fields));
|
||||
} else if (osFamily == 'ios') {
|
||||
payload.addAll(_iosPayload(fields));
|
||||
} else {
|
||||
payload.addAll(_flutterPayload());
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
static Map<String, dynamic> _nativeSharedPayload(
|
||||
Map<String, dynamic> fields,
|
||||
) {
|
||||
return {
|
||||
'carrier': _string(fields['carrier'], ''),
|
||||
'mcc': _string(fields['mcc'], 'unknown'),
|
||||
'mnc': _string(fields['mnc'], 'unknown'),
|
||||
'has_sim': _int(fields['has_sim'], -1),
|
||||
'storage': _int(fields['storage'], -1),
|
||||
'available_storage': _int(fields['available_storage'], -1),
|
||||
'battery_level': _int(fields['battery_level'], -1),
|
||||
'is_charging': _int(fields['is_charging'], -1),
|
||||
'vpn_enabled': _int(fields['vpn_enabled'], -1),
|
||||
'proxy_enabled': _int(fields['proxy_enabled'], -1),
|
||||
};
|
||||
}
|
||||
|
||||
static Map<String, dynamic> _androidPayload(Map<String, dynamic> fields) {
|
||||
return {
|
||||
..._nativeSharedPayload(fields),
|
||||
'activation_time': _int(fields['activation_time'], 0),
|
||||
'install_source': _string(fields['install_source'], ''),
|
||||
'android_id': _string(fields['android_id'], ''),
|
||||
'android_id_o': _string(fields['android_id_o'], ''),
|
||||
'android_id_status': _string(fields['android_id_status'], 'unknown'),
|
||||
'oaid': _string(fields['oaid'], ''),
|
||||
'oaid_o': _string(fields['oaid_o'], ''),
|
||||
'aaid': _string(fields['aaid'], ''),
|
||||
'aaid_o': _string(fields['aaid_o'], ''),
|
||||
'guid': _string(fields['guid'], ''),
|
||||
'guid_o': _string(fields['guid_o'], ''),
|
||||
'brand':
|
||||
_string(fields['brand'], _string(fields['device_brand'], 'unknown')),
|
||||
'manufacturer': _string(fields['manufacturer'], 'unknown'),
|
||||
'model':
|
||||
_string(fields['model'], _string(fields['device_model'], 'unknown')),
|
||||
'package_name': _string(fields['package_name'], 'unknown'),
|
||||
'abi': _string(fields['abi'], 'unknown'),
|
||||
'is_rooted': _int(fields['is_rooted'], -1),
|
||||
'usb_debug_enabled': _int(fields['usb_debug_enabled'], -1),
|
||||
'developer_options_enabled':
|
||||
_int(fields['developer_options_enabled'], -1),
|
||||
'adb_enabled': _int(fields['adb_enabled'], -1),
|
||||
'has_baseband': _int(fields['has_baseband'], -1),
|
||||
'sensor_count_supported': _int(fields['sensor_count_supported'], -1),
|
||||
'has_accelerometer': _int(fields['has_accelerometer'], -1),
|
||||
'has_gyroscope': _int(fields['has_gyroscope'], -1),
|
||||
'has_magnetometer': _int(fields['has_magnetometer'], -1),
|
||||
'has_light_sensor': _int(fields['has_light_sensor'], -1),
|
||||
'has_proximity_sensor': _int(fields['has_proximity_sensor'], -1),
|
||||
'build_hardware': _string(fields['build_hardware'], 'unknown'),
|
||||
'build_product': _string(fields['build_product'], 'unknown'),
|
||||
'build_fingerprint': _string(fields['build_fingerprint'], 'unknown'),
|
||||
};
|
||||
}
|
||||
|
||||
static Map<String, dynamic> _iosPayload(Map<String, dynamic> fields) {
|
||||
return {
|
||||
..._nativeSharedPayload(fields),
|
||||
'bundle_id': _string(fields['bundle_id'], ''),
|
||||
'ios_device_model_raw': _string(fields['ios_device_model_raw'], ''),
|
||||
'idfv': _string(fields['idfv'], ''),
|
||||
'idfa': _string(fields['idfa'], ''),
|
||||
'att_status': _string(fields['att_status'], 'unknown'),
|
||||
'lat': _int(fields['lat'], -1),
|
||||
'is_jailbroken': _int(fields['is_jailbroken'], -1),
|
||||
};
|
||||
}
|
||||
|
||||
static Map<String, dynamic> _flutterPayload() {
|
||||
return const {};
|
||||
}
|
||||
|
||||
static Map<String, dynamic> _webPayload(Map<String, dynamic> fields) {
|
||||
return {
|
||||
'current_url': _string(fields['current_url'], ''),
|
||||
'current_domain': _string(fields['current_domain'], ''),
|
||||
'current_path': _string(fields['current_path'], ''),
|
||||
'current_query_hash': _string(fields['current_query_hash'], ''),
|
||||
'referer': _string(fields['referer'], ''),
|
||||
'referer_domain': _string(fields['referer_domain'], ''),
|
||||
'referer_path': _string(fields['referer_path'], ''),
|
||||
'browser_name': _string(fields['browser_name'], 'unknown'),
|
||||
'browser_version': _string(fields['browser_version'], 'unknown'),
|
||||
'browser_kernel': _string(fields['browser_kernel'], 'unknown'),
|
||||
'display_mode': _string(fields['display_mode'], 'unknown'),
|
||||
'visibility_state': _string(fields['visibility_state'], 'unknown'),
|
||||
'cookie_enabled': _int(fields['cookie_enabled'], -1),
|
||||
'local_storage_enabled': _int(fields['local_storage_enabled'], -1),
|
||||
'session_storage_enabled': _int(fields['session_storage_enabled'], -1),
|
||||
'indexeddb_enabled': _int(fields['indexeddb_enabled'], -1),
|
||||
'service_worker_enabled': _int(fields['service_worker_enabled'], -1),
|
||||
'connection_type': _string(fields['connection_type'], 'unknown'),
|
||||
'effective_type': _string(fields['effective_type'], 'unknown'),
|
||||
'downlink': _int(fields['downlink'], -1),
|
||||
'rtt': _int(fields['rtt'], -1),
|
||||
'canvas_fp': _string(fields['canvas_fp'], ''),
|
||||
'webgl_fp': _string(fields['webgl_fp'], ''),
|
||||
'webgl_vendor': _string(fields['webgl_vendor'], 'unknown'),
|
||||
'webgl_renderer': _string(fields['webgl_renderer'], 'unknown'),
|
||||
'webgl_status': _string(fields['webgl_status'], 'unknown'),
|
||||
'webgl_extensions_hash': _string(fields['webgl_extensions_hash'], ''),
|
||||
'webgl_params_hash': _string(fields['webgl_params_hash'], ''),
|
||||
'audio_sample_rate': _int(fields['audio_sample_rate'], -1),
|
||||
'fonts': _string(fields['fonts'], ''),
|
||||
'fonts_hash': _string(fields['fonts_hash'], ''),
|
||||
'font_count': _int(fields['font_count'], -1),
|
||||
'plugins': _string(fields['plugins'], ''),
|
||||
'plugins_hash': _string(fields['plugins_hash'], ''),
|
||||
'plugin_count': _int(fields['plugin_count'], -1),
|
||||
'mimes': _string(fields['mimes'], ''),
|
||||
'mimes_hash': _string(fields['mimes_hash'], ''),
|
||||
'mime_count': _int(fields['mime_count'], -1),
|
||||
'has_webdriver': _int(fields['has_webdriver'], 0),
|
||||
'hardware_concurrency': _int(fields['hardware_concurrency'], -1),
|
||||
'browser_platform': _string(fields['browser_platform'], 'unknown'),
|
||||
'color_depth': _int(fields['color_depth'], -1),
|
||||
'audio_fp': _string(fields['audio_fp'], ''),
|
||||
'viewport_width': _int(fields['viewport_width'], -1),
|
||||
'viewport_height': _int(fields['viewport_height'], -1),
|
||||
'is_headless': _int(fields['is_headless'], -1),
|
||||
'headless_reason': _string(fields['headless_reason'], ''),
|
||||
};
|
||||
}
|
||||
|
||||
static String _string(dynamic value, String fallback) {
|
||||
if (value == null) return fallback;
|
||||
final string = value.toString().trim();
|
||||
return string.isEmpty ? fallback : string;
|
||||
}
|
||||
|
||||
static int _int(dynamic value, int fallback) {
|
||||
if (value is int) return value;
|
||||
if (value is double && value.isFinite) return value.round();
|
||||
if (value is String) {
|
||||
final asInt = int.tryParse(value.trim());
|
||||
if (asInt != null) return asInt;
|
||||
final asDouble = double.tryParse(value.trim());
|
||||
if (asDouble != null && asDouble.isFinite) return asDouble.round();
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
static int _devicePixelRatio(dynamic value) {
|
||||
if (value == null) return -1;
|
||||
final ratio =
|
||||
value is num ? value.toDouble() : double.tryParse(value.toString());
|
||||
if (ratio == null || !ratio.isFinite) return -1;
|
||||
if (ratio > 10) return ratio.round();
|
||||
return (ratio * 100).round();
|
||||
}
|
||||
|
||||
static String _deviceType(String device) {
|
||||
switch (device.toLowerCase()) {
|
||||
case 'android':
|
||||
case 'ios':
|
||||
return 'phone';
|
||||
case 'pc':
|
||||
return 'desktop';
|
||||
default:
|
||||
return 'unknown';
|
||||
}
|
||||
}
|
||||
|
||||
static String _osFamily(String systemName) {
|
||||
switch (systemName.toLowerCase()) {
|
||||
case 'android':
|
||||
return 'android';
|
||||
case 'ios':
|
||||
return 'ios';
|
||||
case 'macos':
|
||||
return 'macos';
|
||||
case 'windows':
|
||||
return 'windows';
|
||||
case 'linux':
|
||||
return 'linux';
|
||||
default:
|
||||
return 'unknown';
|
||||
}
|
||||
}
|
||||
|
||||
static String _buildMode() {
|
||||
if (kReleaseMode) return 'release';
|
||||
if (kProfileMode) return 'profile';
|
||||
if (kDebugMode) return 'debug';
|
||||
return 'unknown';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export 'sdk_init_platform_fields_native.dart'
|
||||
// ignore: uri_does_not_exist
|
||||
if (dart.library.html) 'sdk_init_platform_fields_web.dart';
|
||||
@@ -0,0 +1,61 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:device_info_plus/device_info_plus.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:package_info_plus/package_info_plus.dart';
|
||||
|
||||
const MethodChannel _nativeChannel = MethodChannel('analytics_sdk');
|
||||
|
||||
Future<Map<String, dynamic>> collectSdkInitPlatformFields() async {
|
||||
final fields = <String, dynamic>{};
|
||||
|
||||
try {
|
||||
final info = await PackageInfo.fromPlatform();
|
||||
fields['package_name'] = info.packageName;
|
||||
fields['bundle_id'] = info.packageName;
|
||||
} catch (_) {}
|
||||
|
||||
try {
|
||||
final plugin = DeviceInfoPlugin();
|
||||
if (Platform.isAndroid) {
|
||||
final info = await plugin.androidInfo;
|
||||
fields.addAll({
|
||||
'brand': info.brand,
|
||||
'manufacturer': info.manufacturer,
|
||||
'model': info.model,
|
||||
'abi': info.supportedAbis.isNotEmpty
|
||||
? info.supportedAbis.first
|
||||
: 'unknown',
|
||||
'is_rooted': -1,
|
||||
'build_hardware': info.hardware,
|
||||
'build_product': info.product,
|
||||
'build_fingerprint': info.fingerprint,
|
||||
});
|
||||
} else if (Platform.isIOS) {
|
||||
final info = await plugin.iosInfo;
|
||||
fields.addAll({
|
||||
'bundle_id': fields['bundle_id'] ?? '',
|
||||
'ios_device_model_raw': info.utsname.machine,
|
||||
'is_jailbroken': -1,
|
||||
});
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
fields.addAll(await _collectNativeChannelFields());
|
||||
|
||||
return fields;
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> _collectNativeChannelFields() async {
|
||||
try {
|
||||
final result = await _nativeChannel
|
||||
.invokeMapMethod<String, dynamic>('getSdkInitPlatformFields');
|
||||
if (result == null) return const {};
|
||||
return Map<String, dynamic>.from(result)
|
||||
..removeWhere((key, value) => value == null);
|
||||
} on MissingPluginException {
|
||||
return const {};
|
||||
} catch (_) {
|
||||
return const {};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,540 @@
|
||||
// ignore: avoid_web_libraries_in_flutter
|
||||
import 'dart:convert';
|
||||
import 'dart:html' as html;
|
||||
|
||||
import 'package:crypto/crypto.dart';
|
||||
import 'package:analytics_sdk/utils/js_interop_compat.dart';
|
||||
|
||||
Future<Map<String, dynamic>> collectSdkInitPlatformFields() async {
|
||||
final navigator = html.window.navigator;
|
||||
final screen = html.window.screen;
|
||||
final uri = Uri.tryParse(html.window.location.href);
|
||||
final refererUri = Uri.tryParse(html.document.referrer);
|
||||
final connection = _connection(navigator);
|
||||
final browser = _parseBrowser(navigator.userAgent);
|
||||
final canvasFp = _canvasFingerprint();
|
||||
final webgl = _webglInfo();
|
||||
final audio = _audioInfo();
|
||||
final fonts = _fonts();
|
||||
final plugins = _plugins(navigator);
|
||||
final mimes = _mimes(navigator);
|
||||
final headless = _headlessReasons(navigator, plugins);
|
||||
headless.addAll(webgl.headlessReasons);
|
||||
if (fonts != null && fonts.isNotEmpty && fonts.length < 3) {
|
||||
headless.add('fonts_low');
|
||||
}
|
||||
|
||||
return {
|
||||
'current_url': _urlWithoutQueryAndFragment(uri),
|
||||
'current_domain': uri?.host ?? '',
|
||||
'current_path': uri?.path ?? '',
|
||||
'current_query_hash': _hash(uri?.query ?? ''),
|
||||
'referer': _urlWithoutQueryAndFragment(refererUri),
|
||||
'referer_domain': refererUri?.host ?? '',
|
||||
'referer_path': refererUri?.path ?? '',
|
||||
'browser_name': browser.name,
|
||||
'browser_version': browser.version,
|
||||
'browser_kernel': _browserKernel(navigator.userAgent),
|
||||
'display_mode': _displayMode(),
|
||||
'visibility_state': _nonEmpty(html.document.visibilityState, 'unknown'),
|
||||
'cookie_enabled': navigator.cookieEnabled == true ? 1 : 0,
|
||||
'local_storage_enabled': _storageEnabled(html.window.localStorage),
|
||||
'session_storage_enabled': _storageEnabled(html.window.sessionStorage),
|
||||
'indexeddb_enabled': _hasProperty(html.window, 'indexedDB'),
|
||||
'service_worker_enabled': _hasProperty(navigator, 'serviceWorker'),
|
||||
'connection_type': _string(connection, 'type', 'unknown'),
|
||||
'effective_type': _effectiveType(connection),
|
||||
'downlink': _downlinkKbps(connection),
|
||||
'rtt': _number(connection, 'rtt', -1).round(),
|
||||
'canvas_fp': canvasFp,
|
||||
'webgl_fp': webgl.fp,
|
||||
'webgl_vendor': webgl.vendor,
|
||||
'webgl_renderer': webgl.renderer,
|
||||
'webgl_status': webgl.status,
|
||||
'webgl_extensions_hash': webgl.extensionsHash,
|
||||
'webgl_params_hash': webgl.paramsHash,
|
||||
'audio_sample_rate': audio.sampleRate,
|
||||
'fonts': fonts?.join(',') ?? '',
|
||||
'fonts_hash': _hash(fonts?.join('|') ?? ''),
|
||||
'font_count': fonts?.length ?? -1,
|
||||
'plugins': plugins.join(','),
|
||||
'plugins_hash': _hash(plugins.join('|')),
|
||||
'plugin_count': plugins.length,
|
||||
'mimes': mimes.join(','),
|
||||
'mimes_hash': _hash(mimes.join('|')),
|
||||
'mime_count': mimes.length,
|
||||
'has_webdriver': navigator.webdriver == true ? 1 : 0,
|
||||
'hardware_concurrency': navigator.hardwareConcurrency ?? -1,
|
||||
'browser_platform': _nonEmpty(navigator.platform, 'unknown'),
|
||||
'color_depth': screen?.colorDepth ?? -1,
|
||||
'audio_fp': audio.fp,
|
||||
'viewport_width': html.window.innerWidth ?? -1,
|
||||
'viewport_height': html.window.innerHeight ?? -1,
|
||||
'touch_support': (navigator.maxTouchPoints ?? 0) > 0 ? 1 : 0,
|
||||
'max_touch_points': navigator.maxTouchPoints ?? -1,
|
||||
'is_headless': headless.isEmpty ? 0 : 1,
|
||||
'headless_reason': _unique(headless).join(','),
|
||||
};
|
||||
}
|
||||
|
||||
class _BrowserInfo {
|
||||
final String name;
|
||||
final String version;
|
||||
|
||||
const _BrowserInfo(this.name, this.version);
|
||||
}
|
||||
|
||||
class _WebglInfo {
|
||||
final String fp;
|
||||
final String vendor;
|
||||
final String renderer;
|
||||
final String status;
|
||||
final String extensionsHash;
|
||||
final String paramsHash;
|
||||
final List<String> headlessReasons;
|
||||
|
||||
const _WebglInfo({
|
||||
required this.fp,
|
||||
required this.vendor,
|
||||
required this.renderer,
|
||||
required this.status,
|
||||
required this.extensionsHash,
|
||||
required this.paramsHash,
|
||||
required this.headlessReasons,
|
||||
});
|
||||
}
|
||||
|
||||
class _AudioInfo {
|
||||
final int sampleRate;
|
||||
final String fp;
|
||||
|
||||
const _AudioInfo(this.sampleRate, this.fp);
|
||||
}
|
||||
|
||||
String _urlWithoutQueryAndFragment(Uri? uri) {
|
||||
if (uri == null) return '';
|
||||
final buffer = StringBuffer();
|
||||
if (uri.scheme.isNotEmpty) {
|
||||
buffer
|
||||
..write(uri.scheme)
|
||||
..write('://');
|
||||
}
|
||||
if (uri.host.isNotEmpty) {
|
||||
buffer.write(uri.host);
|
||||
}
|
||||
if (uri.hasPort) {
|
||||
buffer.write(':${uri.port}');
|
||||
}
|
||||
if (uri.path.isNotEmpty) {
|
||||
buffer.write(uri.path);
|
||||
}
|
||||
return buffer.toString();
|
||||
}
|
||||
|
||||
Object? _connection(html.Navigator navigator) {
|
||||
for (final key in ['connection', 'mozConnection', 'webkitConnection']) {
|
||||
try {
|
||||
if (hasJsProperty(navigator, key)) {
|
||||
return getJsProperty(navigator, key);
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
_BrowserInfo _parseBrowser(String ua) {
|
||||
final rules = <String, RegExp>{
|
||||
'Edge': RegExp(r'(?:Edg|EdgA|EdgiOS|Edge)/([\d.]+)'),
|
||||
'Opera': RegExp(r'(?:OPR|Opera)/([\d.]+)'),
|
||||
'Chrome': RegExp(r'(?:Chrome|CriOS)/([\d.]+)'),
|
||||
'Firefox': RegExp(r'(?:Firefox|FxiOS)/([\d.]+)'),
|
||||
'Safari': RegExp(r'Version/([\d.]+).*Safari'),
|
||||
};
|
||||
for (final entry in rules.entries) {
|
||||
final match = entry.value.firstMatch(ua);
|
||||
if (match != null) {
|
||||
return _BrowserInfo(entry.key, _nonEmpty(match.group(1), 'unknown'));
|
||||
}
|
||||
}
|
||||
return const _BrowserInfo('unknown', 'unknown');
|
||||
}
|
||||
|
||||
String _string(Object? object, String key, String fallback) {
|
||||
if (object == null) return fallback;
|
||||
try {
|
||||
final value = getJsProperty(object, key);
|
||||
final string = value?.toString().trim() ?? '';
|
||||
return string.isEmpty ? fallback : string;
|
||||
} catch (_) {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
num _number(Object? object, String key, num fallback) {
|
||||
if (object == null) return fallback;
|
||||
try {
|
||||
final value = getJsProperty(object, key);
|
||||
if (value is num && value.isFinite) return value;
|
||||
return num.tryParse(value?.toString() ?? '') ?? fallback;
|
||||
} catch (_) {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
String _effectiveType(Object? connection) {
|
||||
final value = _string(connection, 'effectiveType', 'unknown').toLowerCase();
|
||||
if (value == 'slow-2g') return '2g';
|
||||
const allowed = {'2g', '3g', '4g', '5g'};
|
||||
return allowed.contains(value) ? value : 'unknown';
|
||||
}
|
||||
|
||||
int _downlinkKbps(Object? connection) {
|
||||
final downlink = _number(connection, 'downlink', -1);
|
||||
return downlink >= 0 ? (downlink * 1000).round() : -1;
|
||||
}
|
||||
|
||||
int _hasProperty(Object object, String key) {
|
||||
try {
|
||||
return hasJsProperty(object, key) ? 1 : 0;
|
||||
} catch (_) {
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
int _storageEnabled(html.Storage storage) {
|
||||
try {
|
||||
const key = '__analytics_sdk_storage_test__';
|
||||
storage[key] = '1';
|
||||
final ok = storage[key] == '1';
|
||||
storage.remove(key);
|
||||
return ok ? 1 : 0;
|
||||
} catch (_) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
String _canvasFingerprint() {
|
||||
try {
|
||||
final canvas = html.CanvasElement(width: 240, height: 60);
|
||||
final context = canvas.context2D;
|
||||
context
|
||||
..textBaseline = 'top'
|
||||
..font = '14px Arial'
|
||||
..fillStyle = '#f60';
|
||||
context.fillRect(125, 1, 62, 20);
|
||||
context
|
||||
..fillStyle = '#069'
|
||||
..fillText('analytics_sdk_canvas_fp', 2, 15)
|
||||
..fillStyle = 'rgba(102, 204, 0, 0.7)'
|
||||
..fillText('analytics_sdk_canvas_fp', 4, 17);
|
||||
return _hash(canvas.toDataUrl());
|
||||
} catch (_) {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
_WebglInfo _webglInfo() {
|
||||
Object? gl;
|
||||
try {
|
||||
final canvas = html.CanvasElement(width: 1, height: 1);
|
||||
gl = canvas.getContext('webgl') ?? canvas.getContext('experimental-webgl');
|
||||
} catch (_) {
|
||||
return const _WebglInfo(
|
||||
fp: '',
|
||||
vendor: 'unknown',
|
||||
renderer: 'unknown',
|
||||
status: 'error',
|
||||
extensionsHash: '',
|
||||
paramsHash: '',
|
||||
headlessReasons: ['webgl_unavailable'],
|
||||
);
|
||||
}
|
||||
|
||||
if (gl == null) {
|
||||
return const _WebglInfo(
|
||||
fp: '',
|
||||
vendor: 'unknown',
|
||||
renderer: 'unknown',
|
||||
status: 'unsupported',
|
||||
extensionsHash: '',
|
||||
paramsHash: '',
|
||||
headlessReasons: ['webgl_unavailable'],
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
final debugInfo = callJsMethod(gl, 'getExtension', [
|
||||
'WEBGL_debug_renderer_info',
|
||||
]);
|
||||
final vendor =
|
||||
_webglParameter(gl, debugInfo, 'UNMASKED_VENDOR_WEBGL') ??
|
||||
_webglParameterByConstant(gl, 0x1F00) ??
|
||||
'unknown';
|
||||
final renderer =
|
||||
_webglParameter(gl, debugInfo, 'UNMASKED_RENDERER_WEBGL') ??
|
||||
_webglParameterByConstant(gl, 0x1F01) ??
|
||||
'unknown';
|
||||
final extensions = _jsStringArray(
|
||||
callJsMethod(gl, 'getSupportedExtensions'),
|
||||
)..sort();
|
||||
final params = _webglParams(gl);
|
||||
final extensionsHash = _hash(extensions.join('|'));
|
||||
final paramsHash = _hash(params.join('|'));
|
||||
final fp = _hash(
|
||||
[vendor, renderer, extensions.join('|'), params.join('|')].join('|'),
|
||||
);
|
||||
|
||||
return _WebglInfo(
|
||||
fp: fp,
|
||||
vendor: vendor,
|
||||
renderer: renderer,
|
||||
status: 'available',
|
||||
extensionsHash: extensionsHash,
|
||||
paramsHash: paramsHash,
|
||||
headlessReasons: const [],
|
||||
);
|
||||
} catch (_) {
|
||||
return const _WebglInfo(
|
||||
fp: '',
|
||||
vendor: 'unknown',
|
||||
renderer: 'unknown',
|
||||
status: 'error',
|
||||
extensionsHash: '',
|
||||
paramsHash: '',
|
||||
headlessReasons: ['webgl_unavailable'],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String? _webglParameter(Object gl, Object? debugInfo, String key) {
|
||||
if (debugInfo == null) return null;
|
||||
try {
|
||||
final parameter = getJsProperty(debugInfo, key);
|
||||
if (parameter == null) return null;
|
||||
final value = callJsMethod(gl, 'getParameter', [parameter]);
|
||||
final string = _jsValueToString(value).trim();
|
||||
return string.isEmpty ? null : string;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
String? _webglParameterByConstant(Object gl, int parameter) {
|
||||
try {
|
||||
final value = callJsMethod(gl, 'getParameter', [parameter]);
|
||||
final string = _jsValueToString(value).trim();
|
||||
return string.isEmpty ? null : string;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
List<String> _webglParams(Object gl) {
|
||||
const parameters = <int>[
|
||||
0x0D33, // MAX_TEXTURE_SIZE
|
||||
0x0D3A, // MAX_VIEWPORT_DIMS
|
||||
0x846D, // ALIASED_POINT_SIZE_RANGE
|
||||
0x846E, // ALIASED_LINE_WIDTH_RANGE
|
||||
0x84E8, // MAX_RENDERBUFFER_SIZE
|
||||
0x8869, // MAX_VERTEX_ATTRIBS
|
||||
0x8872, // MAX_TEXTURE_IMAGE_UNITS
|
||||
0x8B4C, // MAX_VERTEX_TEXTURE_IMAGE_UNITS
|
||||
0x8B8C, // SHADING_LANGUAGE_VERSION
|
||||
];
|
||||
final values = <String>[];
|
||||
for (final parameter in parameters) {
|
||||
final value = _webglParameterByConstant(gl, parameter);
|
||||
if (value != null) values.add('$parameter=$value');
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
_AudioInfo _audioInfo() {
|
||||
Object? context;
|
||||
try {
|
||||
final constructor =
|
||||
getJsProperty(html.window, 'AudioContext') ??
|
||||
getJsProperty(html.window, 'webkitAudioContext');
|
||||
if (constructor == null) return const _AudioInfo(-1, '');
|
||||
context = constructJsObject(constructor);
|
||||
final sampleRate = _number(context, 'sampleRate', -1).round();
|
||||
final fp = sampleRate > 0 ? _hash('sampleRate:$sampleRate') : '';
|
||||
return _AudioInfo(sampleRate, fp);
|
||||
} catch (_) {
|
||||
return const _AudioInfo(-1, '');
|
||||
} finally {
|
||||
if (context != null) {
|
||||
try {
|
||||
callJsMethod(context, 'close');
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
List<String>? _fonts() {
|
||||
try {
|
||||
final canvas = html.CanvasElement(width: 200, height: 50);
|
||||
final context = canvas.context2D;
|
||||
const text = 'mmmmmmmmmmlli';
|
||||
const baseFonts = ['monospace', 'serif', 'sans-serif'];
|
||||
final baseline = <String, num>{};
|
||||
for (final base in baseFonts) {
|
||||
context.font = '72px $base';
|
||||
baseline[base] = context.measureText(text).width ?? -1;
|
||||
}
|
||||
|
||||
const candidates = [
|
||||
'Arial',
|
||||
'Arial Black',
|
||||
'Calibri',
|
||||
'Cambria',
|
||||
'Comic Sans MS',
|
||||
'Courier New',
|
||||
'Georgia',
|
||||
'Helvetica',
|
||||
'Impact',
|
||||
'Menlo',
|
||||
'Microsoft YaHei',
|
||||
'Monaco',
|
||||
'Noto Sans',
|
||||
'PingFang SC',
|
||||
'Roboto',
|
||||
'San Francisco',
|
||||
'Segoe UI',
|
||||
'Tahoma',
|
||||
'Times New Roman',
|
||||
'Trebuchet MS',
|
||||
'Verdana',
|
||||
];
|
||||
|
||||
final detected = <String>[];
|
||||
for (final font in candidates) {
|
||||
var found = false;
|
||||
for (final base in baseFonts) {
|
||||
context.font = '72px "$font", $base';
|
||||
final width = context.measureText(text).width ?? -1;
|
||||
if (width != baseline[base]) {
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (found) detected.add(font);
|
||||
}
|
||||
return detected;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
List<String> _plugins(html.Navigator navigator) {
|
||||
try {
|
||||
return _indexedPropertyList(getJsProperty(navigator, 'plugins'), 'name');
|
||||
} catch (_) {
|
||||
return const [];
|
||||
}
|
||||
}
|
||||
|
||||
List<String> _mimes(html.Navigator navigator) {
|
||||
try {
|
||||
return _indexedPropertyList(getJsProperty(navigator, 'mimeTypes'), 'type');
|
||||
} catch (_) {
|
||||
return const [];
|
||||
}
|
||||
}
|
||||
|
||||
List<String> _indexedPropertyList(Object? collection, String property) {
|
||||
if (collection == null) return const [];
|
||||
final length = _number(collection, 'length', 0).round();
|
||||
if (length <= 0) return const [];
|
||||
final values = <String>[];
|
||||
for (var i = 0; i < length; i++) {
|
||||
try {
|
||||
final item = getJsProperty(collection, i);
|
||||
if (item == null) continue;
|
||||
final value = getJsProperty(item, property);
|
||||
final string = value?.toString().trim() ?? '';
|
||||
if (string.isNotEmpty) values.add(string);
|
||||
} catch (_) {}
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
List<String> _jsStringArray(Object? collection) {
|
||||
if (collection == null) return const [];
|
||||
final length = _number(collection, 'length', 0).round();
|
||||
if (length <= 0) return const [];
|
||||
final values = <String>[];
|
||||
for (var i = 0; i < length; i++) {
|
||||
try {
|
||||
final value = getJsProperty(collection, i);
|
||||
final string = value?.toString().trim() ?? '';
|
||||
if (string.isNotEmpty) values.add(string);
|
||||
} catch (_) {}
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
String _jsValueToString(Object? value) {
|
||||
if (value == null) return '';
|
||||
if (value is String || value is num || value is bool) return '$value';
|
||||
try {
|
||||
final values = _jsStringArray(value);
|
||||
if (values.isNotEmpty) return values.join(',');
|
||||
} catch (_) {}
|
||||
return value.toString();
|
||||
}
|
||||
|
||||
List<String> _headlessReasons(html.Navigator navigator, List<String> plugins) {
|
||||
final reasons = <String>[];
|
||||
try {
|
||||
if (navigator.webdriver == true) reasons.add('webdriver');
|
||||
} catch (_) {}
|
||||
if (plugins.isEmpty) reasons.add('plugins_empty');
|
||||
try {
|
||||
final width = html.window.innerWidth ?? 0;
|
||||
final height = html.window.innerHeight ?? 0;
|
||||
if (width <= 0 || height <= 0) reasons.add('viewport_zero');
|
||||
} catch (_) {}
|
||||
return reasons;
|
||||
}
|
||||
|
||||
String _displayMode() {
|
||||
const modes = ['standalone', 'minimal-ui', 'fullscreen', 'browser'];
|
||||
for (final mode in modes) {
|
||||
try {
|
||||
if (html.window.matchMedia('(display-mode: $mode)').matches) {
|
||||
return mode;
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
String _browserKernel(String ua) {
|
||||
if (ua.contains('Edge/')) return 'edgehtml';
|
||||
if (ua.contains('Edg/')) return 'chromium';
|
||||
if (ua.contains('OPR/') || ua.contains('Opera/')) return 'chromium';
|
||||
if (ua.contains('Chrome/') || ua.contains('Chromium/')) return 'chromium';
|
||||
if (ua.contains('Firefox/')) return 'gecko';
|
||||
if (ua.contains('Trident/')) return 'trident';
|
||||
if (ua.contains('Safari/')) return 'webkit';
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
String _nonEmpty(String? value, String fallback) {
|
||||
final string = value?.trim() ?? '';
|
||||
return string.isEmpty ? fallback : string;
|
||||
}
|
||||
|
||||
List<String> _unique(List<String> values) {
|
||||
final seen = <String>{};
|
||||
return [
|
||||
for (final value in values)
|
||||
if (seen.add(value)) value,
|
||||
];
|
||||
}
|
||||
|
||||
String _hash(String value) {
|
||||
if (value.isEmpty) return '';
|
||||
return md5.convert(utf8.encode(value)).toString();
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
class SessionBehaviorManager {
|
||||
static final SessionBehaviorManager instance =
|
||||
SessionBehaviorManager._internal();
|
||||
|
||||
factory SessionBehaviorManager() => instance;
|
||||
|
||||
SessionBehaviorManager._internal() : _nowProvider = DateTime.now;
|
||||
|
||||
SessionBehaviorManager.forTest({
|
||||
DateTime Function()? nowProvider,
|
||||
}) : _nowProvider = nowProvider ?? DateTime.now;
|
||||
|
||||
static const int _scrollThresholdPx = 8;
|
||||
static const int _bottomTolerancePx = 1;
|
||||
static const _scrollMinInterval = Duration(milliseconds: 200);
|
||||
static const _touchDedupWindow = Duration(milliseconds: 50);
|
||||
|
||||
final DateTime Function() _nowProvider;
|
||||
|
||||
String _sid = '';
|
||||
DateTime? _pageStartedAt;
|
||||
int? _lastScrollTop;
|
||||
DateTime? _lastScrollProcessedAt;
|
||||
DateTime? _lastTouchStartAt;
|
||||
DateTime? _lastSwipeAt;
|
||||
DateTime? _lastLongPressAt;
|
||||
DateTime? _lastPinchAt;
|
||||
int _lastScrollDirection = 0;
|
||||
|
||||
int _scrollTotalCount = 0;
|
||||
int _scrollTotalDistancePx = 0;
|
||||
int _scrollUpCount = 0;
|
||||
int _scrollDownCount = 0;
|
||||
int _scrollDirectionChangeCount = 0;
|
||||
int _firstScrollDelayMs = -1;
|
||||
int _maxScrollDepthPercent = -1;
|
||||
int _isReachBottom = -1;
|
||||
int _contentHeight = -1;
|
||||
int _touchTotalCount = 0;
|
||||
int _swipeTotalCount = 0;
|
||||
int _longPressTotalCount = 0;
|
||||
int _pinchTotalCount = 0;
|
||||
int _firstInteractionDelayMs = -1;
|
||||
|
||||
void reset({String sid = ''}) {
|
||||
_sid = sid;
|
||||
_pageStartedAt = _nowProvider();
|
||||
_lastScrollTop = null;
|
||||
_lastScrollProcessedAt = null;
|
||||
_lastTouchStartAt = null;
|
||||
_lastSwipeAt = null;
|
||||
_lastLongPressAt = null;
|
||||
_lastPinchAt = null;
|
||||
_lastScrollDirection = 0;
|
||||
|
||||
_scrollTotalCount = 0;
|
||||
_scrollTotalDistancePx = 0;
|
||||
_scrollUpCount = 0;
|
||||
_scrollDownCount = 0;
|
||||
_scrollDirectionChangeCount = 0;
|
||||
_firstScrollDelayMs = -1;
|
||||
_maxScrollDepthPercent = -1;
|
||||
_isReachBottom = -1;
|
||||
_contentHeight = -1;
|
||||
_touchTotalCount = 0;
|
||||
_swipeTotalCount = 0;
|
||||
_longPressTotalCount = 0;
|
||||
_pinchTotalCount = 0;
|
||||
_firstInteractionDelayMs = -1;
|
||||
}
|
||||
|
||||
void recordAcceptedEvent(Map<String, dynamic> event) {
|
||||
final eventName = event['event']?.toString() ?? '';
|
||||
if (eventName.isEmpty || eventName == 'app_session_summary') return;
|
||||
|
||||
final sid = event['sid']?.toString() ?? '';
|
||||
if (sid != _sid) {
|
||||
reset(sid: sid);
|
||||
}
|
||||
|
||||
if (eventName == 'app_page_view') {
|
||||
recordPageEnter(_pageEnterTime(event) ?? _nowProvider());
|
||||
}
|
||||
}
|
||||
|
||||
void recordPageEnter(DateTime enteredAt) {
|
||||
_pageStartedAt = enteredAt;
|
||||
_lastScrollTop = null;
|
||||
_lastScrollProcessedAt = null;
|
||||
_lastScrollDirection = 0;
|
||||
}
|
||||
|
||||
void recordScroll({
|
||||
required int scrollTop,
|
||||
required int viewportHeight,
|
||||
required int contentHeight,
|
||||
DateTime? at,
|
||||
}) {
|
||||
final observedAt = at ?? _nowProvider();
|
||||
final normalizedScrollTop = _nonNegative(scrollTop);
|
||||
final normalizedViewportHeight = _nonNegative(viewportHeight);
|
||||
final normalizedContentHeight = _nonNegative(contentHeight);
|
||||
|
||||
_updateScrollExtent(
|
||||
scrollTop: normalizedScrollTop,
|
||||
viewportHeight: normalizedViewportHeight,
|
||||
contentHeight: normalizedContentHeight,
|
||||
);
|
||||
|
||||
final previousScrollTop = _lastScrollTop;
|
||||
if (previousScrollTop == null) {
|
||||
_lastScrollTop = normalizedScrollTop;
|
||||
_lastScrollProcessedAt = observedAt;
|
||||
return;
|
||||
}
|
||||
|
||||
final previousProcessedAt = _lastScrollProcessedAt;
|
||||
if (previousProcessedAt != null &&
|
||||
observedAt.difference(previousProcessedAt) < _scrollMinInterval) {
|
||||
return;
|
||||
}
|
||||
|
||||
final delta = normalizedScrollTop - previousScrollTop;
|
||||
final distance = delta.abs();
|
||||
if (distance < _scrollThresholdPx) {
|
||||
_lastScrollTop = normalizedScrollTop;
|
||||
_lastScrollProcessedAt = observedAt;
|
||||
return;
|
||||
}
|
||||
|
||||
final direction = delta > 0 ? 1 : -1;
|
||||
_scrollTotalCount += 1;
|
||||
_scrollTotalDistancePx += distance;
|
||||
if (direction > 0) {
|
||||
_scrollDownCount += 1;
|
||||
} else {
|
||||
_scrollUpCount += 1;
|
||||
}
|
||||
if (_lastScrollDirection != 0 && _lastScrollDirection != direction) {
|
||||
_scrollDirectionChangeCount += 1;
|
||||
}
|
||||
_lastScrollDirection = direction;
|
||||
_lastScrollTop = normalizedScrollTop;
|
||||
_lastScrollProcessedAt = observedAt;
|
||||
_recordFirstScrollDelay(observedAt);
|
||||
}
|
||||
|
||||
void recordTouchStart({DateTime? at}) {
|
||||
final observedAt = at ?? _nowProvider();
|
||||
if (_isDuplicate(_lastTouchStartAt, observedAt)) return;
|
||||
_lastTouchStartAt = observedAt;
|
||||
_touchTotalCount += 1;
|
||||
_recordFirstInteractionDelay(observedAt);
|
||||
}
|
||||
|
||||
void recordSwipe({DateTime? at}) {
|
||||
final observedAt = at ?? _nowProvider();
|
||||
if (_isDuplicate(_lastSwipeAt, observedAt)) return;
|
||||
_lastSwipeAt = observedAt;
|
||||
_swipeTotalCount += 1;
|
||||
_recordFirstInteractionDelay(observedAt);
|
||||
}
|
||||
|
||||
void recordLongPress({DateTime? at}) {
|
||||
final observedAt = at ?? _nowProvider();
|
||||
if (_isDuplicate(_lastLongPressAt, observedAt)) return;
|
||||
_lastLongPressAt = observedAt;
|
||||
_longPressTotalCount += 1;
|
||||
_recordFirstInteractionDelay(observedAt);
|
||||
}
|
||||
|
||||
void recordPinch({DateTime? at}) {
|
||||
final observedAt = at ?? _nowProvider();
|
||||
if (_isDuplicate(_lastPinchAt, observedAt)) return;
|
||||
_lastPinchAt = observedAt;
|
||||
_pinchTotalCount += 1;
|
||||
_recordFirstInteractionDelay(observedAt);
|
||||
}
|
||||
|
||||
SessionBehaviorSnapshot snapshot() {
|
||||
return SessionBehaviorSnapshot(
|
||||
scrollTotalCount: _scrollTotalCount,
|
||||
scrollTotalDistancePx: _scrollTotalDistancePx,
|
||||
scrollUpCount: _scrollUpCount,
|
||||
scrollDownCount: _scrollDownCount,
|
||||
scrollDirectionChangeCount: _scrollDirectionChangeCount,
|
||||
firstScrollDelayMs: _firstScrollDelayMs,
|
||||
maxScrollDepthPercent: _maxScrollDepthPercent,
|
||||
isReachBottom: _isReachBottom,
|
||||
contentHeight: _contentHeight,
|
||||
touchTotalCount: _touchTotalCount,
|
||||
swipeTotalCount: _swipeTotalCount,
|
||||
longPressTotalCount: _longPressTotalCount,
|
||||
pinchTotalCount: _pinchTotalCount,
|
||||
firstInteractionDelayMs: _firstInteractionDelayMs,
|
||||
);
|
||||
}
|
||||
|
||||
void _updateScrollExtent({
|
||||
required int scrollTop,
|
||||
required int viewportHeight,
|
||||
required int contentHeight,
|
||||
}) {
|
||||
if (contentHeight <= 0 || viewportHeight <= 0) return;
|
||||
_contentHeight = contentHeight;
|
||||
final depth = ((scrollTop + viewportHeight) / contentHeight * 100)
|
||||
.round()
|
||||
.clamp(0, 100);
|
||||
if (_maxScrollDepthPercent < 0 || depth > _maxScrollDepthPercent) {
|
||||
_maxScrollDepthPercent = depth;
|
||||
}
|
||||
if (scrollTop + viewportHeight >= contentHeight - _bottomTolerancePx) {
|
||||
_isReachBottom = 1;
|
||||
} else if (_isReachBottom < 0) {
|
||||
_isReachBottom = 0;
|
||||
}
|
||||
}
|
||||
|
||||
void _recordFirstScrollDelay(DateTime at) {
|
||||
if (_firstScrollDelayMs >= 0) return;
|
||||
_firstScrollDelayMs = _delaySincePageStart(at);
|
||||
}
|
||||
|
||||
void _recordFirstInteractionDelay(DateTime at) {
|
||||
if (_firstInteractionDelayMs >= 0) return;
|
||||
_firstInteractionDelayMs = _delaySincePageStart(at);
|
||||
}
|
||||
|
||||
int _delaySincePageStart(DateTime at) {
|
||||
final pageStartedAt = _pageStartedAt;
|
||||
if (pageStartedAt == null) return -1;
|
||||
final delay = at.difference(pageStartedAt).inMilliseconds;
|
||||
return delay < 0 ? 0 : delay;
|
||||
}
|
||||
|
||||
DateTime? _pageEnterTime(Map<String, dynamic> event) {
|
||||
final payload = event['payload'];
|
||||
if (payload is! Map) return null;
|
||||
final value = payload['page_enter_ts'];
|
||||
if (value is int) {
|
||||
return DateTime.fromMillisecondsSinceEpoch(value * 1000);
|
||||
}
|
||||
if (value is num) {
|
||||
return DateTime.fromMillisecondsSinceEpoch(value.round() * 1000);
|
||||
}
|
||||
final parsed = int.tryParse(value?.toString() ?? '');
|
||||
if (parsed == null) return null;
|
||||
return DateTime.fromMillisecondsSinceEpoch(parsed * 1000);
|
||||
}
|
||||
|
||||
bool _isDuplicate(DateTime? previous, DateTime current) {
|
||||
if (previous == null) return false;
|
||||
final diffMs = current.difference(previous).inMilliseconds.abs();
|
||||
return diffMs < _touchDedupWindow.inMilliseconds;
|
||||
}
|
||||
|
||||
int _nonNegative(int value) => value < 0 ? 0 : value;
|
||||
}
|
||||
|
||||
class SessionBehaviorSnapshot {
|
||||
final int scrollTotalCount;
|
||||
final int scrollTotalDistancePx;
|
||||
final int scrollUpCount;
|
||||
final int scrollDownCount;
|
||||
final int scrollDirectionChangeCount;
|
||||
final int firstScrollDelayMs;
|
||||
final int maxScrollDepthPercent;
|
||||
final int isReachBottom;
|
||||
final int contentHeight;
|
||||
final int touchTotalCount;
|
||||
final int swipeTotalCount;
|
||||
final int longPressTotalCount;
|
||||
final int pinchTotalCount;
|
||||
final int firstInteractionDelayMs;
|
||||
|
||||
const SessionBehaviorSnapshot({
|
||||
required this.scrollTotalCount,
|
||||
required this.scrollTotalDistancePx,
|
||||
required this.scrollUpCount,
|
||||
required this.scrollDownCount,
|
||||
required this.scrollDirectionChangeCount,
|
||||
required this.firstScrollDelayMs,
|
||||
required this.maxScrollDepthPercent,
|
||||
required this.isReachBottom,
|
||||
required this.contentHeight,
|
||||
required this.touchTotalCount,
|
||||
required this.swipeTotalCount,
|
||||
required this.longPressTotalCount,
|
||||
required this.pinchTotalCount,
|
||||
required this.firstInteractionDelayMs,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export 'session_behavior_monitor_stub.dart'
|
||||
if (dart.library.html) 'session_behavior_monitor_web.dart';
|
||||
@@ -0,0 +1,10 @@
|
||||
class SessionBehaviorMonitor {
|
||||
static final SessionBehaviorMonitor instance =
|
||||
SessionBehaviorMonitor._internal();
|
||||
|
||||
SessionBehaviorMonitor._internal();
|
||||
|
||||
void start() {}
|
||||
|
||||
void dispose() {}
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
// ignore: avoid_web_libraries_in_flutter
|
||||
import 'dart:async';
|
||||
import 'dart:html' as html;
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:analytics_sdk/manager/session_behavior_manager.dart';
|
||||
|
||||
class SessionBehaviorMonitor {
|
||||
static final SessionBehaviorMonitor instance =
|
||||
SessionBehaviorMonitor._internal();
|
||||
|
||||
SessionBehaviorMonitor._internal();
|
||||
|
||||
static const int _swipeThresholdPx = 50;
|
||||
static const int _pinchThresholdPx = 20;
|
||||
static const int _longPressMoveTolerancePx = 10;
|
||||
static const _longPressDuration = Duration(milliseconds: 500);
|
||||
|
||||
final List<StreamSubscription<dynamic>> _subscriptions = [];
|
||||
final Map<int, _TouchStart> _touchStarts = <int, _TouchStart>{};
|
||||
final Map<int, math.Point<num>> _activeTouches = <int, math.Point<num>>{};
|
||||
|
||||
double? _pinchStartDistance;
|
||||
bool _pinchRecorded = false;
|
||||
bool _started = false;
|
||||
|
||||
void start() {
|
||||
if (_started) return;
|
||||
_started = true;
|
||||
_subscriptions
|
||||
..add(html.window.onScroll.listen(_handleScroll))
|
||||
..add(html.document.onTouchStart.listen(_handleTouchStart))
|
||||
..add(html.document.onTouchMove.listen(_handleTouchMove))
|
||||
..add(html.document.onTouchEnd.listen(_handleTouchEnd))
|
||||
..add(html.document.onTouchCancel.listen(_handleTouchCancel));
|
||||
_handleScroll(null);
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
for (final subscription in _subscriptions) {
|
||||
subscription.cancel();
|
||||
}
|
||||
_subscriptions.clear();
|
||||
_touchStarts.clear();
|
||||
_activeTouches.clear();
|
||||
_pinchStartDistance = null;
|
||||
_pinchRecorded = false;
|
||||
_started = false;
|
||||
}
|
||||
|
||||
void _handleScroll(Object? _) {
|
||||
try {
|
||||
final element = html.document.scrollingElement ??
|
||||
html.document.documentElement ??
|
||||
html.document.body;
|
||||
final viewportHeight = html.window.innerHeight ?? element?.clientHeight;
|
||||
final contentHeight = element?.scrollHeight;
|
||||
final scrollTop = element?.scrollTop ?? html.window.pageYOffset.round();
|
||||
if (viewportHeight == null || contentHeight == null) return;
|
||||
SessionBehaviorManager.instance.recordScroll(
|
||||
scrollTop: scrollTop,
|
||||
viewportHeight: viewportHeight,
|
||||
contentHeight: contentHeight,
|
||||
);
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
void _handleTouchStart(html.TouchEvent event) {
|
||||
try {
|
||||
final touches = event.touches;
|
||||
final changedTouches = event.changedTouches;
|
||||
if (touches == null || changedTouches == null) return;
|
||||
|
||||
final now = DateTime.now();
|
||||
if (touches.length == 1) {
|
||||
SessionBehaviorManager.instance.recordTouchStart(at: now);
|
||||
}
|
||||
|
||||
for (final touch in changedTouches) {
|
||||
final identifier = touch.identifier ?? 0;
|
||||
final point = touch.client;
|
||||
_touchStarts[identifier] = _TouchStart(point: point, startedAt: now);
|
||||
_activeTouches[identifier] = point;
|
||||
}
|
||||
_updatePinchBaseline();
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
void _handleTouchMove(html.TouchEvent event) {
|
||||
try {
|
||||
final touches = event.touches;
|
||||
if (touches == null) return;
|
||||
for (final touch in touches) {
|
||||
_activeTouches[touch.identifier ?? 0] = touch.client;
|
||||
}
|
||||
if (_activeTouches.length < 2) return;
|
||||
final distance = _firstTwoTouchDistance();
|
||||
if (distance == null) return;
|
||||
_pinchStartDistance ??= distance;
|
||||
if (!_pinchRecorded &&
|
||||
(distance - _pinchStartDistance!).abs() >= _pinchThresholdPx) {
|
||||
_pinchRecorded = true;
|
||||
SessionBehaviorManager.instance.recordPinch();
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
void _handleTouchEnd(html.TouchEvent event) {
|
||||
try {
|
||||
final changedTouches = event.changedTouches;
|
||||
if (changedTouches == null) return;
|
||||
final now = DateTime.now();
|
||||
final wasSingleTouch = _activeTouches.length == 1;
|
||||
for (final touch in changedTouches) {
|
||||
final identifier = touch.identifier ?? 0;
|
||||
final start = _touchStarts[identifier];
|
||||
if (start != null && wasSingleTouch) {
|
||||
_recordFinishedSingleTouch(
|
||||
start: start,
|
||||
endPoint: touch.client,
|
||||
endedAt: now,
|
||||
);
|
||||
}
|
||||
_touchStarts.remove(identifier);
|
||||
_activeTouches.remove(identifier);
|
||||
}
|
||||
_updatePinchBaseline();
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
void _handleTouchCancel(html.TouchEvent event) {
|
||||
try {
|
||||
final changedTouches = event.changedTouches;
|
||||
if (changedTouches == null) return;
|
||||
for (final touch in changedTouches) {
|
||||
final identifier = touch.identifier ?? 0;
|
||||
_touchStarts.remove(identifier);
|
||||
_activeTouches.remove(identifier);
|
||||
}
|
||||
_updatePinchBaseline();
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
void _recordFinishedSingleTouch({
|
||||
required _TouchStart start,
|
||||
required math.Point<num> endPoint,
|
||||
required DateTime endedAt,
|
||||
}) {
|
||||
final distance = _distance(start.point, endPoint);
|
||||
if (distance >= _swipeThresholdPx) {
|
||||
SessionBehaviorManager.instance.recordSwipe(at: endedAt);
|
||||
return;
|
||||
}
|
||||
if (endedAt.difference(start.startedAt) >= _longPressDuration &&
|
||||
distance <= _longPressMoveTolerancePx) {
|
||||
SessionBehaviorManager.instance.recordLongPress(at: endedAt);
|
||||
}
|
||||
}
|
||||
|
||||
void _updatePinchBaseline() {
|
||||
if (_activeTouches.length < 2) {
|
||||
_pinchStartDistance = null;
|
||||
_pinchRecorded = false;
|
||||
return;
|
||||
}
|
||||
_pinchStartDistance = _firstTwoTouchDistance();
|
||||
_pinchRecorded = false;
|
||||
}
|
||||
|
||||
double? _firstTwoTouchDistance() {
|
||||
if (_activeTouches.length < 2) return null;
|
||||
final iterator = _activeTouches.values.iterator;
|
||||
iterator.moveNext();
|
||||
final first = iterator.current;
|
||||
iterator.moveNext();
|
||||
final second = iterator.current;
|
||||
return _distance(first, second);
|
||||
}
|
||||
|
||||
double _distance(math.Point<num> a, math.Point<num> b) {
|
||||
final dx = a.x - b.x;
|
||||
final dy = a.y - b.y;
|
||||
return math.sqrt(dx * dx + dy * dy);
|
||||
}
|
||||
}
|
||||
|
||||
class _TouchStart {
|
||||
final math.Point<num> point;
|
||||
final DateTime startedAt;
|
||||
|
||||
const _TouchStart({
|
||||
required this.point,
|
||||
required this.startedAt,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:analytics_sdk/utils/logger.dart';
|
||||
import 'package:analytics_sdk/utils/uuid_util.dart';
|
||||
|
||||
/// 会话管理器:管理会话ID(sid)的生成和更新
|
||||
///
|
||||
/// 会话定义:用户在一次连续、自然的产品使用过程中产生的一组行为集合
|
||||
///
|
||||
/// **sid 说明**:sid 由客户端 SDK 自动生成,每个事件均会携带。
|
||||
/// 服务端上报事件时,需通过 [AnalyticsSdk.getParams] 获取当前 sid 并一同回传,
|
||||
/// 以便服务端事件与客户端事件归属同一会话。
|
||||
///
|
||||
/// 会话中断阈值:30 分钟无任何有效行为,生成新会话ID
|
||||
///
|
||||
/// APP产品定义方案,任意满足一条就重新生成会话ID:
|
||||
/// 1. App 冷启动(进程首次启动)
|
||||
/// 2. App 从后台切换前台,且后台停留 > 30 分钟
|
||||
/// 3. 上一次行为距离当前 > 30 分钟
|
||||
class SessionManager {
|
||||
static final SessionManager instance = SessionManager._internal();
|
||||
|
||||
factory SessionManager() => instance;
|
||||
|
||||
SessionManager._internal();
|
||||
|
||||
/// 当前会话ID
|
||||
String? _currentSessionId;
|
||||
|
||||
/// 会话创建时间
|
||||
DateTime? _sessionStartTime;
|
||||
|
||||
/// 最后一次行为时间
|
||||
DateTime? _lastActivityTime;
|
||||
|
||||
/// 应用进入后台的时间
|
||||
DateTime? _backgroundTime;
|
||||
|
||||
/// 当前正在通知结束的旧 session 逻辑结束时间。
|
||||
DateTime? _notifyingSessionEndTime;
|
||||
|
||||
/// 会话中断阈值(30分钟)
|
||||
static const Duration sessionTimeout = Duration(minutes: 30);
|
||||
|
||||
static const Set<String> _effectiveLifecycleKeys = {
|
||||
'cold_start',
|
||||
'web_load',
|
||||
'warm_start',
|
||||
'foreground',
|
||||
};
|
||||
|
||||
static const Set<String> _nonBusinessActivityEvents = {
|
||||
'sdk_init',
|
||||
'network_state_change',
|
||||
'app_session_summary',
|
||||
'navigation',
|
||||
'page_lifecycle',
|
||||
};
|
||||
|
||||
/// 是否已初始化(用于判断冷启动)
|
||||
bool _isInitialized = false;
|
||||
|
||||
/// 新 session 创建时的回调(由外部注入,避免直接依赖 AdImpressionManager)
|
||||
/// 通常由 AnalyticsSdk.init() 注入:`() => AdImpressionManager.instance.clear()`
|
||||
void Function()? onNewSession;
|
||||
|
||||
/// 旧 session 即将结束时的回调。
|
||||
///
|
||||
/// 在新 sid 生成前调用,使调用方仍能用旧 sid 补发 app_session_summary。
|
||||
void Function(String sessionEndReason)? onSessionEnding;
|
||||
|
||||
/// 获取当前会话ID
|
||||
/// 如果当前没有会话或会话已过期,会自动创建新会话
|
||||
String getSessionId() {
|
||||
final now = DateTime.now();
|
||||
|
||||
// 检查是否需要生成新会话
|
||||
// 注意:这里只检查和创建会话,不更新 _lastActivityTime
|
||||
// 调用方应通过 recordActivity() 显式记录行为时间,避免非行为调用(如 debug 面板)误延长会话
|
||||
if (_shouldCreateNewSession(now)) {
|
||||
_createNewSession(now);
|
||||
}
|
||||
|
||||
// 安全检查:确保会话ID不为null(理论上不会发生,但作为保护措施)
|
||||
if (_currentSessionId == null) {
|
||||
Logger.analyticsSdk('警告:会话ID为null,强制创建新会话', level: LogLevel.warn);
|
||||
_createNewSession(now);
|
||||
}
|
||||
|
||||
return _currentSessionId!;
|
||||
}
|
||||
|
||||
/// 检查是否需要创建新会话
|
||||
bool _shouldCreateNewSession(DateTime now) {
|
||||
// 1. 如果还没有会话ID,需要创建
|
||||
if (_currentSessionId == null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 2. 如果还未初始化,说明是冷启动,需要创建新会话
|
||||
if (!_isInitialized) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 3. 如果从后台恢复,且后台停留时间 > 30 分钟
|
||||
// 注意:这里只在超时时清空 _backgroundTime(并返回 true 触发新会话)。
|
||||
// 非超时分支不清空,保留给 onAppForeground() 统一处理,
|
||||
// 避免后台期间有代码调用 getSessionId()(如推送通知触发 track())时
|
||||
// 提前清空导致 onAppForeground() 无法正确判断后台时长。
|
||||
if (_backgroundTime != null) {
|
||||
final backgroundDuration = now.difference(_backgroundTime!);
|
||||
if (backgroundDuration > sessionTimeout) {
|
||||
Logger.analyticsSdk(
|
||||
'后台停留时间 ${backgroundDuration.inMinutes} 分钟,超过阈值,创建新会话');
|
||||
_backgroundTime = null;
|
||||
return true;
|
||||
}
|
||||
// 未超时:不清空 _backgroundTime,由 onAppForeground() 负责清空
|
||||
}
|
||||
|
||||
// 4. 如果上一次行为距离当前 > 30 分钟
|
||||
if (_lastActivityTime != null) {
|
||||
final timeSinceLastActivity = now.difference(_lastActivityTime!);
|
||||
if (timeSinceLastActivity > sessionTimeout) {
|
||||
Logger.analyticsSdk(
|
||||
'距离上次行为 ${timeSinceLastActivity.inMinutes} 分钟,超过阈值,创建新会话');
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// 创建新会话
|
||||
void _createNewSession(DateTime now) {
|
||||
try {
|
||||
_notifySessionEndingIfNeeded('idle');
|
||||
_currentSessionId = generateUuidV4().replaceAll("-", "");
|
||||
_sessionStartTime = now;
|
||||
_lastActivityTime = now;
|
||||
_isInitialized = true;
|
||||
// 新 session 开始时通知外部清理广告去重状态
|
||||
onNewSession?.call();
|
||||
Logger.analyticsSdk('创建新会话ID: $_currentSessionId');
|
||||
} catch (e) {
|
||||
// UUID 生成失败时使用时间戳作为备选方案
|
||||
final timestamp = now.millisecondsSinceEpoch;
|
||||
_currentSessionId = 'session_${timestamp}_${now.microsecondsSinceEpoch}';
|
||||
_sessionStartTime = now;
|
||||
_lastActivityTime = now;
|
||||
_isInitialized = true;
|
||||
onNewSession?.call();
|
||||
Logger.analyticsSdk('UUID 生成失败,使用时间戳创建会话ID: $_currentSessionId');
|
||||
}
|
||||
}
|
||||
|
||||
/// 初始化会话管理器
|
||||
/// 在 SDK 初始化时调用,标记为冷启动
|
||||
void initialize() {
|
||||
if (!_isInitialized) {
|
||||
final now = DateTime.now();
|
||||
_createNewSession(now);
|
||||
Logger.analyticsSdk('会话管理器初始化(冷启动),创建新会话');
|
||||
}
|
||||
}
|
||||
|
||||
/// 应用进入后台
|
||||
/// 在应用生命周期变为后台时调用
|
||||
void onAppBackground() {
|
||||
_backgroundTime = DateTime.now();
|
||||
Logger.analyticsSdk('应用进入后台,记录时间: $_backgroundTime');
|
||||
}
|
||||
|
||||
/// 应用从后台恢复
|
||||
/// 在应用生命周期变为前台时调用
|
||||
void onAppForeground() {
|
||||
final now = DateTime.now();
|
||||
|
||||
// 如果从后台恢复,检查是否需要创建新会话
|
||||
if (_backgroundTime != null) {
|
||||
final backgroundDuration = now.difference(_backgroundTime!);
|
||||
if (backgroundDuration > sessionTimeout) {
|
||||
Logger.analyticsSdk(
|
||||
'从后台恢复,后台停留 ${backgroundDuration.inMinutes} 分钟,超过阈值,创建新会话');
|
||||
_createNewSession(now);
|
||||
} else {
|
||||
Logger.analyticsSdk(
|
||||
'从后台恢复,后台停留 ${backgroundDuration.inMinutes} 分钟,继续使用当前会话');
|
||||
// 更新最后行为时间,表示用户重新活跃
|
||||
_lastActivityTime = now;
|
||||
}
|
||||
_backgroundTime = null; // 清空后台时间
|
||||
} else {
|
||||
// 如果没有后台时间记录,直接更新最后行为时间
|
||||
_lastActivityTime = now;
|
||||
}
|
||||
}
|
||||
|
||||
/// 记录用户行为
|
||||
/// 每次生成事件时调用,用于更新最后行为时间
|
||||
void recordActivity() {
|
||||
_lastActivityTime = DateTime.now();
|
||||
}
|
||||
|
||||
/// 仅在成功进入 SDK 队列且属于有效行为时更新最后行为时间。
|
||||
void recordActivityForEvent(Map<String, dynamic> event, {DateTime? at}) {
|
||||
if (!_isEffectiveActivityEvent(event)) return;
|
||||
_lastActivityTime = at ?? DateTime.now();
|
||||
}
|
||||
|
||||
/// 获取当前会话ID(不触发新会话创建)
|
||||
String? get currentSessionId => _currentSessionId;
|
||||
|
||||
/// 获取会话开始时间
|
||||
DateTime? get sessionStartTime => _sessionStartTime;
|
||||
|
||||
/// 获取最后行为时间
|
||||
DateTime? get lastActivityTime => _lastActivityTime;
|
||||
|
||||
/// 当前 session 结束回调内可读取的逻辑结束时间。
|
||||
DateTime? get sessionEndTimeForNotification => _notifyingSessionEndTime;
|
||||
|
||||
/// 仅供测试使用:获取后台时间(验证 _shouldCreateNewSession 不提前清空)
|
||||
@visibleForTesting
|
||||
DateTime? get backgroundTimeForTest => _backgroundTime;
|
||||
|
||||
@visibleForTesting
|
||||
void setBackgroundTimeForTest(DateTime? value) {
|
||||
_backgroundTime = value;
|
||||
}
|
||||
|
||||
@visibleForTesting
|
||||
DateTime? get sessionEndTimeForNotificationForTest =>
|
||||
_notifyingSessionEndTime;
|
||||
|
||||
@visibleForTesting
|
||||
void setLastActivityTimeForTest(DateTime? value) {
|
||||
_lastActivityTime = value;
|
||||
}
|
||||
|
||||
@visibleForTesting
|
||||
void setSessionStartTimeForTest(DateTime? value) {
|
||||
_sessionStartTime = value;
|
||||
}
|
||||
|
||||
/// QA 调试用:把后台停留时间虚拟为指定时长,便于手工触发 idle summary。
|
||||
///
|
||||
/// Release 包中不生效,避免接入方误用影响真实 session 规则。
|
||||
bool debugSetBackgroundElapsedForQa(Duration elapsed) {
|
||||
if (kReleaseMode) return false;
|
||||
if (elapsed <= Duration.zero) return false;
|
||||
final now = DateTime.now();
|
||||
_backgroundTime = now.subtract(elapsed);
|
||||
_lastActivityTime = now.subtract(elapsed);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// 手动重置会话(用于测试或特殊场景)
|
||||
void reset() {
|
||||
_currentSessionId = null;
|
||||
_sessionStartTime = null;
|
||||
_lastActivityTime = null;
|
||||
_backgroundTime = null;
|
||||
_notifyingSessionEndTime = null;
|
||||
_isInitialized = false;
|
||||
Logger.analyticsSdk('会话已重置');
|
||||
}
|
||||
|
||||
void _notifySessionEndingIfNeeded(String reason) {
|
||||
if (_currentSessionId == null || !_isInitialized) return;
|
||||
final previousEndTime = _notifyingSessionEndTime;
|
||||
_notifyingSessionEndTime = _logicalSessionEndTime(
|
||||
reason: reason,
|
||||
now: DateTime.now(),
|
||||
);
|
||||
try {
|
||||
onSessionEnding?.call(reason);
|
||||
} catch (e) {
|
||||
Logger.analyticsSdk('旧 session 结束回调执行失败: $e', level: LogLevel.warn);
|
||||
} finally {
|
||||
_notifyingSessionEndTime = previousEndTime;
|
||||
}
|
||||
}
|
||||
|
||||
DateTime _logicalSessionEndTime({
|
||||
required String reason,
|
||||
required DateTime now,
|
||||
}) {
|
||||
if (reason != 'idle') return now;
|
||||
final sessionStart = _sessionStartTime ?? now;
|
||||
final rawLastActivity = _lastActivityTime ?? sessionStart;
|
||||
final lastActivity =
|
||||
rawLastActivity.isBefore(sessionStart) ? sessionStart : rawLastActivity;
|
||||
final logicalEnd = lastActivity.add(sessionTimeout);
|
||||
return logicalEnd.isAfter(now) ? now : logicalEnd;
|
||||
}
|
||||
|
||||
bool _isEffectiveActivityEvent(Map<String, dynamic> event) {
|
||||
final eventName = event['event']?.toString() ?? '';
|
||||
if (eventName.isEmpty) return false;
|
||||
if (eventName == 'app_lifecycle') {
|
||||
final key = _lifecycleEventKey(event);
|
||||
return _effectiveLifecycleKeys.contains(key);
|
||||
}
|
||||
if (_nonBusinessActivityEvents.contains(eventName)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
String _lifecycleEventKey(Map<String, dynamic> event) {
|
||||
final payload = event['payload'];
|
||||
if (payload is Map) {
|
||||
final value = payload['lifecycle_event_key']?.toString().trim() ?? '';
|
||||
if (value.isNotEmpty) return value;
|
||||
}
|
||||
return event['lifecycle_event_key']?.toString().trim() ?? '';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:crypto/crypto.dart';
|
||||
|
||||
/// Session 内自动汇总指标。
|
||||
///
|
||||
/// 只记录成功进入 SDK 队列的事件。这里不采集滚动轨迹、键盘输入内容、
|
||||
/// 长按、双指缩放等高风险/不稳定指标,避免为了汇总事件扩大监听面。
|
||||
class SessionMetricsManager {
|
||||
static final SessionMetricsManager instance =
|
||||
SessionMetricsManager._internal();
|
||||
|
||||
factory SessionMetricsManager() => instance;
|
||||
|
||||
SessionMetricsManager._internal();
|
||||
|
||||
static const int _maxBehaviorSequence = 200;
|
||||
static const int _maxPageStayRecords = 100;
|
||||
|
||||
static const Set<String> _nonBusinessEvents = {
|
||||
'sdk_init',
|
||||
'app_lifecycle',
|
||||
'network_state_change',
|
||||
'app_session_summary',
|
||||
'app_page_view',
|
||||
'page_click',
|
||||
'navigation',
|
||||
'page_lifecycle',
|
||||
};
|
||||
|
||||
String _sid = '';
|
||||
int _eventCountTotal = 0;
|
||||
int _businessEventCount = 0;
|
||||
int _foregroundCount = 0;
|
||||
int _backgroundCountInSid = 0;
|
||||
int _pageViewCount = 0;
|
||||
int _pageClickCount = 0;
|
||||
int _networkChangeCount = 0;
|
||||
String _lastEventName = '';
|
||||
final Set<String> _pageKeys = <String>{};
|
||||
final Set<String> _pagesWithInteraction = <String>{};
|
||||
final List<String> _behaviorSequence = <String>[];
|
||||
final List<_PageStayRecord> _activePageStays = <_PageStayRecord>[];
|
||||
final List<_PageStayRecord> _completedPageStays = <_PageStayRecord>[];
|
||||
|
||||
void recordAcceptedEvent(Map<String, dynamic> event) {
|
||||
final eventName = event['event']?.toString() ?? '';
|
||||
if (eventName.isEmpty || eventName == 'app_session_summary') return;
|
||||
|
||||
final sid = event['sid']?.toString() ?? '';
|
||||
if (sid != _sid) {
|
||||
reset(sid: sid);
|
||||
}
|
||||
|
||||
_eventCountTotal += 1;
|
||||
_lastEventName = eventName;
|
||||
_recordBehaviorSequence(eventName, _payloadPageKey(event));
|
||||
|
||||
if (!_nonBusinessEvents.contains(eventName)) {
|
||||
_businessEventCount += 1;
|
||||
}
|
||||
|
||||
if (eventName == 'app_page_view') {
|
||||
_pageViewCount += 1;
|
||||
_recordPageKey(event);
|
||||
} else if (eventName == 'page_click') {
|
||||
_pageClickCount += 1;
|
||||
_recordPageKey(event);
|
||||
final pageKey = _payloadPageKey(event);
|
||||
if (pageKey.isNotEmpty) _pagesWithInteraction.add(pageKey);
|
||||
} else if (eventName == 'app_lifecycle') {
|
||||
final key = _payloadString(event, 'lifecycle_event_key',
|
||||
fallback: event['lifecycle_event_key']);
|
||||
if (key == 'foreground') {
|
||||
_foregroundCount += 1;
|
||||
} else if (key == 'background') {
|
||||
_backgroundCountInSid += 1;
|
||||
}
|
||||
} else if (eventName == 'network_state_change') {
|
||||
_networkChangeCount += 1;
|
||||
}
|
||||
}
|
||||
|
||||
void recordPageEnter({
|
||||
required String pageKey,
|
||||
required String pageName,
|
||||
required DateTime enterTime,
|
||||
}) {
|
||||
final normalizedPageKey = pageKey.trim();
|
||||
if (normalizedPageKey.isEmpty) return;
|
||||
_pageKeys.add(normalizedPageKey);
|
||||
_closeActivePageStays(exitTime: enterTime);
|
||||
_activePageStays.add(_PageStayRecord(
|
||||
pageKey: normalizedPageKey,
|
||||
pageName: pageName,
|
||||
enterTime: enterTime,
|
||||
));
|
||||
}
|
||||
|
||||
void recordPageExit({
|
||||
required String pageKey,
|
||||
required String pageName,
|
||||
required DateTime enterTime,
|
||||
required DateTime exitTime,
|
||||
}) {
|
||||
final normalizedPageKey = pageKey.trim();
|
||||
if (normalizedPageKey.isEmpty) return;
|
||||
_pageKeys.add(normalizedPageKey);
|
||||
|
||||
final index = _findActivePageStayIndex(normalizedPageKey, enterTime);
|
||||
if (index < 0) return;
|
||||
final record = _activePageStays.removeAt(index);
|
||||
_completedPageStays.add(record.close(
|
||||
exitTime: exitTime,
|
||||
hasInteraction: _pagesWithInteraction.contains(normalizedPageKey),
|
||||
));
|
||||
_trimCompletedPageStays();
|
||||
}
|
||||
|
||||
SessionMetricsSnapshot snapshot({DateTime? endedAt}) {
|
||||
final now = endedAt ?? DateTime.now();
|
||||
return SessionMetricsSnapshot(
|
||||
eventCountTotal: _eventCountTotal,
|
||||
businessEventCount: _businessEventCount,
|
||||
foregroundCount: _foregroundCount,
|
||||
backgroundCountInSid: _backgroundCountInSid,
|
||||
pageCount: _pageKeys.length,
|
||||
pageViewCount: _pageViewCount,
|
||||
pageClickCount: _pageClickCount,
|
||||
networkChangeCount: _networkChangeCount,
|
||||
sessionSid: _sid,
|
||||
lastEventName: _lastEventName,
|
||||
pageStayList: _pageStayList(now),
|
||||
behaviorSequenceHash: _behaviorSequenceHash(),
|
||||
);
|
||||
}
|
||||
|
||||
void reset({String sid = ''}) {
|
||||
_sid = sid;
|
||||
_eventCountTotal = 0;
|
||||
_businessEventCount = 0;
|
||||
_foregroundCount = 0;
|
||||
_backgroundCountInSid = 0;
|
||||
_pageViewCount = 0;
|
||||
_pageClickCount = 0;
|
||||
_networkChangeCount = 0;
|
||||
_lastEventName = '';
|
||||
_pageKeys.clear();
|
||||
_pagesWithInteraction.clear();
|
||||
_behaviorSequence.clear();
|
||||
_activePageStays.clear();
|
||||
_completedPageStays.clear();
|
||||
}
|
||||
|
||||
void _recordPageKey(Map<String, dynamic> event) {
|
||||
final pageKey = _payloadPageKey(event);
|
||||
if (pageKey.isNotEmpty) _pageKeys.add(pageKey);
|
||||
}
|
||||
|
||||
String _payloadPageKey(Map<String, dynamic> event) {
|
||||
return _payloadString(event, 'page_key');
|
||||
}
|
||||
|
||||
String _payloadString(
|
||||
Map<String, dynamic> event,
|
||||
String key, {
|
||||
Object? fallback,
|
||||
}) {
|
||||
final payload = event['payload'];
|
||||
if (payload is Map) {
|
||||
final value = payload[key]?.toString().trim() ?? '';
|
||||
if (value.isNotEmpty) return value;
|
||||
}
|
||||
return fallback?.toString().trim() ?? '';
|
||||
}
|
||||
|
||||
void _recordBehaviorSequence(String eventName, String pageKey) {
|
||||
final token = pageKey.isEmpty ? eventName : '$eventName:$pageKey';
|
||||
_behaviorSequence.add(token);
|
||||
if (_behaviorSequence.length > _maxBehaviorSequence) {
|
||||
_behaviorSequence.removeAt(0);
|
||||
}
|
||||
}
|
||||
|
||||
String _behaviorSequenceHash() {
|
||||
if (_behaviorSequence.isEmpty) return '';
|
||||
return sha256.convert(utf8.encode(_behaviorSequence.join('|'))).toString();
|
||||
}
|
||||
|
||||
List<Map<String, dynamic>> _pageStayList(DateTime endedAt) {
|
||||
final all = [
|
||||
..._completedPageStays,
|
||||
..._activePageStays.map((record) => record.close(
|
||||
exitTime: endedAt,
|
||||
hasInteraction: _pagesWithInteraction.contains(record.pageKey),
|
||||
)),
|
||||
]..sort((a, b) => a.enterTime.compareTo(b.enterTime));
|
||||
final capped = all.length > _maxPageStayRecords
|
||||
? all.sublist(all.length - _maxPageStayRecords)
|
||||
: all;
|
||||
return capped.map((record) => record.toJson()).toList();
|
||||
}
|
||||
|
||||
void _closeActivePageStays({required DateTime exitTime}) {
|
||||
if (_activePageStays.isEmpty) return;
|
||||
for (final record in _activePageStays) {
|
||||
_completedPageStays.add(record.close(
|
||||
exitTime: exitTime,
|
||||
hasInteraction: _pagesWithInteraction.contains(record.pageKey),
|
||||
));
|
||||
}
|
||||
_activePageStays.clear();
|
||||
_trimCompletedPageStays();
|
||||
}
|
||||
|
||||
int _findActivePageStayIndex(String pageKey, DateTime enterTime) {
|
||||
for (var i = _activePageStays.length - 1; i >= 0; i--) {
|
||||
final record = _activePageStays[i];
|
||||
if (record.pageKey == pageKey && record.enterTime == enterTime) return i;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
void _trimCompletedPageStays() {
|
||||
while (_completedPageStays.length > _maxPageStayRecords) {
|
||||
_completedPageStays.removeAt(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class SessionMetricsSnapshot {
|
||||
final int eventCountTotal;
|
||||
final int businessEventCount;
|
||||
final int foregroundCount;
|
||||
final int backgroundCountInSid;
|
||||
final int pageCount;
|
||||
final int pageViewCount;
|
||||
final int pageClickCount;
|
||||
final int networkChangeCount;
|
||||
final String sessionSid;
|
||||
final String lastEventName;
|
||||
final List<Map<String, dynamic>> pageStayList;
|
||||
final String behaviorSequenceHash;
|
||||
|
||||
const SessionMetricsSnapshot({
|
||||
required this.eventCountTotal,
|
||||
required this.businessEventCount,
|
||||
required this.foregroundCount,
|
||||
required this.backgroundCountInSid,
|
||||
required this.pageCount,
|
||||
required this.pageViewCount,
|
||||
required this.pageClickCount,
|
||||
required this.networkChangeCount,
|
||||
required this.sessionSid,
|
||||
required this.lastEventName,
|
||||
required this.pageStayList,
|
||||
required this.behaviorSequenceHash,
|
||||
});
|
||||
}
|
||||
|
||||
class _PageStayRecord {
|
||||
final String pageKey;
|
||||
final String pageName;
|
||||
final DateTime enterTime;
|
||||
final DateTime? exitTime;
|
||||
final bool hasInteraction;
|
||||
|
||||
const _PageStayRecord({
|
||||
required this.pageKey,
|
||||
required this.pageName,
|
||||
required this.enterTime,
|
||||
this.exitTime,
|
||||
this.hasInteraction = false,
|
||||
});
|
||||
|
||||
_PageStayRecord close({
|
||||
required DateTime exitTime,
|
||||
required bool hasInteraction,
|
||||
}) {
|
||||
return _PageStayRecord(
|
||||
pageKey: pageKey,
|
||||
pageName: pageName,
|
||||
enterTime: enterTime,
|
||||
exitTime: exitTime,
|
||||
hasInteraction: hasInteraction,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final endTime = exitTime ?? DateTime.now();
|
||||
final stayDurationMs = endTime.difference(enterTime).inMilliseconds;
|
||||
return {
|
||||
'page_key': pageKey,
|
||||
'page_name': pageName,
|
||||
'stay_duration_ms': stayDurationMs < 0 ? 0 : stayDurationMs,
|
||||
'enter_time': enterTime.millisecondsSinceEpoch ~/ 1000,
|
||||
'has_interaction': hasInteraction ? 1 : 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export 'session_sensor_proximity_stub.dart'
|
||||
if (dart.library.io) 'session_sensor_proximity_native.dart';
|
||||
@@ -0,0 +1,17 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
const EventChannel _proximityChannel = EventChannel('analytics_sdk/proximity');
|
||||
|
||||
Stream<int> proximityEvents() {
|
||||
if (!Platform.isAndroid && !Platform.isIOS) {
|
||||
return const Stream<int>.empty();
|
||||
}
|
||||
return _proximityChannel.receiveBroadcastStream().map((value) {
|
||||
if (value is bool) return value ? 1 : 0;
|
||||
if (value is num) return value == 0 ? 0 : 1;
|
||||
final text = value?.toString().trim().toLowerCase() ?? '';
|
||||
return text == 'true' || text == '1' ? 1 : 0;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
Stream<int> proximityEvents() => const Stream<int>.empty();
|
||||
@@ -0,0 +1,467 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:analytics_sdk/manager/session_sensor_proximity.dart';
|
||||
import 'package:analytics_sdk/utils/logger.dart';
|
||||
import 'package:crypto/crypto.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:sensors_plus/sensors_plus.dart';
|
||||
|
||||
enum SensorSampleType {
|
||||
accelerometer,
|
||||
gyroscope,
|
||||
magnetometer,
|
||||
proximity,
|
||||
}
|
||||
|
||||
class SensorMagnitudeSample {
|
||||
final SensorSampleType type;
|
||||
final double magnitude;
|
||||
final DateTime sampledAt;
|
||||
|
||||
const SensorMagnitudeSample({
|
||||
required this.type,
|
||||
required this.magnitude,
|
||||
required this.sampledAt,
|
||||
});
|
||||
}
|
||||
|
||||
class SessionSensorSnapshot {
|
||||
final String sensorSignature;
|
||||
final int sensorCountActive;
|
||||
|
||||
const SessionSensorSnapshot({
|
||||
required this.sensorSignature,
|
||||
required this.sensorCountActive,
|
||||
});
|
||||
|
||||
const SessionSensorSnapshot.unsupported()
|
||||
: sensorSignature = '',
|
||||
sensorCountActive = -1;
|
||||
}
|
||||
|
||||
typedef SensorStreamFactory = Stream<SensorMagnitudeSample> Function(
|
||||
SensorSampleType type,
|
||||
Duration samplingPeriod,
|
||||
);
|
||||
|
||||
typedef PlatformSupportResolver = bool Function();
|
||||
|
||||
/// Android/iOS session-level sensor sampler.
|
||||
///
|
||||
/// It only keeps rolling aggregate statistics for the current sid. Raw sensor
|
||||
/// sequences are never retained or exposed.
|
||||
class SessionSensorSampler {
|
||||
static final SessionSensorSampler instance = SessionSensorSampler._internal();
|
||||
|
||||
static const Duration _defaultWindowInterval = Duration(seconds: 60);
|
||||
static const Duration _defaultWindowDuration = Duration(seconds: 5);
|
||||
static const Duration _defaultSamplingPeriod = Duration(milliseconds: 200);
|
||||
static const int _defaultMaxWindowCount = 6;
|
||||
|
||||
final PlatformSupportResolver _isSupportedPlatform;
|
||||
final SensorStreamFactory _sensorStreams;
|
||||
final Duration _windowInterval;
|
||||
final Duration _windowDuration;
|
||||
final Duration _samplingPeriod;
|
||||
final int _maxWindowCount;
|
||||
|
||||
String _sid = '';
|
||||
bool _isForeground = true;
|
||||
bool _hasForegroundPageView = false;
|
||||
bool _samplingStarted = false;
|
||||
bool _samplingUnavailable = false;
|
||||
bool _windowActive = false;
|
||||
int _windowsStarted = 0;
|
||||
Timer? _nextWindowTimer;
|
||||
Timer? _windowStopTimer;
|
||||
final List<StreamSubscription<SensorMagnitudeSample>> _subscriptions =
|
||||
<StreamSubscription<SensorMagnitudeSample>>[];
|
||||
final Map<SensorSampleType, _SensorStats> _stats =
|
||||
<SensorSampleType, _SensorStats>{};
|
||||
|
||||
factory SessionSensorSampler() => instance;
|
||||
|
||||
SessionSensorSampler._internal()
|
||||
: _isSupportedPlatform = _defaultPlatformSupported,
|
||||
_sensorStreams = _defaultSensorStreams,
|
||||
_windowInterval = _defaultWindowInterval,
|
||||
_windowDuration = _defaultWindowDuration,
|
||||
_samplingPeriod = _defaultSamplingPeriod,
|
||||
_maxWindowCount = _defaultMaxWindowCount;
|
||||
|
||||
@visibleForTesting
|
||||
SessionSensorSampler.forTest({
|
||||
required PlatformSupportResolver isSupportedPlatform,
|
||||
required SensorStreamFactory sensorStreams,
|
||||
Duration windowInterval = _defaultWindowInterval,
|
||||
Duration windowDuration = _defaultWindowDuration,
|
||||
Duration samplingPeriod = _defaultSamplingPeriod,
|
||||
int maxWindowCount = _defaultMaxWindowCount,
|
||||
}) : _isSupportedPlatform = isSupportedPlatform,
|
||||
_sensorStreams = sensorStreams,
|
||||
_windowInterval = windowInterval,
|
||||
_windowDuration = windowDuration,
|
||||
_samplingPeriod = samplingPeriod,
|
||||
_maxWindowCount = maxWindowCount;
|
||||
|
||||
void recordAcceptedEvent(Map<String, dynamic> event) {
|
||||
final eventName = event['event']?.toString() ?? '';
|
||||
if (eventName.isEmpty || eventName == 'app_session_summary') return;
|
||||
|
||||
final sid = event['sid']?.toString() ?? '';
|
||||
if (sid != _sid) {
|
||||
reset(sid: sid);
|
||||
}
|
||||
|
||||
if (eventName == 'app_page_view') {
|
||||
_hasForegroundPageView = _isForeground;
|
||||
_ensureSamplingTask();
|
||||
return;
|
||||
}
|
||||
|
||||
if (eventName != 'app_lifecycle') return;
|
||||
final key = _lifecycleEventKey(event);
|
||||
if (key == 'background') {
|
||||
markBackground();
|
||||
} else if (key == 'foreground') {
|
||||
markForeground();
|
||||
}
|
||||
}
|
||||
|
||||
void markForeground() {
|
||||
_isForeground = true;
|
||||
_hasForegroundPageView = false;
|
||||
}
|
||||
|
||||
String _lifecycleEventKey(Map<String, dynamic> event) {
|
||||
final payload = event['payload'];
|
||||
if (payload is Map) {
|
||||
final value = payload['lifecycle_event_key']?.toString().trim() ?? '';
|
||||
if (value.isNotEmpty) return value;
|
||||
}
|
||||
return event['lifecycle_event_key']?.toString().trim() ?? '';
|
||||
}
|
||||
|
||||
void markBackground() {
|
||||
_isForeground = false;
|
||||
_hasForegroundPageView = false;
|
||||
_pauseSamplingTask();
|
||||
}
|
||||
|
||||
void reset({String sid = ''}) {
|
||||
_pauseSamplingTask();
|
||||
_sid = sid;
|
||||
_isForeground = true;
|
||||
_hasForegroundPageView = false;
|
||||
_samplingStarted = false;
|
||||
_samplingUnavailable = false;
|
||||
_windowsStarted = 0;
|
||||
_stats.clear();
|
||||
}
|
||||
|
||||
SessionSensorSnapshot snapshot({int screenBrightness = -1}) {
|
||||
if (_samplingUnavailable || !_samplingStarted) {
|
||||
return const SessionSensorSnapshot.unsupported();
|
||||
}
|
||||
final activeCount = _stats.entries
|
||||
.where((entry) => entry.value.isActive(_activeThreshold(entry.key)))
|
||||
.length;
|
||||
final totalSamples =
|
||||
_stats.values.fold<int>(0, (sum, stats) => sum + stats.count);
|
||||
if (totalSamples <= 0) {
|
||||
return const SessionSensorSnapshot(
|
||||
sensorSignature: '',
|
||||
sensorCountActive: 0,
|
||||
);
|
||||
}
|
||||
if (activeCount <= 0) {
|
||||
return const SessionSensorSnapshot(
|
||||
sensorSignature: '',
|
||||
sensorCountActive: 0,
|
||||
);
|
||||
}
|
||||
final signatureInput = _signatureInput(screenBrightness: screenBrightness);
|
||||
return SessionSensorSnapshot(
|
||||
sensorSignature: sha256.convert(utf8.encode(signatureInput)).toString(),
|
||||
sensorCountActive: activeCount,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> dispose() async {
|
||||
_pauseSamplingTask();
|
||||
_stats.clear();
|
||||
}
|
||||
|
||||
@visibleForTesting
|
||||
void debugMarkSamplingStartedForTest() {
|
||||
_samplingStarted = true;
|
||||
_samplingUnavailable = false;
|
||||
}
|
||||
|
||||
@visibleForTesting
|
||||
void debugRecordSampleForTest(
|
||||
SensorSampleType type, {
|
||||
required double magnitude,
|
||||
required DateTime sampledAt,
|
||||
}) {
|
||||
_recordSample(SensorMagnitudeSample(
|
||||
type: type,
|
||||
magnitude: magnitude,
|
||||
sampledAt: sampledAt,
|
||||
));
|
||||
}
|
||||
|
||||
void _ensureSamplingTask() {
|
||||
if (!_isForeground || !_hasForegroundPageView) return;
|
||||
if (_windowsStarted >= _maxWindowCount) return;
|
||||
if (_windowActive || _nextWindowTimer != null) return;
|
||||
if (!_isSupportedPlatform()) {
|
||||
_samplingUnavailable = true;
|
||||
return;
|
||||
}
|
||||
_startWindow();
|
||||
}
|
||||
|
||||
void _startWindow() {
|
||||
if (!_isForeground || !_hasForegroundPageView) return;
|
||||
if (_windowsStarted >= _maxWindowCount) return;
|
||||
if (!_isSupportedPlatform()) {
|
||||
_samplingUnavailable = true;
|
||||
return;
|
||||
}
|
||||
|
||||
final subscriptions = <StreamSubscription<SensorMagnitudeSample>>[];
|
||||
for (final type in SensorSampleType.values) {
|
||||
try {
|
||||
final subscription = _sensorStreams(type, _samplingPeriod).listen(
|
||||
_recordSample,
|
||||
onError: (Object error) {
|
||||
Logger.analyticsSdk('传感器采样异常($type): $error', level: LogLevel.warn);
|
||||
},
|
||||
cancelOnError: false,
|
||||
);
|
||||
subscriptions.add(subscription);
|
||||
} catch (e) {
|
||||
Logger.analyticsSdk('传感器监听启动失败($type): $e', level: LogLevel.warn);
|
||||
}
|
||||
}
|
||||
|
||||
if (subscriptions.isEmpty) {
|
||||
_samplingUnavailable = true;
|
||||
return;
|
||||
}
|
||||
|
||||
_subscriptions.addAll(subscriptions);
|
||||
_samplingStarted = true;
|
||||
_samplingUnavailable = false;
|
||||
_windowActive = true;
|
||||
_windowsStarted += 1;
|
||||
_windowStopTimer?.cancel();
|
||||
_windowStopTimer = Timer(_windowDuration, _finishWindow);
|
||||
}
|
||||
|
||||
void _finishWindow() {
|
||||
_stopCurrentWindow();
|
||||
if (!_isForeground || !_hasForegroundPageView) return;
|
||||
if (_windowsStarted >= _maxWindowCount) return;
|
||||
_nextWindowTimer?.cancel();
|
||||
_nextWindowTimer = Timer(_windowInterval, () {
|
||||
_nextWindowTimer = null;
|
||||
_startWindow();
|
||||
});
|
||||
}
|
||||
|
||||
void _pauseSamplingTask() {
|
||||
_nextWindowTimer?.cancel();
|
||||
_nextWindowTimer = null;
|
||||
_windowStopTimer?.cancel();
|
||||
_windowStopTimer = null;
|
||||
_stopCurrentWindow();
|
||||
}
|
||||
|
||||
void _stopCurrentWindow() {
|
||||
_windowActive = false;
|
||||
for (final subscription in _subscriptions) {
|
||||
unawaited(subscription.cancel());
|
||||
}
|
||||
_subscriptions.clear();
|
||||
}
|
||||
|
||||
void _recordSample(SensorMagnitudeSample sample) {
|
||||
if (!sample.magnitude.isFinite) return;
|
||||
final stats = _stats.putIfAbsent(sample.type, _SensorStats.new);
|
||||
if (!stats.canRecord(sample.sampledAt, _samplingPeriod)) return;
|
||||
stats.record(sample.magnitude, sample.sampledAt);
|
||||
}
|
||||
|
||||
String _signatureInput({required int screenBrightness}) {
|
||||
final tokens = <String>['sid=$_sid', 'windows=$_windowsStarted'];
|
||||
if (screenBrightness >= 0 && screenBrightness <= 100) {
|
||||
tokens.add('screen_brightness=$screenBrightness');
|
||||
}
|
||||
for (final type in SensorSampleType.values) {
|
||||
final stats = _stats[type];
|
||||
if (stats == null || stats.count == 0) continue;
|
||||
tokens.add('${_typeCode(type)}:${stats.signatureToken}');
|
||||
}
|
||||
return tokens.join('|');
|
||||
}
|
||||
|
||||
static bool _defaultPlatformSupported() {
|
||||
return isSupportedPlatformForTest(
|
||||
isWeb: kIsWeb,
|
||||
platform: defaultTargetPlatform,
|
||||
);
|
||||
}
|
||||
|
||||
@visibleForTesting
|
||||
static bool isSupportedPlatformForTest({
|
||||
required bool isWeb,
|
||||
required TargetPlatform platform,
|
||||
}) {
|
||||
return !isWeb &&
|
||||
(platform == TargetPlatform.android || platform == TargetPlatform.iOS);
|
||||
}
|
||||
|
||||
static Stream<SensorMagnitudeSample> _defaultSensorStreams(
|
||||
SensorSampleType type,
|
||||
Duration samplingPeriod,
|
||||
) {
|
||||
switch (type) {
|
||||
case SensorSampleType.accelerometer:
|
||||
return accelerometerEvents.map(
|
||||
(event) => SensorMagnitudeSample(
|
||||
type: type,
|
||||
magnitude: _magnitude(event.x, event.y, event.z),
|
||||
sampledAt: DateTime.now(),
|
||||
),
|
||||
);
|
||||
case SensorSampleType.gyroscope:
|
||||
return gyroscopeEvents.map(
|
||||
(event) => SensorMagnitudeSample(
|
||||
type: type,
|
||||
magnitude: _magnitude(event.x, event.y, event.z),
|
||||
sampledAt: DateTime.now(),
|
||||
),
|
||||
);
|
||||
case SensorSampleType.magnetometer:
|
||||
return magnetometerEvents.map(
|
||||
(event) => SensorMagnitudeSample(
|
||||
type: type,
|
||||
magnitude: _magnitude(event.x, event.y, event.z),
|
||||
sampledAt: DateTime.now(),
|
||||
),
|
||||
);
|
||||
case SensorSampleType.proximity:
|
||||
return proximityEvents().map(
|
||||
(value) => SensorMagnitudeSample(
|
||||
type: type,
|
||||
magnitude: value == 0 ? 0 : 1,
|
||||
sampledAt: DateTime.now(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
static double _magnitude(double x, double y, double z) {
|
||||
return sqrt(x * x + y * y + z * z);
|
||||
}
|
||||
|
||||
static String _typeCode(SensorSampleType type) {
|
||||
switch (type) {
|
||||
case SensorSampleType.accelerometer:
|
||||
return 'acc';
|
||||
case SensorSampleType.gyroscope:
|
||||
return 'gyro';
|
||||
case SensorSampleType.magnetometer:
|
||||
return 'mag';
|
||||
case SensorSampleType.proximity:
|
||||
return 'prox';
|
||||
}
|
||||
}
|
||||
|
||||
static double _activeThreshold(SensorSampleType type) {
|
||||
switch (type) {
|
||||
case SensorSampleType.accelerometer:
|
||||
return 0.8;
|
||||
case SensorSampleType.gyroscope:
|
||||
return 0.05;
|
||||
case SensorSampleType.magnetometer:
|
||||
return 3.0;
|
||||
case SensorSampleType.proximity:
|
||||
return 1.0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _SensorStats {
|
||||
int count = 0;
|
||||
double mean = 0;
|
||||
double _m2 = 0;
|
||||
double minValue = double.infinity;
|
||||
double maxValue = double.negativeInfinity;
|
||||
DateTime? _lastSampleAt;
|
||||
int _intervalCount = 0;
|
||||
int _totalIntervalMs = 0;
|
||||
|
||||
bool canRecord(DateTime sampledAt, Duration minInterval) {
|
||||
final last = _lastSampleAt;
|
||||
if (last == null) return true;
|
||||
return sampledAt.difference(last) >= minInterval;
|
||||
}
|
||||
|
||||
void record(double value, DateTime sampledAt) {
|
||||
final last = _lastSampleAt;
|
||||
if (last != null) {
|
||||
final intervalMs = sampledAt.difference(last).inMilliseconds;
|
||||
if (intervalMs > 0) {
|
||||
_intervalCount += 1;
|
||||
_totalIntervalMs += intervalMs;
|
||||
}
|
||||
}
|
||||
_lastSampleAt = sampledAt;
|
||||
|
||||
count += 1;
|
||||
final delta = value - mean;
|
||||
mean += delta / count;
|
||||
final delta2 = value - mean;
|
||||
_m2 += delta * delta2;
|
||||
if (value < minValue) minValue = value;
|
||||
if (value > maxValue) maxValue = value;
|
||||
}
|
||||
|
||||
double get variance => count > 1 ? _m2 / (count - 1) : 0;
|
||||
|
||||
double get range {
|
||||
if (count <= 0) return 0;
|
||||
return maxValue - minValue;
|
||||
}
|
||||
|
||||
int get averageIntervalMs {
|
||||
if (_intervalCount <= 0) return 0;
|
||||
return (_totalIntervalMs / _intervalCount).round();
|
||||
}
|
||||
|
||||
bool isActive(double threshold) {
|
||||
return count > 0 && range >= threshold;
|
||||
}
|
||||
|
||||
String get signatureToken {
|
||||
return [
|
||||
'n=$count',
|
||||
'mean=${_fixed(mean)}',
|
||||
'var=${_fixed(variance)}',
|
||||
'min=${_fixed(minValue)}',
|
||||
'max=${_fixed(maxValue)}',
|
||||
'range=${_fixed(range)}',
|
||||
'avg_i=$averageIntervalMs',
|
||||
].join(',');
|
||||
}
|
||||
|
||||
static String _fixed(double value) {
|
||||
if (!value.isFinite) return '0.000';
|
||||
return value.toStringAsFixed(3);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import 'package:analytics_sdk/enum/user_type_enum.dart';
|
||||
|
||||
/// 全局用户管理器
|
||||
/// 负责管理用户类型信息,支持用户类型更新和登出操作
|
||||
class UserManager {
|
||||
static final UserManager instance = UserManager._internal();
|
||||
|
||||
factory UserManager() => instance;
|
||||
|
||||
UserManager._internal();
|
||||
|
||||
/// 当前用户类型:normal、vip 等
|
||||
String _userType = UserTypeEnum.normal.label;
|
||||
|
||||
String get userType => _userType;
|
||||
|
||||
static String normalizeUserType(String? value) {
|
||||
return value?.trim().toLowerCase() == UserTypeEnum.vip.label
|
||||
? UserTypeEnum.vip.label
|
||||
: UserTypeEnum.normal.label;
|
||||
}
|
||||
|
||||
/// 更新用户类型(登录、升级会员、切换账号时调用)
|
||||
void updateUserType(String newType) {
|
||||
final normalized = normalizeUserType(newType);
|
||||
if (_userType != normalized) {
|
||||
_userType = normalized;
|
||||
}
|
||||
}
|
||||
|
||||
/// 登出
|
||||
void logout() {
|
||||
_userType = UserTypeEnum.normal.label; // 或 'guest',根据业务决定
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:analytics_sdk/entity/app_lifecycle_event.dart';
|
||||
import 'package:analytics_sdk/manager/session_manager.dart';
|
||||
import 'package:analytics_sdk/observer/page_lifecycle_observer.dart';
|
||||
import 'package:analytics_sdk/utils/environment_info_util.dart';
|
||||
import 'package:analytics_sdk/utils/logger.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
/// 应用生命周期观察者:监听应用前后台切换,用于会话管理与生命周期埋点
|
||||
class AppLifecycleObserver with WidgetsBindingObserver {
|
||||
static final AppLifecycleObserver instance = AppLifecycleObserver._internal();
|
||||
|
||||
factory AppLifecycleObserver() => instance;
|
||||
|
||||
AppLifecycleObserver._internal();
|
||||
|
||||
bool _isInitialized = false;
|
||||
void Function(dynamic event)? _track;
|
||||
DateTime Function() _now = DateTime.now;
|
||||
DateTime? _foregroundStartedAt;
|
||||
DateTime? _backgroundStartedAt;
|
||||
int _foregroundCountInSid = 0;
|
||||
String _foregroundCountSid = '';
|
||||
|
||||
/// 初始化观察者
|
||||
void initialize({
|
||||
void Function(dynamic event)? track,
|
||||
DateTime Function()? now,
|
||||
}) {
|
||||
if (track != null) _track = track;
|
||||
if (now != null) _now = now;
|
||||
_foregroundStartedAt ??= _now();
|
||||
if (!_isInitialized) {
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
_isInitialized = true;
|
||||
Logger.analyticsSdk('应用生命周期观察者已初始化');
|
||||
}
|
||||
}
|
||||
|
||||
/// 销毁观察者
|
||||
void dispose() {
|
||||
if (_isInitialized) {
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
_isInitialized = false;
|
||||
Logger.analyticsSdk('应用生命周期观察者已销毁');
|
||||
}
|
||||
_track = null;
|
||||
_foregroundStartedAt = null;
|
||||
_backgroundStartedAt = null;
|
||||
_foregroundCountInSid = 0;
|
||||
_foregroundCountSid = '';
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||
super.didChangeAppLifecycleState(state);
|
||||
|
||||
try {
|
||||
switch (state) {
|
||||
case AppLifecycleState.resumed:
|
||||
if (!kIsWeb) {
|
||||
final now = _now();
|
||||
final backgroundStartedAt = _backgroundStartedAt;
|
||||
final backgroundDurationMs = backgroundStartedAt == null
|
||||
? 0
|
||||
: now.difference(backgroundStartedAt).inMilliseconds;
|
||||
// 应用从后台恢复前台
|
||||
SessionManager.instance.onAppForeground();
|
||||
if (backgroundStartedAt != null) {
|
||||
_emit(AppLifecycleEvent.warmStart(
|
||||
backgroundDurationMs: backgroundDurationMs,
|
||||
resumeReason: 'unknown',
|
||||
));
|
||||
_emit(AppLifecycleEvent.foreground(
|
||||
foregroundCountInSid: _nextForegroundCountInSid(),
|
||||
));
|
||||
}
|
||||
_backgroundStartedAt = null;
|
||||
_foregroundStartedAt = now;
|
||||
}
|
||||
unawaited(
|
||||
EnvironmentInfoUtil.refreshBatteryLevelIfNeeded(force: true),
|
||||
);
|
||||
unawaited(EnvironmentInfoUtil.refreshRuntimeObservationFields());
|
||||
Logger.analyticsSdk('应用恢复前台');
|
||||
break;
|
||||
case AppLifecycleState.paused:
|
||||
if (!kIsWeb) {
|
||||
final now = _now();
|
||||
final foregroundDurationMs = _foregroundStartedAt == null
|
||||
? 0
|
||||
: now.difference(_foregroundStartedAt!).inMilliseconds;
|
||||
_emit(AppLifecycleEvent.background(
|
||||
foregroundDurationMs: foregroundDurationMs,
|
||||
lastActivePageKey: PageLifecycleObserver.currentPageKey,
|
||||
));
|
||||
// Android 和 iOS 进入后台均走 paused(iOS 先经过 hidden 再到 paused)
|
||||
// 统一在此记录后台开始时间,避免在 hidden 重复记录
|
||||
SessionManager.instance.onAppBackground();
|
||||
_backgroundStartedAt = now;
|
||||
}
|
||||
Logger.analyticsSdk('应用进入后台');
|
||||
break;
|
||||
case AppLifecycleState.inactive:
|
||||
// inactive 是过渡状态(进后台和回前台都会经过),不记录后台时间
|
||||
Logger.analyticsSdk('应用进入 inactive 状态');
|
||||
break;
|
||||
case AppLifecycleState.detached:
|
||||
if (!kIsWeb) {
|
||||
final now = _now();
|
||||
_emit(AppLifecycleEvent.terminate(
|
||||
lastActivePageKey: PageLifecycleObserver.currentPageKey,
|
||||
terminateType: 'unknown',
|
||||
sessionDurationMs: _sessionDurationMs(now),
|
||||
terminateTs: now.millisecondsSinceEpoch ~/ 1000,
|
||||
));
|
||||
}
|
||||
Logger.analyticsSdk('应用即将终止');
|
||||
break;
|
||||
default:
|
||||
// Flutter 3.0 没有 hidden;较新版本的 hidden 是过渡状态,
|
||||
// 不应影响会话开始或后台时长。
|
||||
Logger.analyticsSdk('应用生命周期过渡状态: $state');
|
||||
break;
|
||||
}
|
||||
} catch (e) {
|
||||
Logger.analyticsSdk('应用生命周期状态变化处理异常: $e');
|
||||
_emit(AppLifecycleEvent.unknown(unknownReason: 'collect_error'));
|
||||
}
|
||||
}
|
||||
|
||||
void _emit(AppLifecycleEvent event) {
|
||||
try {
|
||||
final track = _track;
|
||||
if (track == null) return;
|
||||
track(event);
|
||||
} catch (e) {
|
||||
Logger.analyticsSdk('app_lifecycle 上报异常,已安全处理: $e');
|
||||
}
|
||||
}
|
||||
|
||||
int _nextForegroundCountInSid() {
|
||||
final sid = SessionManager.instance.currentSessionId ?? '';
|
||||
if (sid != _foregroundCountSid) {
|
||||
_foregroundCountSid = sid;
|
||||
_foregroundCountInSid = 0;
|
||||
}
|
||||
_foregroundCountInSid += 1;
|
||||
return _foregroundCountInSid;
|
||||
}
|
||||
|
||||
int _sessionDurationMs(DateTime now) {
|
||||
final start = SessionManager.instance.sessionStartTime;
|
||||
if (start == null) return 0;
|
||||
final duration = now.difference(start).inMilliseconds;
|
||||
return duration < 0 ? 0 : duration;
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user