Initial commit: 91dj android client dev_1.0.0

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
谢宇宁
2026-10-08 11:37:25 +07:00
co-authored by Claude Opus 5.5
commit 97dd6a739a
1262 changed files with 119813 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
{
"flutter": "3.38.3"
}
+193
View File
@@ -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/
+16
View File
@@ -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.
+35
View File
@@ -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
+29
View File
@@ -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/
+1
View File
@@ -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
+4
View File
@@ -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
+9
View File
@@ -0,0 +1,9 @@
*.iml
.gradle
/local.properties
/.idea/workspace.xml
/.idea/libraries
.DS_Store
/build
/captures
.cxx
+64
View File
@@ -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
+1
View File
@@ -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>
@@ -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
}
}
@@ -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' }
}
}
@@ -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)
}
}
@@ -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)
}
}
+38
View File
@@ -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
View File
@@ -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)
}
}
+23
View File
@@ -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
+12
View File
@@ -0,0 +1,12 @@
import 'package:flutter_web_plugins/flutter_web_plugins.dart';
/// Analytics SDK 的 Web 平台插件入口。
///
/// Flutter Web 插件系统要求此文件存在,并实现 [registerWith]。
/// SDK 的 Web 实现(设备信息、指纹等)位于 utils/ 目录下的 web 专属文件。
class AnalyticsSdkWeb {
static void registerWith(Registrar registrar) {
// SDK 不使用 MethodChannel,无需注册通道。
// Web 平台功能通过条件导入的工具类直接实现。
}
}
+84
View File
@@ -0,0 +1,84 @@
/// API 配置管理类:统一管理所有 API 端点路径
class ApiConfig {
ApiConfig._();
// ==================== API 路径(固定不变)====================
/// 上报接口路径(固定)
static const String defaultReportPath = '/api/eventTracking/batchReport.json';
/// 上报接口路径
static String get reportPath => defaultReportPath;
// ==================== 配置方法 ====================
/// 构建完整的 URL
/// [baseDomain] 基础域名,如 'https://api.example.com'
/// [path] API 路径,如 '/api/analytics/report'
/// [appId] 应用ID(可选),如果提供会作为查询参数添加到URL中
/// 返回完整的 URL,如 'https://api.example.com/api/analytics/report?appId=xxx'
static String buildUrl(String baseDomain, String path, {String? appId}) {
// 确保路径以 / 开头
final normalizedPath = _normalizePath(path);
// 解析基础域名
final uri = Uri.parse(baseDomain);
final scheme = uri.scheme.isEmpty ? 'https' : uri.scheme;
final host = uri.host.isEmpty ? baseDomain : uri.host;
// 保留非默认端口(如 :8080),避免 IP+端口 场景下端口丢失
final defaultPort = scheme == 'https' ? 443 : 80;
final portSuffix =
(uri.port > 0 && uri.port != defaultPort) ? ':${uri.port}' : '';
// 保留 baseDomain 中的路径前缀(如 /v2),仅在 host 成功解析时使用
// 去掉末尾 / 避免与 normalizedPath 拼出双斜杠
final basePath = uri.host.isNotEmpty && uri.path.isNotEmpty && uri.path != '/'
? (uri.path.endsWith('/')
? uri.path.substring(0, uri.path.length - 1)
: uri.path)
: '';
// 构建完整 URL
String url = '$scheme://$host$portSuffix$basePath$normalizedPath';
// 如果提供了 appId,添加到查询参数
if (appId != null && appId.isNotEmpty) {
url = _addQueryParameter(url, 'appId', appId);
}
return url;
}
/// 规范化路径:确保路径以 / 开头
static String _normalizePath(String path) {
if (path.isEmpty) {
return '/';
}
return path.startsWith('/') ? path : '/$path';
}
/// 获取上报接口 URL
/// [baseDomain] 基础域名
/// [appId] 应用ID(可选,如果提供会作为查询参数)
static String getReportUrl(String baseDomain, {String? appId}) {
return buildUrl(baseDomain, reportPath, appId: appId);
}
/// 添加查询参数到 URL
/// [url] 原始 URL
/// [key] 参数名
/// [value] 参数值
static String _addQueryParameter(String url, String key, String value) {
try {
final uri = Uri.parse(url);
final queryParams = Map<String, String>.from(uri.queryParameters);
queryParams[key] = value;
return uri.replace(queryParameters: queryParams).toString();
} catch (e) {
// 如果 URL 解析失败,直接拼接
final separator = url.contains('?') ? '&' : '?';
return '$url$separator$key=${Uri.encodeComponent(value)}';
}
}
}
+334
View File
@@ -0,0 +1,334 @@
/// SDK 配置管理类:统一管理所有 SDK 相关的配置常量
class SdkConfig {
SdkConfig._();
// ==================== 默认值常量(单一来源)====================
// 字段声明和 reset() 均引用这些常量,避免三处重复。
static const int _defaultMaxQueueSize = 5000;
static const int _defaultAutoUploadThreshold = 10;
static const int _defaultMaxBatchSize = 500;
static const int _defaultIsolateEncodingThreshold = 100;
static const Duration _defaultUploadInterval = Duration(seconds: 5);
static const Duration _defaultPageLoadTimeout = Duration(seconds: 5);
static const Duration _defaultClickThrottleDuration =
Duration(milliseconds: 300);
static const Duration _defaultConnectionTimeout = Duration(seconds: 10);
static const Duration _defaultReadTimeout = Duration(seconds: 30);
static const Duration _defaultSpeedTestTimeout = Duration(seconds: 5);
static const Duration _defaultConfigRequestTimeout = Duration(seconds: 10);
static const int _defaultMaxBackoffLevel = 5;
static const Duration _defaultBaseRetryDelay = Duration(seconds: 2);
static const Duration _defaultMaxRetryDelay = Duration(seconds: 60);
static const String _defaultCacheFileName = 'data_plus_events_cache.jsonl';
static const String _defaultDebugReportEventLogFileName =
'data_plus_report_events_debug.jsonl';
static const bool _defaultEnableDebugReportLog = false;
static const String _defaultEventTypeConfigCacheFileName =
'analytics_event_types_config.json';
static const String _defaultDomainCacheFileName =
'data_plus_domains_cache.json';
static const int _defaultMaxCacheLines = 2000;
static const int _defaultMaxCacheBytes = 2 * 1024 * 1024;
static const int _defaultTombstoneCompactionThreshold = 200;
static const String _defaultTombstoneFileName =
'data_plus_events_tombstone.txt';
static const int _defaultMaxJsonSize = 5 * 1024 * 1024;
static const int _defaultMaxSingleEventSize = 300 * 1024;
static const int _defaultMaxConfigResponseSize = 1 * 1024 * 1024;
static const int _defaultMaxPageStackSize = 100;
static const int _defaultMaxAdImpressionCapacity = 10000;
static const int _defaultCacheBufferThreshold = 10;
static const Duration _defaultCacheFlushInterval = Duration(seconds: 5);
// ==================== 事件队列配置 ====================
/// 事件队列最大容量限制,防止内存无限增长
static int maxQueueSize = _defaultMaxQueueSize;
/// 自动上报阈值:队列达到此数量时立即触发上报
static int autoUploadThreshold = _defaultAutoUploadThreshold;
/// 单次上报的最大事件数量限制,避免请求过大导致超时
static int maxBatchSize = _defaultMaxBatchSize;
/// JSON 编码的批次大小阈值,超过此大小使用 isolate 编码
static int isolateEncodingThreshold = _defaultIsolateEncodingThreshold;
// ==================== 定时器配置 ====================
/// 定时上报间隔(秒)
static Duration uploadInterval = _defaultUploadInterval;
/// 页面加载超时时间(秒)
static Duration pageLoadTimeout = _defaultPageLoadTimeout;
/// 点击事件节流时间(毫秒)
static Duration clickThrottleDuration = _defaultClickThrottleDuration;
// ==================== 网络超时配置 ====================
/// HTTP 连接超时时间
static Duration connectionTimeout = _defaultConnectionTimeout;
/// HTTP 读取超时时间
static Duration readTimeout = _defaultReadTimeout;
/// HTTP 写入超时时间(与连接超时相同)
static Duration get writeTimeout => connectionTimeout;
/// HTTP 总超时时间(连接 + 写入 + 读取)
static Duration get totalRequestTimeout =>
connectionTimeout + writeTimeout + readTimeout;
/// 域名测速超时时间
static Duration speedTestTimeout = _defaultSpeedTestTimeout;
/// 配置请求超时时间
static Duration configRequestTimeout = _defaultConfigRequestTimeout;
// ==================== 加密配置 ====================
/// AES-GCM 解密密钥(固定密钥,用于 decryptResponseAuto 等)
static const String decryptKey =
'en1BNo0VBrN/zi+mI2LO7E9W40ehCBYwC+frBn8s3rQ';
// ==================== 重试配置 ====================
/// 最大退避级别
static int maxBackoffLevel = _defaultMaxBackoffLevel;
/// 基础延迟时间
static Duration baseRetryDelay = _defaultBaseRetryDelay;
/// 最大延迟时间
static Duration maxRetryDelay = _defaultMaxRetryDelay;
// ==================== 缓存配置 ====================
/// 事件缓存文件名
static String cacheFileName = _defaultCacheFileName;
/// Debug 包完整事件 JSONL 文件名(Native 平台 applicationDocumentsDirectory 下)
static String debugReportEventLogFileName =
_defaultDebugReportEventLogFileName;
/// 是否启用调试事件日志。
///
/// 默认关闭;开启后才会本地记录上传事件完整 JSONL,并打印 accepted event 完整 JSON。
static bool enableDebugReportLog = _defaultEnableDebugReportLog;
/// 事件类型配置缓存文件名
static String eventTypeConfigCacheFileName =
_defaultEventTypeConfigCacheFileName;
/// 域名列表缓存文件名
static String domainCacheFileName = _defaultDomainCacheFileName;
/// 最大缓存行数限制,防止文件过大导致内存问题
/// 默认 2000 行,避免在网络不稳定时生成过大的缓存文件
static int maxCacheLines = _defaultMaxCacheLines;
/// 缓存文件最大字节数限制,超过后从文件开头裁剪旧事件
/// 默认 2MB
static int maxCacheBytes = _defaultMaxCacheBytes;
/// tombstone 触发压缩的 event_id 数量阈值
/// 默认 200:每积累 200 条已上报 ID 就压缩一次主缓存文件
static int tombstoneCompactionThreshold =
_defaultTombstoneCompactionThreshold;
/// tombstone 缓存文件名
static String tombstoneFileName = _defaultTombstoneFileName;
/// 持久化 buffer 攒批阈值(达到此数量立即写盘)
static int cacheBufferThreshold = _defaultCacheBufferThreshold;
/// 持久化 buffer 定时刷写间隔
static Duration cacheFlushInterval = _defaultCacheFlushInterval;
// ==================== 请求大小限制 ====================
/// JSON 最大大小限制(5MB)
static int maxJsonSize = _defaultMaxJsonSize;
/// 单条事件最大字节数限制,超过则丢弃(300KB)
static int maxSingleEventSize = _defaultMaxSingleEventSize;
/// 配置接口响应体最大字节数限制(1MB),防止恶意/异常服务器返回超大数据导致 OOM
static int maxConfigResponseSize = _defaultMaxConfigResponseSize;
// ==================== 容量配置 ====================
/// 页面栈最大容量
static int maxPageStackSize = _defaultMaxPageStackSize;
/// 广告去重最大容量
static int maxAdImpressionCapacity = _defaultMaxAdImpressionCapacity;
// ==================== 配置方法 ====================
/// 配置所有 SDK 参数
///
/// 示例:
/// ```dart
/// SdkConfig.configure(
/// maxQueueSize: 20000,
/// uploadInterval: Duration(seconds: 30),
/// connectionTimeout: Duration(seconds: 15),
/// );
/// ```
static void configure({
int? maxQueueSize,
int? autoUploadThreshold,
int? maxBatchSize,
int? isolateEncodingThreshold,
Duration? uploadInterval,
Duration? pageLoadTimeout,
Duration? clickThrottleDuration,
Duration? connectionTimeout,
Duration? readTimeout,
Duration? speedTestTimeout,
Duration? configRequestTimeout,
int? maxBackoffLevel,
Duration? baseRetryDelay,
Duration? maxRetryDelay,
String? cacheFileName,
String? debugReportEventLogFileName,
bool? enableDebugReportLog,
String? eventTypeConfigCacheFileName,
String? domainCacheFileName,
int? maxCacheLines,
int? maxCacheBytes,
int? maxJsonSize,
int? maxSingleEventSize,
int? maxPageStackSize,
int? maxAdImpressionCapacity,
}) {
if (maxQueueSize != null && maxQueueSize > 0) {
SdkConfig.maxQueueSize = maxQueueSize;
}
if (autoUploadThreshold != null && autoUploadThreshold > 0) {
SdkConfig.autoUploadThreshold = autoUploadThreshold;
}
if (maxBatchSize != null && maxBatchSize > 0) {
SdkConfig.maxBatchSize = maxBatchSize;
}
if (isolateEncodingThreshold != null && isolateEncodingThreshold > 0) {
SdkConfig.isolateEncodingThreshold = isolateEncodingThreshold;
}
if (uploadInterval != null) {
SdkConfig.uploadInterval = uploadInterval;
}
if (pageLoadTimeout != null) {
SdkConfig.pageLoadTimeout = pageLoadTimeout;
}
if (clickThrottleDuration != null) {
SdkConfig.clickThrottleDuration = clickThrottleDuration;
}
if (connectionTimeout != null) {
SdkConfig.connectionTimeout = connectionTimeout;
}
if (readTimeout != null) {
SdkConfig.readTimeout = readTimeout;
}
if (speedTestTimeout != null) {
SdkConfig.speedTestTimeout = speedTestTimeout;
}
if (configRequestTimeout != null) {
SdkConfig.configRequestTimeout = configRequestTimeout;
}
if (maxBackoffLevel != null && maxBackoffLevel >= 0) {
SdkConfig.maxBackoffLevel = maxBackoffLevel;
}
if (baseRetryDelay != null) {
SdkConfig.baseRetryDelay = baseRetryDelay;
}
if (maxRetryDelay != null) {
SdkConfig.maxRetryDelay = maxRetryDelay;
}
if (cacheFileName != null &&
cacheFileName.isNotEmpty &&
!cacheFileName.contains('/') &&
!cacheFileName.contains('\\') &&
!cacheFileName.contains('..')) {
SdkConfig.cacheFileName = cacheFileName;
}
if (debugReportEventLogFileName != null &&
debugReportEventLogFileName.isNotEmpty &&
!debugReportEventLogFileName.contains('/') &&
!debugReportEventLogFileName.contains('\\') &&
!debugReportEventLogFileName.contains('..')) {
SdkConfig.debugReportEventLogFileName = debugReportEventLogFileName;
}
if (enableDebugReportLog != null) {
SdkConfig.enableDebugReportLog = enableDebugReportLog;
}
if (eventTypeConfigCacheFileName != null &&
eventTypeConfigCacheFileName.isNotEmpty &&
!eventTypeConfigCacheFileName.contains('/') &&
!eventTypeConfigCacheFileName.contains('\\') &&
!eventTypeConfigCacheFileName.contains('..')) {
SdkConfig.eventTypeConfigCacheFileName = eventTypeConfigCacheFileName;
}
if (domainCacheFileName != null &&
domainCacheFileName.isNotEmpty &&
!domainCacheFileName.contains('/') &&
!domainCacheFileName.contains('\\') &&
!domainCacheFileName.contains('..')) {
SdkConfig.domainCacheFileName = domainCacheFileName;
}
if (maxCacheLines != null && maxCacheLines > 0) {
SdkConfig.maxCacheLines = maxCacheLines;
}
if (maxCacheBytes != null && maxCacheBytes > 0) {
SdkConfig.maxCacheBytes = maxCacheBytes;
}
if (maxJsonSize != null && maxJsonSize > 0) {
SdkConfig.maxJsonSize = maxJsonSize;
}
if (maxSingleEventSize != null && maxSingleEventSize > 0) {
SdkConfig.maxSingleEventSize = maxSingleEventSize;
}
if (maxPageStackSize != null && maxPageStackSize > 0) {
SdkConfig.maxPageStackSize = maxPageStackSize;
}
if (maxAdImpressionCapacity != null && maxAdImpressionCapacity > 0) {
SdkConfig.maxAdImpressionCapacity = maxAdImpressionCapacity;
}
}
/// 重置所有配置为默认值
static void reset() {
maxQueueSize = _defaultMaxQueueSize;
autoUploadThreshold = _defaultAutoUploadThreshold;
maxBatchSize = _defaultMaxBatchSize;
isolateEncodingThreshold = _defaultIsolateEncodingThreshold;
uploadInterval = _defaultUploadInterval;
pageLoadTimeout = _defaultPageLoadTimeout;
clickThrottleDuration = _defaultClickThrottleDuration;
connectionTimeout = _defaultConnectionTimeout;
readTimeout = _defaultReadTimeout;
speedTestTimeout = _defaultSpeedTestTimeout;
configRequestTimeout = _defaultConfigRequestTimeout;
maxBackoffLevel = _defaultMaxBackoffLevel;
baseRetryDelay = _defaultBaseRetryDelay;
maxRetryDelay = _defaultMaxRetryDelay;
cacheFileName = _defaultCacheFileName;
debugReportEventLogFileName = _defaultDebugReportEventLogFileName;
enableDebugReportLog = _defaultEnableDebugReportLog;
eventTypeConfigCacheFileName = _defaultEventTypeConfigCacheFileName;
domainCacheFileName = _defaultDomainCacheFileName;
maxCacheLines = _defaultMaxCacheLines;
maxCacheBytes = _defaultMaxCacheBytes;
tombstoneCompactionThreshold = _defaultTombstoneCompactionThreshold;
tombstoneFileName = _defaultTombstoneFileName;
cacheBufferThreshold = _defaultCacheBufferThreshold;
cacheFlushInterval = _defaultCacheFlushInterval;
maxJsonSize = _defaultMaxJsonSize;
maxSingleEventSize = _defaultMaxSingleEventSize;
maxConfigResponseSize = _defaultMaxConfigResponseSize;
maxPageStackSize = _defaultMaxPageStackSize;
maxAdImpressionCapacity = _defaultMaxAdImpressionCapacity;
}
}
+189
View File
@@ -0,0 +1,189 @@
// 如果需要其他 UUID 生成,保留;但 eventId 现在固定,不再使用
/// 事件类型枚举:定义所有可能的事件类型
/// 每个枚举值存储固定的 event 字符串
/// 同时覆盖客户端、服务端上报的所有事件,方便统一管理
enum EventType {
// ==================== 安装 & 用户 ====================
/// App 安装事件(客户端)
appInstall(event: 'app_install'),
/// SDK 初始化快照事件(客户端自动)
sdkInit(event: 'sdk_init'),
/// 应用生命周期事件(客户端自动)
appLifecycle(event: 'app_lifecycle'),
/// 网络状态变化事件(客户端自动)
networkStateChange(event: 'network_state_change'),
/// 会话汇总事件(客户端自动)
appSessionSummary(event: 'app_session_summary'),
/// 用户注册事件(服务端)
userRegister(event: 'user_register'),
/// 用户登录事件(服务端)
userLogin(event: 'user_login'),
/// 实时在线人数(服务端)
realtimeOnline(event: 'realtime_online'),
// ==================== 订单 & 金币 ====================
/// 订单创建事件(服务端)
orderCreated(event: 'order_created'),
/// 订单支付成功事件(服务端)
orderPaid(event: 'order_paid'),
/// 金币消耗事件(服务端)
coinConsume(event: 'coin_consume'),
/// 导航模块点击事件
navigation(event: 'navigation'),
/// 应用页面展示
appPageView(event: 'app_page_view'),
/// 应用页面点击
pageClick(event: 'page_click'),
/// 页面内元素曝光事件(客户端)
appPageItemExposure(event: 'app_pageitem_exposure'),
/// 页面内元素点击事件(客户端)
appPageItemClick(event: 'app_pageitem_click'),
/// 广告事件
advertising(event: 'advertising'),
/// 广告点击事件
adClick(event: 'ad_click'),
/// 页面存活
pageLifecycle(event: 'page_lifecycle'),
/// 视频事件
videoEvent(event: 'video_event'),
/// 广告展示
adImpression(event: 'ad_impression'),
// ==================== 广告 SDK 生命周期事件 ====================
/// 广告 SDK 初始化
adSdkInit(event: 'ad_sdk_init'),
/// 广告请求
adRequest(event: 'ad_request'),
/// 广告填充成功
adFill(event: 'ad_fill'),
/// 广告无填充
adNoFill(event: 'ad_no_fill'),
/// 广告素材加载成功
adLoadSuccess(event: 'ad_load_success'),
/// 广告素材加载失败。
@Deprecated('ad_load_fail 已并入 ad_error,使用 MATERIAL_LOAD_FAIL 错误码。')
adLoadFail(event: 'ad_load_fail'),
/// 广告 SDK 曝光
adImpressionSdk(event: 'ad_impression_sdk'),
/// 广告 SDK 点击
adClickSdk(event: 'ad_click_sdk'),
/// 广告关闭
adClose(event: 'ad_close'),
/// 广告跳过
adSkip(event: 'ad_skip'),
/// 广告错误
adError(event: 'ad_error'),
/// 视频点赞事件
videoLike(event: 'video_like'),
/// 小说事件
novelEvent(event: 'novel_event'),
/// 小说点赞事件
novelLike(event: 'novel_like'),
/// 漫画事件
comicEvent(event: 'comic_event'),
/// 漫画点赞事件
comicLike(event: 'comic_like'),
/// 关键词搜索事件
keywordSearch(event: 'keyword_search'),
/// 关键词搜索结果点击事件
keywordClick(event: 'keyword_click'),
/// 小说收藏事件
novelCollect(event: 'novel_collect'),
/// 漫画收藏事件
comicCollect(event: 'comic_collect'),
// ==================== 推荐引擎相关 ====================
/// 推荐列表展示事件(服务端/客户端)
recommendListView(event: 'recommend_list_view'),
/// 推荐列表点击事件(服务端/客户端)
recommendListClick(event: 'recommend_list_click'),
// ==================== 视频相关扩展事件 ====================
/// 视频评论事件(服务端/客户端)
videoComment(event: 'video_comment'),
/// 视频收藏事件(服务端/客户端)
videoCollect(event: 'video_collect'),
/// 视频购买事件(服务端/客户端)
videoPurchase(event: 'video_purchase'),
/// 视频状态更改事件(服务端)
videoStatusChange(event: 'video_status_change'),
// ==================== 小说相关扩展事件 ====================
/// 小说评论事件(服务端/客户端)
novelComment(event: 'novel_comment'),
/// 小说购买事件(服务端/客户端)
novelPurchase(event: 'novel_purchase'),
// ==================== 漫画相关扩展事件 ====================
/// 漫画评论事件(服务端/客户端)
comicComment(event: 'comic_comment'),
/// 漫画购买事件(服务端/客户端)
comicPurchase(event: 'comic_purchase'),
;
/// 事件类型字符串
final String event;
const EventType({required this.event});
/// 静态方法:根据 event 字符串获取枚举值(如果存在)
static EventType? fromEvent(String event) {
try {
return EventType.values.firstWhere((e) => e.event == event);
} catch (_) {
return null; // 或 throw ArgumentError('Unknown event: $event')
}
}
}
@@ -0,0 +1,79 @@
import 'package:analytics_sdk/manager/page_name_manager.dart';
import 'package:analytics_sdk/const/event_type.dart';
import 'package:analytics_sdk/manager/sdk_context.dart';
/// 广告点击日志 (手动上报)
class AdClickEvent {
/// 事件类型(固定)
final String event = EventType.adClick.event;
/// 去重 ID(动态计算)
late final String eventId;
/// 页面唯一标识,建议使用 [AppPageEnum](如 `AppPageEnum.videoPlay.key`),
/// 也可传入自定义字符串。
final String pageKey;
/// 页面展示名称,建议与 [pageKey] 保持对应(如 `AppPageEnum.videoPlay.name`)。
final String pageName;
/// 广告位标识:与展示事件一致,如 home_banner_1
final String adSlotKey;
/// 被点击的广告ID
final String adId;
/// 素材ID(可选)
final String creativeId;
/// 广告类型字符串,由广告中心定义,如 banner, feed 等
final String adType;
/// 公共字段(通过工具类生成)
late final Map<String, dynamic> _commonFields;
AdClickEvent({
required String pageKey,
required this.pageName,
required this.adSlotKey,
required this.adId,
this.creativeId = '',
this.adType = '',
}) : pageKey = PageNameMapper.normalizeKey(pageKey) {
_commonFields = SdkContext.generateCommonFields(event);
eventId = SdkContext.generateEventIdFromCommonFields(_commonFields, [
pageKey,
pageName,
adSlotKey,
adId,
adType,
creativeId,
]);
}
/// 转换为 JSON Map,用于上报和缓存
Map<String, dynamic> toJson() {
Map<String, dynamic> payload = {
'page_key': pageKey,
'page_name': pageName,
'ad_slot_key': adSlotKey,
'ad_id': adId,
'ad_type': adType,
'creative_id': creativeId,
};
return {..._commonFields, 'event_id': eventId, "payload": payload};
}
/// 从 JSON 恢复实例(用于缓存恢复)
factory AdClickEvent.fromJson(Map<String, dynamic> json) {
return AdClickEvent(
pageKey: json['page_key'] as String,
pageName: json['page_name'] as String,
adSlotKey: json['ad_slot_key'] as String,
adId: json['ad_id'] as String,
creativeId: json['creative_id'] as String? ?? '',
adType: json['ad_type'] as String? ?? '',
);
}
}
@@ -0,0 +1,97 @@
import 'package:analytics_sdk/manager/page_name_manager.dart';
import 'package:analytics_sdk/const/event_type.dart';
import 'package:analytics_sdk/manager/sdk_context.dart';
/// 广告展示日志 (手动)
/// 展示上报逻辑:
/// 1. 进入 APP → TabA
/// - 20 条曝光 → 上报 20 条(批量)
/// 2. 切换到TabB
/// - 20 条曝光 → 上报 20 条
/// 3. 返回TabA → 上滑加载 10 条
/// - 新 10 条渲染出来 → 上报 10 条
/// 4. 用户再次上下滑动TabA
/// - 所有广告已曝光,无需再上报
class AdImpressionEvent {
final String event = EventType.adImpression.event;
late final String eventId;
/// 页面唯一标识,建议使用 [AppPageEnum](如 `AppPageEnum.home.key`),
/// 也可传入自定义字符串。
final String pageKey;
/// 页面展示名称,建议与 [pageKey] 保持对应(如 `AppPageEnum.home.name`)。
final String pageName;
/// 广告位标识:如 home_banner_1等 对应广告导入数据的advertiseLocationCode
final String adSlotKey;
/// 本次广告请求唯一 ID。
final String requestId;
/// 广告ID, 多个广告ID英文逗号分隔 对应广告导入数据的advertiseCode
final String adId;
/// 实际素材尺寸,格式如 "1080x1920"。
final String materialSize;
/// 素材ID(可选)多个素材ID英文逗号分隔,和广告ID一一对应,没有的填充空字符串
final String creativeId;
/// 广告类型字符串,如 banner, feed, interstitial, reward_video 等,由广告中心定义
final String adType;
late final Map<String, dynamic> _commonFields;
AdImpressionEvent({
required String pageKey,
required this.pageName,
required this.adSlotKey,
this.requestId = '',
required this.adId,
this.materialSize = '',
this.creativeId = '',
this.adType = '',
}) : pageKey = PageNameMapper.normalizeKey(pageKey) {
_commonFields = SdkContext.generateCommonFields(event);
eventId = SdkContext.generateEventIdFromCommonFields(_commonFields, [
pageKey,
pageName,
adSlotKey,
requestId,
adId,
adType,
materialSize,
creativeId,
]);
}
Map<String, dynamic> toJson() {
Map<String, dynamic> payload = {
'page_key': pageKey,
'page_name': pageName,
'request_id': requestId,
'ad_slot_key': adSlotKey,
'ad_id': adId,
'ad_type': adType,
'material_size': materialSize,
'creative_id': creativeId,
};
return {..._commonFields, 'event_id': eventId, "payload": payload};
}
factory AdImpressionEvent.fromJson(Map<String, dynamic> json) {
return AdImpressionEvent(
pageKey: json['page_key'] as String,
pageName: json['page_name'] as String,
adSlotKey: json['ad_slot_key'] as String,
requestId: json['request_id'] as String? ?? '',
adId: json['ad_id'] as String,
materialSize: json['material_size'] as String? ?? '',
creativeId: json['creative_id'] as String? ?? '',
adType: json['ad_type'] as String? ?? '',
);
}
}
+459
View File
@@ -0,0 +1,459 @@
import 'package:analytics_sdk/const/event_type.dart';
import 'package:analytics_sdk/manager/sdk_context.dart';
abstract class _AdSdkEvent {
final String event;
late final String eventId;
late final Map<String, dynamic> _commonFields;
_AdSdkEvent(this.event, List<String> eventIdFields) {
_commonFields = SdkContext.generateCommonFields(event);
eventId = SdkContext.generateEventIdFromCommonFields(
_commonFields,
eventIdFields,
);
}
Map<String, dynamic> buildPayload();
Map<String, dynamic> toJson() {
return {
..._commonFields,
'event_id': eventId,
'payload': buildPayload(),
};
}
}
/// 广告 SDK 初始化事件。
class AdSdkInitEvent extends _AdSdkEvent {
final int screenWidth;
final int screenHeight;
final int screenDpi;
final String orientation;
final String networkType;
final String carrier;
final String language;
final String timezone;
final String oaid;
final String androidId;
final String idfv;
final String idfa;
final bool lat;
final String adSdkVersion;
final int availableStorage;
final int batteryLevel;
final String cpu;
final int storage;
final String activationTime;
AdSdkInitEvent({
this.screenWidth = 0,
this.screenHeight = 0,
this.screenDpi = 0,
this.orientation = '',
this.networkType = '',
this.carrier = '',
this.language = '',
this.timezone = '',
this.oaid = '',
this.androidId = '',
this.idfv = '',
this.idfa = '',
this.lat = false,
required this.adSdkVersion,
this.availableStorage = 0,
this.batteryLevel = 0,
this.cpu = '',
this.storage = 0,
this.activationTime = '',
}) : super(EventType.adSdkInit.event, [
screenWidth.toString(),
screenHeight.toString(),
screenDpi.toString(),
orientation,
networkType,
carrier,
language,
timezone,
oaid,
androidId,
idfv,
idfa,
lat.toString(),
adSdkVersion,
availableStorage.toString(),
batteryLevel.toString(),
cpu,
storage.toString(),
activationTime,
]);
@override
Map<String, dynamic> buildPayload() => {
'screen_width': screenWidth,
'screen_height': screenHeight,
'screen_dpi': screenDpi,
'orientation': orientation,
'network_type': networkType,
'carrier': carrier,
'language': language,
'timezone': timezone,
'oaid': oaid,
'android_id': androidId,
'idfv': idfv,
'idfa': idfa,
'lat': lat,
'ad_sdk_version': adSdkVersion,
'available_storage': availableStorage,
'battery_level': batteryLevel,
'cpu': cpu,
'storage': storage,
'activation_time': activationTime,
};
}
/// 广告请求事件。
class AdRequestEvent extends _AdSdkEvent {
final String requestId;
final String adSlotKey;
final String adType;
final String? adId;
AdRequestEvent({
required this.requestId,
required this.adSlotKey,
this.adType = '',
this.adId,
}) : super(EventType.adRequest.event, [
requestId,
adSlotKey,
adType,
adId ?? '',
]);
@override
Map<String, dynamic> buildPayload() => {
'request_id': requestId,
'ad_slot_key': adSlotKey,
'ad_type': adType,
if (adId?.trim().isNotEmpty ?? false) 'ad_id': adId!.trim(),
};
}
/// 广告填充成功事件。
class AdFillEvent extends _AdSdkEvent {
final String traceId;
final String requestId;
final String adSlotKey;
final String adId;
final String adType;
final int fillCount;
AdFillEvent({
this.traceId = '',
required this.requestId,
required this.adSlotKey,
required this.adId,
required this.adType,
required this.fillCount,
}) : super(EventType.adFill.event, [
if (traceId.isNotEmpty) traceId,
requestId,
adSlotKey,
adId,
adType,
fillCount.toString(),
]);
@override
Map<String, dynamic> buildPayload() => {
'trace_id': traceId,
'request_id': requestId,
'ad_slot_key': adSlotKey,
'ad_id': adId,
'ad_type': adType,
'fill_count': fillCount,
};
}
/// 广告无填充事件。
class AdNoFillEvent extends _AdSdkEvent {
final String requestId;
final String adSlotKey;
final String adType;
final String reason;
AdNoFillEvent({
required this.requestId,
required this.adSlotKey,
required this.adType,
required this.reason,
}) : super(EventType.adNoFill.event, [requestId, adSlotKey, adType, reason]);
@override
Map<String, dynamic> buildPayload() => {
'request_id': requestId,
'ad_slot_key': adSlotKey,
'ad_type': adType,
'reason': reason,
};
}
/// 广告素材加载成功事件。
class AdLoadSuccessEvent extends _AdSdkEvent {
final String traceId;
final String requestId;
final String adSlotKey;
final String adId;
final String adType;
final int loadDuration;
AdLoadSuccessEvent({
this.traceId = '',
required this.requestId,
required this.adSlotKey,
required this.adId,
required this.adType,
required this.loadDuration,
}) : super(EventType.adLoadSuccess.event, [
if (traceId.isNotEmpty) traceId,
requestId,
adSlotKey,
adId,
adType,
loadDuration.toString(),
]);
@override
Map<String, dynamic> buildPayload() => {
'trace_id': traceId,
'request_id': requestId,
'ad_slot_key': adSlotKey,
'ad_id': adId,
'ad_type': adType,
'load_duration': loadDuration,
};
}
/// 广告素材加载失败事件。
@Deprecated('ad_load_fail 已并入 ad_error,使用 MATERIAL_LOAD_FAIL 错误码。')
class AdLoadFailEvent extends _AdSdkEvent {
final String traceId;
final String requestId;
final String adSlotKey;
final String adId;
final String adType;
final String errorCode;
AdLoadFailEvent({
this.traceId = '',
required this.requestId,
required this.adSlotKey,
required this.adId,
required this.adType,
required this.errorCode,
}) : super(EventType.adLoadFail.event, [
if (traceId.isNotEmpty) traceId,
requestId,
adSlotKey,
adId,
adType,
errorCode,
]);
@override
Map<String, dynamic> buildPayload() => {
'trace_id': traceId,
'request_id': requestId,
'ad_slot_key': adSlotKey,
'ad_id': adId,
'ad_type': adType,
'error_code': errorCode,
};
}
/// 广告 SDK 曝光事件。
class AdImpressionSdkEvent extends _AdSdkEvent {
final String traceId;
final String requestId;
final String adSlotKey;
final String adId;
final String adType;
final String materialSize;
AdImpressionSdkEvent({
this.traceId = '',
required this.requestId,
required this.adSlotKey,
required this.adId,
required this.adType,
required this.materialSize,
}) : super(EventType.adImpressionSdk.event, [
traceId,
requestId,
adSlotKey,
adId,
adType,
materialSize,
]);
@override
Map<String, dynamic> buildPayload() => {
'trace_id': traceId,
'request_id': requestId,
'ad_slot_key': adSlotKey,
'ad_id': adId,
'ad_type': adType,
'material_size': materialSize,
};
}
/// 广告 SDK 点击事件。
class AdClickSdkEvent extends _AdSdkEvent {
final String traceId;
final String requestId;
final String adSlotKey;
final String adId;
final String adType;
AdClickSdkEvent({
this.traceId = '',
required this.requestId,
required this.adSlotKey,
required this.adId,
required this.adType,
}) : super(EventType.adClickSdk.event, [
traceId,
requestId,
adSlotKey,
adId,
adType,
]);
@override
Map<String, dynamic> buildPayload() => {
'trace_id': traceId,
'request_id': requestId,
'ad_slot_key': adSlotKey,
'ad_id': adId,
'ad_type': adType,
};
}
/// 广告关闭事件。
class AdCloseEvent extends _AdSdkEvent {
final String traceId;
final String requestId;
final String adSlotKey;
final String adId;
final String adType;
final String closeType;
final int shownDuration;
AdCloseEvent({
this.traceId = '',
required this.requestId,
required this.adSlotKey,
required this.adId,
required this.adType,
required this.closeType,
this.shownDuration = 0,
}) : super(EventType.adClose.event, [
if (traceId.isNotEmpty) traceId,
requestId,
adSlotKey,
adId,
adType,
closeType,
shownDuration.toString(),
]);
@override
Map<String, dynamic> buildPayload() => {
'trace_id': traceId,
'request_id': requestId,
'ad_slot_key': adSlotKey,
'ad_id': adId,
'ad_type': adType,
'close_type': closeType,
'shown_duration': shownDuration,
};
}
/// 广告跳过事件。
class AdSkipEvent extends _AdSdkEvent {
final String traceId;
final String requestId;
final String adSlotKey;
final String adId;
final String adType;
final int shownDuration;
AdSkipEvent({
this.traceId = '',
required this.requestId,
required this.adSlotKey,
required this.adId,
required this.adType,
this.shownDuration = 0,
}) : super(EventType.adSkip.event, [
if (traceId.isNotEmpty) traceId,
requestId,
adSlotKey,
adId,
adType,
shownDuration.toString(),
]);
@override
Map<String, dynamic> buildPayload() => {
'trace_id': traceId,
'request_id': requestId,
'ad_slot_key': adSlotKey,
'ad_id': adId,
'ad_type': adType,
'shown_duration': shownDuration,
};
}
/// 广告错误事件。
class AdErrorEvent extends _AdSdkEvent {
final String traceId;
final String requestId;
final String adSlotKey;
final String adType;
final String errorCode;
final String errorMsg;
final String errorStage;
AdErrorEvent({
this.traceId = '',
this.requestId = '',
required this.adSlotKey,
required this.adType,
required this.errorCode,
required this.errorMsg,
required this.errorStage,
}) : super(EventType.adError.event, [
if (traceId.isNotEmpty) traceId,
requestId,
adSlotKey,
adType,
errorCode,
errorMsg,
errorStage,
]);
@override
Map<String, dynamic> buildPayload() => {
'trace_id': traceId,
'request_id': requestId,
'ad_slot_key': adSlotKey,
'ad_type': adType,
'error_code': errorCode,
'error_msg': errorMsg,
'error_stage': errorStage,
};
}
@@ -0,0 +1,57 @@
import 'package:analytics_sdk/const/event_type.dart';
import 'package:analytics_sdk/manager/sdk_context.dart';
/// APP广告行为
class AdvertisingEvent {
final String event = EventType.advertising.event;
late final String eventId;
///事件类型:click(点击), close(关闭), show(展示)
final String eventType;
/// 广告标识:home_popup(首页弹窗), home_banner(首页Banner), video_reward(激励视频)等
final String advertisingKey;
/// 广告标识名称:首页弹窗, 首页Banner, 激励视频
final String advertisingName;
/// 广告ID
final String advertisingId;
late final Map<String, dynamic> _commonFields;
AdvertisingEvent({
required this.eventType,
required this.advertisingKey,
required this.advertisingName,
required this.advertisingId,
}) {
_commonFields = SdkContext.generateCommonFields(event);
eventId = SdkContext.generateEventIdFromCommonFields(_commonFields, [
eventType,
advertisingKey,
advertisingName,
advertisingId,
]);
}
Map<String, dynamic> toJson() {
Map<String, dynamic> payload = {
'event_type': eventType,
'advertising_key': advertisingKey,
'advertising_name': advertisingName,
'advertising_id': advertisingId,
};
return {..._commonFields, 'event_id': eventId, "payload": payload};
}
factory AdvertisingEvent.fromJson(Map<String, dynamic> json) {
return AdvertisingEvent(
eventType: json['event_type'] as String,
advertisingKey: json['advertising_key'] as String,
advertisingName: json['advertising_name'] as String,
advertisingId: json['advertising_id'] as String,
);
}
}
@@ -0,0 +1,24 @@
/// Tab 页面的 analytics 配置。
///
/// - [key]:pageKey(必填),建议使用 [AppPageEnum](如 `AppPageEnum.home.key`),
/// 也可传入自定义字符串。
/// - [name]:pageName(可选),省略时由 [PageNameMapper] 自动解析;
/// 传入时建议与 [AppPageEnum] 保持一致(如 `AppPageEnum.home.name`)。
///
/// ```dart
/// // 使用枚举
/// AnalyticsTab(AppPageEnum.home.key, AppPageEnum.home.name)
/// AnalyticsTab(AppPageEnum.video.key, AppPageEnum.video.name)
///
/// // 使用自定义值
/// AnalyticsTab('live_room', '直播间')
///
/// // 省略 name(由 PageNameMapper 解析)
/// AnalyticsTab('home')
/// ```
class AnalyticsTab {
final String key;
final String? name;
const AnalyticsTab(this.key, [this.name]);
}
@@ -0,0 +1,5 @@
import 'package:analytics_sdk/manager/sdk_context.dart';
export 'package:analytics_sdk/manager/sdk_context.dart';
// SdkContext 的向后兼容别名,内部使用。
typedef AnalyticsUtils = SdkContext;
@@ -0,0 +1,46 @@
import 'package:analytics_sdk/const/event_type.dart';
import 'package:analytics_sdk/manager/sdk_context.dart';
/// App 安装事件(客户端)
class AppInstallEvent {
/// 事件类型(固定)
final String event = EventType.appInstall.event;
/// 去重 ID(动态计算)
late final String eventId;
/// 落地页点击生成唯一ID, 复制到剪切板, 打开APP后读取剪切板
final String traceId;
/// 原生包是否开启归因:1 开启,0 关闭(默认)
final int attributionFlag;
late final Map<String, dynamic> _commonFields;
AppInstallEvent({
required this.traceId,
this.attributionFlag = 0,
}) {
_commonFields = SdkContext.generateCommonFields(event);
eventId = SdkContext.generateEventIdFromCommonFields(_commonFields, [
traceId,
attributionFlag.toString(),
]);
}
Map<String, dynamic> toJson() {
final payload = <String, dynamic>{
'trace_id': traceId,
'attribution_flag': attributionFlag,
};
return {..._commonFields, 'event_id': eventId, 'payload': payload};
}
factory AppInstallEvent.fromJson(Map<String, dynamic> json) {
return AppInstallEvent(
traceId: json['trace_id'] as String,
attributionFlag: (json['attribution_flag'] as int?) ?? 0,
);
}
}
@@ -0,0 +1,189 @@
import 'dart:convert';
import 'package:analytics_sdk/const/event_type.dart';
import 'package:analytics_sdk/manager/sdk_context.dart';
/// 应用生命周期事件。
///
/// `app_lifecycle` 的子事件字段写入 `payload`。每个工厂方法只写入该
/// 子事件文档定义的字段。
class AppLifecycleEvent {
final String event = EventType.appLifecycle.event;
final String lifecycleEventKey;
final Map<String, dynamic> _fields;
late final String eventId;
late final Map<String, dynamic> _commonFields;
AppLifecycleEvent._(
this.lifecycleEventKey,
Map<String, dynamic> fields,
) : _fields = Map<String, dynamic>.from(fields)
..removeWhere((_, value) => value == null) {
_commonFields = SdkContext.generateCommonFields(event);
eventId = SdkContext.generateEventIdFromCommonFields(_commonFields, [
lifecycleEventKey,
jsonEncode(_stableFields),
]);
}
factory AppLifecycleEvent.coldStart({
required int coldStartDurationMs,
String lastExitType = 'unknown',
int bootTime = 0,
}) =>
AppLifecycleEvent._('cold_start', {
'cold_start_duration_ms': _nonNegative(coldStartDurationMs),
'last_exit_type': _allowedString(
lastExitType,
const {'normal', 'killed', 'crash', 'unknown'},
'unknown',
),
'boot_time': bootTime > 0 ? bootTime : 0,
});
factory AppLifecycleEvent.webLoad({
required String pageUrl,
required int pageLoadTimeMs,
required int viewportWidth,
required int viewportHeight,
}) =>
AppLifecycleEvent._('web_load', {
'page_url': pageUrl,
'page_load_time_ms': _nonNegative(pageLoadTimeMs),
'viewport_width': viewportWidth,
'viewport_height': viewportHeight,
});
factory AppLifecycleEvent.warmStart({
required int backgroundDurationMs,
String resumeReason = 'unknown',
}) =>
AppLifecycleEvent._('warm_start', {
'background_duration_ms': _nonNegative(backgroundDurationMs),
'resume_reason': _allowedString(
resumeReason,
const {'user', 'push', 'scheme', 'unknown'},
'unknown',
),
});
factory AppLifecycleEvent.foreground({
required int foregroundCountInSid,
}) =>
AppLifecycleEvent._('foreground', {
'foreground_count_in_sid':
foregroundCountInSid < 1 ? 1 : foregroundCountInSid,
'is_foreground': 1,
});
factory AppLifecycleEvent.background({
required int foregroundDurationMs,
required String lastActivePageKey,
}) =>
AppLifecycleEvent._('background', {
'is_foreground': 0,
'foreground_duration_ms': _nonNegative(foregroundDurationMs),
'last_active_page_key': lastActivePageKey,
});
factory AppLifecycleEvent.webUnload({
required String pageUrl,
required String lastActivePageKey,
required int pageStayDurationMs,
String unloadReason = 'unknown',
required int scrollDepthPercentAtUnload,
required String webVisibilityState,
}) =>
AppLifecycleEvent._('web_unload', {
'page_url': pageUrl,
'last_active_page_key': lastActivePageKey,
'page_stay_duration_ms': _nonNegative(pageStayDurationMs),
'unload_reason': _allowedString(
unloadReason,
const {
'normal',
'visibility_hidden',
'navigation',
'tab_close',
'unknown',
},
'unknown',
),
'scroll_depth_percent_at_unload':
scrollDepthPercentAtUnload.clamp(0, 100),
'web_visibility_state': _allowedString(
webVisibilityState,
const {'visible', 'hidden', 'unknown'},
'unknown',
),
});
factory AppLifecycleEvent.terminate({
required String lastActivePageKey,
String terminateType = 'unknown',
required int sessionDurationMs,
required int terminateTs,
}) =>
AppLifecycleEvent._('terminate', {
'last_active_page_key': lastActivePageKey,
'terminate_type': _allowedString(
terminateType,
const {'user_quit', 'system_kill', 'app_finish', 'unknown'},
'unknown',
),
'session_duration_ms': _nonNegative(sessionDurationMs),
'terminate_ts': terminateTs > 0
? terminateTs
: DateTime.now().millisecondsSinceEpoch ~/ 1000,
});
factory AppLifecycleEvent.unknown({
String unknownReason = 'unknown',
}) =>
AppLifecycleEvent._('unknown', {
'unknown_reason': _allowedString(
unknownReason,
const {
'callback_missing',
'state_conflict',
'unsupported_platform',
'sdk_not_ready',
'system_restricted',
'route_unrecognized',
'collect_error',
'unknown',
},
'unknown',
),
});
Map<String, dynamic> toJson() {
return {
..._commonFields,
'event_id': eventId,
'payload': {
'lifecycle_event_key': lifecycleEventKey,
..._fields,
},
};
}
Map<String, dynamic> get _stableFields {
final sortedKeys = _fields.keys.toList()..sort();
return {
for (final key in sortedKeys) key: _fields[key],
};
}
static int _nonNegative(int value) => value < 0 ? 0 : value;
static String _allowedString(
String value,
Set<String> allowed,
String fallback,
) {
final normalized = value.trim();
return allowed.contains(normalized) ? normalized : fallback;
}
}
@@ -0,0 +1,217 @@
import 'package:analytics_sdk/const/event_type.dart';
import 'package:analytics_sdk/manager/page_name_manager.dart';
import 'package:analytics_sdk/manager/sdk_context.dart';
/// 页面内元素曝光事件(客户端手动上报)。
///
/// 仅限需要采集页面内元素曝光的项目接入。未对接推荐引擎时,推荐相关字段传空字符串。
class AppPageItemExposureEvent {
final String event = EventType.appPageItemExposure.event;
late final String eventId;
/// 元素唯一 ID。
final String itemId;
/// 元素类型,如推荐、关注、评论、小说、视频、漫画等业务字典值。
final String itemType;
/// 元素名称,如推荐、关注、评论、小说、视频、漫画等业务字典值。
final String itemName;
/// 父元素 ID。
final String parentItemId;
/// 当前页面标识,建议使用 [AppPageEnum] 或业务字典中的稳定 key。
final String pageKey;
/// 当前页面名称。
final String pageName;
/// 来路页面标识。
final String referrerPageKey;
/// 来路页面名称。
final String referrerPageName;
/// 推荐引擎 trace_id;多个推荐列表用英文逗号分隔。
final String recommendTraceId;
/// 推荐内容 ID;未对接推荐引擎传空字符串。
final String recommendId;
/// 推荐引擎 trace_info;未对接推荐引擎传空字符串。
final String recommendTraceInfo;
late final Map<String, dynamic> _commonFields;
AppPageItemExposureEvent({
required this.itemId,
required this.itemType,
required this.itemName,
required this.parentItemId,
required String pageKey,
required this.pageName,
required String referrerPageKey,
required this.referrerPageName,
this.recommendTraceId = '',
this.recommendId = '',
this.recommendTraceInfo = '',
}) : pageKey = PageNameMapper.normalizeKey(pageKey),
referrerPageKey = PageNameMapper.normalizeKey(referrerPageKey) {
_commonFields = SdkContext.generateCommonFields(event);
eventId = SdkContext.generateEventIdFromCommonFields(_commonFields, [
itemId,
itemType,
itemName,
parentItemId,
this.pageKey,
pageName,
this.referrerPageKey,
referrerPageName,
recommendTraceId,
recommendId,
recommendTraceInfo,
]);
}
Map<String, dynamic> toJson() {
final payload = <String, dynamic>{
'item_id': itemId,
'item_type': itemType,
'item_name': itemName,
'parent_item_id': parentItemId,
'page_key': pageKey,
'page_name': pageName,
'referrer_page_key': referrerPageKey,
'referrer_page_name': referrerPageName,
'recommend_trace_id': recommendTraceId,
'recommend_id': recommendId,
'recommend_trace_info': recommendTraceInfo,
};
return {..._commonFields, 'event_id': eventId, 'payload': payload};
}
factory AppPageItemExposureEvent.fromJson(Map<String, dynamic> json) {
final source = _payloadOrSelf(json);
return AppPageItemExposureEvent(
itemId: source['item_id'] as String,
itemType: source['item_type'] as String,
itemName: source['item_name'] as String,
parentItemId: source['parent_item_id'] as String,
pageKey: source['page_key'] as String,
pageName: source['page_name'] as String,
referrerPageKey: source['referrer_page_key'] as String,
referrerPageName: source['referrer_page_name'] as String,
recommendTraceId: source['recommend_trace_id'] as String? ?? '',
recommendId: source['recommend_id'] as String? ?? '',
recommendTraceInfo: source['recommend_trace_info'] as String? ?? '',
);
}
}
/// 页面内元素点击事件(客户端手动上报)。
///
/// 仅限需要采集页面内元素点击的项目接入。未对接推荐引擎时,推荐相关字段传空字符串。
class AppPageItemClickEvent {
final String event = EventType.appPageItemClick.event;
late final String eventId;
/// 元素唯一 ID。
final String itemId;
/// 点击元素类型,如推荐、关注、评论、小说、视频、漫画等业务字典值。
final String itemType;
/// 点击元素名称,如推荐、关注、评论、小说、视频、漫画等业务字典值。
final String itemName;
/// 父元素 ID。
final String parentItemId;
/// 在父元素中的顺序索引(从 0 或 1 开始,按业务约定传入)。
final int position;
/// 当前页面标识,建议使用 [AppPageEnum] 或业务字典中的稳定 key。
final String pageKey;
/// 当前页面名称。
final String pageName;
/// 推荐引擎 trace_id;多个推荐列表用英文逗号分隔。
final String recommendTraceId;
/// 推荐内容 ID;未对接推荐引擎传空字符串。
final String recommendId;
/// 推荐引擎 trace_info;未对接推荐引擎传空字符串。
final String recommendTraceInfo;
late final Map<String, dynamic> _commonFields;
AppPageItemClickEvent({
required this.itemId,
required this.itemType,
required this.itemName,
required this.parentItemId,
required this.position,
required String pageKey,
required this.pageName,
this.recommendTraceId = '',
this.recommendId = '',
this.recommendTraceInfo = '',
}) : pageKey = PageNameMapper.normalizeKey(pageKey) {
_commonFields = SdkContext.generateCommonFields(event);
eventId = SdkContext.generateEventIdFromCommonFields(_commonFields, [
itemId,
itemType,
itemName,
parentItemId,
position.toString(),
this.pageKey,
pageName,
recommendTraceId,
recommendId,
recommendTraceInfo,
]);
}
Map<String, dynamic> toJson() {
final payload = <String, dynamic>{
'item_id': itemId,
'item_type': itemType,
'item_name': itemName,
'parent_item_id': parentItemId,
'position': position,
'page_key': pageKey,
'page_name': pageName,
'recommend_trace_id': recommendTraceId,
'recommend_id': recommendId,
'recommend_trace_info': recommendTraceInfo,
};
return {..._commonFields, 'event_id': eventId, 'payload': payload};
}
factory AppPageItemClickEvent.fromJson(Map<String, dynamic> json) {
final source = _payloadOrSelf(json);
return AppPageItemClickEvent(
itemId: source['item_id'] as String,
itemType: source['item_type'] as String,
itemName: source['item_name'] as String,
parentItemId: source['parent_item_id'] as String,
position: source['position'] as int,
pageKey: source['page_key'] as String,
pageName: source['page_name'] as String,
recommendTraceId: source['recommend_trace_id'] as String? ?? '',
recommendId: source['recommend_id'] as String? ?? '',
recommendTraceInfo: source['recommend_trace_info'] as String? ?? '',
);
}
}
Map<String, dynamic> _payloadOrSelf(Map<String, dynamic> json) {
final payload = json['payload'];
if (payload is Map) return Map<String, dynamic>.from(payload);
return json;
}
@@ -0,0 +1,181 @@
import 'dart:ui' show window;
import 'package:analytics_sdk/const/event_type.dart';
import 'package:analytics_sdk/manager/page_view_auto_fields.dart';
import 'package:analytics_sdk/manager/sdk_context.dart';
import 'package:analytics_sdk/manager/page_name_manager.dart';
import 'package:analytics_sdk/utils/uuid_util.dart';
/// 应用页面展示日志(由 [PageLifecycleObserver] 和 Tab 包装器自动上报)。
///
/// **page_key / page_name 填写规范**
///
/// 建议使用 [AppPageEnum] 预定义值,也可直接传入自定义字符串:
/// ```dart
/// AppPageViewEvent(
/// pageKey: AppPageEnum.videoDetail.key, // 'video_detail'
/// pageName: AppPageEnum.videoDetail.name, // '视频详情页'
/// ...
/// )
/// ```
class AppPageViewEvent {
final String event = EventType.appPageView.event;
late final String eventId;
final String userType;
/// 当前页面标识,建议使用 [AppPageEnum](如 `AppPageEnum.videoDetail.key`),
/// 也可传入自定义字符串。
final String pageKey;
/// 当前页面展示名称,建议与 [pageKey] 保持对应(如 `AppPageEnum.videoDetail.name`)。
final String pageName;
/// 来路页面标识,填写跳转前所在页面的 pageKey,规范同 [pageKey]。
final String referrerPageKey;
/// 来路页面展示名称,规范同 [pageName]。
final String referrerPageName;
/// 当前页面标识(与 [pageKey] 通常保持一致,保留用于扩展场景)。
final String currentPageKey;
/// 当前页面展示名称(与 [pageName] 通常保持一致)。
final String currentPageName;
/// 页面加载耗时(毫秒)
final int pageLoadTime;
/// 当前页面实例唯一 ID,由 SDK 自动生成。
final String pageId;
/// SDK 首次识别当前页面实例的时间,10 位时间戳。
final int pageEnterTs;
/// Web/H5 当前页面物理地址,仅包含 scheme + host + path。
final String pageUrl;
/// 当前页面根容器可交互可视区域宽度。
final int viewportWidth;
/// 当前页面根容器可交互可视区域高度。
final int viewportHeight;
/// 推荐引擎的trace_id,有多个推荐列表时用英文逗号分隔,未接推荐引擎传空字符串
final String recommendTraceId;
late final Map<String, dynamic> _commonFields;
AppPageViewEvent({
required this.userType,
required String pageKey,
required this.pageName,
required String referrerPageKey,
required this.referrerPageName,
required String currentPageKey,
required this.currentPageName,
required this.pageLoadTime,
String? pageId,
int? pageEnterTs,
String? pageUrl,
int? viewportWidth,
int? viewportHeight,
this.recommendTraceId = '',
}) : pageKey = PageNameMapper.normalizeKey(pageKey),
referrerPageKey = PageNameMapper.normalizeKey(referrerPageKey),
currentPageKey = PageNameMapper.normalizeKey(currentPageKey),
pageId = _pageId(pageId),
pageEnterTs = _pageEnterTs(pageEnterTs),
pageUrl = pageUrl ?? currentPageUrl(),
viewportWidth = _viewportDimension(viewportWidth, isWidth: true),
viewportHeight = _viewportDimension(viewportHeight, isWidth: false) {
_commonFields = SdkContext.generateCommonFields(event);
eventId = SdkContext.generateEventIdFromCommonFields(_commonFields, [
this.pageId,
this.pageEnterTs.toString(),
this.pageUrl,
this.viewportWidth.toString(),
this.viewportHeight.toString(),
userType,
this.pageKey,
pageName,
this.referrerPageKey,
referrerPageName,
this.currentPageKey,
currentPageName,
pageLoadTime.toString(),
recommendTraceId,
]);
}
Map<String, dynamic> toJson() {
Map<String, dynamic> payload = {
'user_type': userType,
'page_key': pageKey,
'page_name': pageName,
'referrer_page_key': referrerPageKey,
'referrer_page_name': referrerPageName,
'current_page_key': currentPageKey,
'current_page_name': currentPageName,
'page_load_time': pageLoadTime,
'page_id': pageId,
'page_enter_ts': pageEnterTs,
'page_url': pageUrl,
'viewport_width': viewportWidth,
'viewport_height': viewportHeight,
'recommend_trace_id': recommendTraceId,
};
return {
..._commonFields,
'event_id': eventId,
"payload": payload,
};
}
factory AppPageViewEvent.fromJson(Map<String, dynamic> json) {
return AppPageViewEvent(
userType: json['user_type'] as String,
pageKey: json['page_key'] as String,
pageName: json['page_name'] as String,
referrerPageKey: json['referrer_page_key'] as String,
referrerPageName: json['referrer_page_name'] as String,
currentPageKey: json['current_page_key'] as String,
currentPageName: json['current_page_name'] as String,
pageLoadTime: json['page_load_time'] as int,
pageId: json['page_id'] as String?,
pageEnterTs: json['page_enter_ts'] as int?,
pageUrl: json['page_url'] as String?,
viewportWidth: json['viewport_width'] as int?,
viewportHeight: json['viewport_height'] as int?,
recommendTraceId: json['recommend_trace_id'] as String? ?? '',
);
}
static String _pageId(String? value) {
final normalized = value?.trim() ?? '';
if (normalized.isNotEmpty) return normalized;
return generateUuidV4().replaceAll('-', '');
}
static int _pageEnterTs(int? value) {
if (value != null && value > 0) return value;
return DateTime.now().millisecondsSinceEpoch ~/ 1000;
}
static int _viewportDimension(int? value, {required bool isWidth}) {
if (value != null) return value >= 0 ? value : -1;
try {
final pixelRatio = window.devicePixelRatio;
if (pixelRatio <= 0) return -1;
final physicalSize = window.physicalSize;
final logical = isWidth
? physicalSize.width / pixelRatio
: physicalSize.height / pixelRatio;
if (!logical.isFinite || logical <= 0) return -1;
return logical.round();
} catch (_) {
return -1;
}
}
}
@@ -0,0 +1,128 @@
import 'dart:convert';
import 'package:analytics_sdk/const/event_type.dart';
import 'package:analytics_sdk/manager/sdk_context.dart';
import 'package:analytics_sdk/manager/session_manager.dart';
import 'package:analytics_sdk/manager/session_behavior_manager.dart';
import 'package:analytics_sdk/manager/session_metrics_manager.dart';
import 'package:analytics_sdk/manager/session_sensor_sampler.dart';
/// 会话汇总事件。
///
/// 汇总字段写入 `payload`;签名类 payload 字段由
/// [SdkContext.attachDeviceEnvIfNeeded] 在 track 管道中统一补充。
class AppSessionSummaryEvent {
final String event = EventType.appSessionSummary.event;
final Map<String, dynamic> _payloadFields;
late final String eventId;
late final Map<String, dynamic> _commonFields;
AppSessionSummaryEvent({
required String sessionEndReason,
DateTime? endedAt,
}) : _payloadFields = _buildPayloadFields(
sessionEndReason: sessionEndReason,
endedAt: endedAt ?? DateTime.now(),
) {
_commonFields = SdkContext.generateCommonFields(event);
eventId = SdkContext.generateEventIdFromCommonFields(_commonFields, [
jsonEncode(_stablePayloadFields),
]);
}
Map<String, dynamic> toJson() {
return {
..._commonFields,
'event_id': eventId,
'payload': Map<String, dynamic>.from(_payloadFields),
};
}
Map<String, dynamic> get _stablePayloadFields {
final sortedKeys = _payloadFields.keys.toList()..sort();
return {
for (final key in sortedKeys) key: _payloadFields[key],
};
}
static Map<String, dynamic> _buildPayloadFields({
required String sessionEndReason,
required DateTime endedAt,
}) {
final metrics = SessionMetricsManager.instance.snapshot(endedAt: endedAt);
final behavior = SessionBehaviorManager.instance.snapshot();
final environment = SdkContext.getEnvironmentSnapshotFields();
final screenBrightness = _intField(environment['screen_brightness'], -1);
final sensor = SessionSensorSampler.instance.snapshot(
screenBrightness: screenBrightness,
);
final sessionStart = SessionManager.instance.sessionStartTime ?? endedAt;
final sessionStartTs = sessionStart.millisecondsSinceEpoch ~/ 1000;
final sessionEndTs = endedAt.millisecondsSinceEpoch ~/ 1000;
final durationMs = endedAt.difference(sessionStart).inMilliseconds;
return {
'session_start_ts': sessionStartTs,
'session_end_ts': sessionEndTs,
'session_duration_ms': durationMs < 0 ? 0 : durationMs,
'event_count_total': metrics.eventCountTotal,
'business_event_count': metrics.businessEventCount,
'foreground_count': metrics.foregroundCount,
'background_count_in_sid': metrics.backgroundCountInSid,
'page_count': metrics.pageCount,
'page_view_count': metrics.pageViewCount,
'page_click_count': metrics.pageClickCount,
'network_change_count': metrics.networkChangeCount,
'session_end_reason': _sessionEndReason(sessionEndReason),
'last_event_name': metrics.lastEventName,
'orientation': environment['orientation']?.toString() ?? '',
'screen_brightness': screenBrightness,
'volume': _intField(environment['volume'], -1),
'is_screen_on': _intField(environment['is_screen_on'], -1),
'battery_level': _intField(environment['battery_level'], -1),
'is_charging': _intField(environment['is_charging'], -1),
'battery_state': _stringField(environment['battery_state'], 'unknown'),
'sensor_signature': sensor.sensorSignature,
'sensor_count_active': sensor.sensorCountActive,
'scroll_total_count': behavior.scrollTotalCount,
'scroll_total_distance_px': behavior.scrollTotalDistancePx,
'scroll_up_count': behavior.scrollUpCount,
'scroll_down_count': behavior.scrollDownCount,
'scroll_direction_change_count': behavior.scrollDirectionChangeCount,
'first_scroll_delay_ms': behavior.firstScrollDelayMs,
'max_scroll_depth_percent': behavior.maxScrollDepthPercent,
'is_reach_bottom': behavior.isReachBottom,
'content_height': behavior.contentHeight,
'touch_total_count': behavior.touchTotalCount,
'swipe_total_count': behavior.swipeTotalCount,
'long_press_total_count': behavior.longPressTotalCount,
'pinch_total_count': behavior.pinchTotalCount,
'first_interaction_delay_ms': behavior.firstInteractionDelayMs,
'page_stay_list': metrics.pageStayList,
'behavior_sequence_hash': metrics.behaviorSequenceHash,
};
}
static int _intField(dynamic value, int fallback) {
if (value is int) return value;
if (value is num) return value.round();
return int.tryParse(value?.toString() ?? '') ?? fallback;
}
static String _stringField(dynamic value, String fallback) {
final text = value?.toString().trim() ?? '';
return text.isEmpty ? fallback : text;
}
static String _sessionEndReason(String value) {
final normalized = value.trim();
const allowed = {
'idle',
'app_exit',
'web_visibility_hidden',
'forced',
};
return allowed.contains(normalized) ? normalized : 'app_exit';
}
}
+130
View File
@@ -0,0 +1,130 @@
import 'package:analytics_sdk/const/event_type.dart';
import 'package:analytics_sdk/manager/sdk_context.dart';
import 'package:analytics_sdk/enum/read_behavior_enum.dart';
/// 漫画事件
class ComicEvent {
final String event = EventType.comicEvent.event;
late final String eventId;
/// 漫画 id
final String comicId;
/// 漫画标题
final String comicTitle;
/// 分类ID
final String comicTypeId;
/// 分类名称
final String comicTypeName;
/// 标签KEY, 多个标签使用英文逗号分隔
final String comicTagKey;
/// 标签名称,多个标签使用英文逗号分隔
final String comicTagName;
/// 推荐引擎的trace_id,未接推荐引擎传空字符串
final String recommendTraceId;
/// 阅读进度百分比(0-100)
final int readProgress;
/// 当前阅读第几页(从1开始)
final int pageNo;
/// 行为标识:view(展示), page_next(下一页), page_prev(上一页), complete(读完) 等
final ReadBehaviorEnum comicBehavior;
/// 章节ID,没有章节上报空字符串,没有对接搜索推荐引擎的上报空字符串
final String chapterId;
/// 章节名称,没有章节上报空字符串,没有对接搜索推荐引擎的上报空字符串
final String chapterName;
/// 老司机媒体资源ID,如未使用老司机库资源可传空字符串
final String mediaId;
late final Map<String, dynamic> _commonFields;
ComicEvent({
required this.comicId,
required this.comicTitle,
required this.comicTypeId,
required this.comicTypeName,
required this.comicTagKey,
required this.comicTagName,
required this.readProgress,
required this.pageNo,
required this.comicBehavior,
this.recommendTraceId = '',
this.mediaId = '',
this.chapterId = '',
this.chapterName = '',
}) {
_commonFields = SdkContext.generateCommonFields(event);
eventId = SdkContext.generateEventIdFromCommonFields(_commonFields, [
comicId,
comicTitle,
comicTypeId,
comicTypeName,
comicTagKey,
comicTagName,
readProgress.toString(),
pageNo.toString(),
comicBehavior.key,
comicBehavior.name,
recommendTraceId,
mediaId,
chapterId,
chapterName,
]);
}
Map<String, dynamic> toJson() {
Map<String, dynamic> payload = {
'comic_id': comicId,
'comic_title': comicTitle,
'comic_type_id': comicTypeId,
'comic_type_name': comicTypeName,
'comic_tag_key': comicTagKey,
'comic_tag_name': comicTagName,
'recommend_trace_id': recommendTraceId,
'read_progress': readProgress,
'page_no': pageNo,
'comic_behavior_key': comicBehavior.key,
'comic_behavior_name': comicBehavior.name,
'media_id': mediaId,
'chapter_id': chapterId,
'chapter_name': chapterName,
};
return {..._commonFields, 'event_id': eventId, "payload": payload};
}
factory ComicEvent.fromJson(Map<String, dynamic> json) {
final behaviorKey = json['comic_behavior_key'] as String? ?? '';
final behaviorName = json['comic_behavior_name'] as String? ?? '';
final behavior = ReadBehaviorEnum(
behaviorKey.isNotEmpty ? behaviorKey : ReadBehaviorEnum.VIEW.key,
behaviorName.isNotEmpty ? behaviorName : ReadBehaviorEnum.VIEW.name,
);
return ComicEvent(
comicId: json['comic_id'] as String,
comicTitle: json['comic_title'] as String,
comicTypeId: json['comic_type_id'] as String,
comicTypeName: json['comic_type_name'] as String,
comicTagKey: json['comic_tag_key'] as String,
comicTagName: json['comic_tag_name'] as String,
readProgress: json['read_progress'] as int,
pageNo: json['page_no'] as int,
comicBehavior: behavior,
recommendTraceId: json['recommend_trace_id'] as String? ?? '',
mediaId: json['media_id'] as String? ?? '',
chapterId: json['chapter_id'] as String? ?? '',
chapterName: json['chapter_name'] as String? ?? '',
);
}
}
@@ -0,0 +1,71 @@
import 'package:analytics_sdk/const/event_type.dart';
import 'package:analytics_sdk/manager/sdk_context.dart';
import 'package:analytics_sdk/enum/click_item_type_enum.dart';
/// 关键词搜索结果点击事件
class KeywordClickEvent {
final String event = EventType.keywordClick.event;
late final String eventId;
/// 关联关键词
final String keyword;
/// 点击项目ID(视频ID/小说ID等)
final String clickItemId;
/// 点击项目类型,可用预定义常量或自定义:
/// `ClickItemTypeEnum.video` / `ClickItemTypeEnum('manga', '漫画类')`
final ClickItemTypeEnum clickItemType;
/// 点击位置(搜索结果中的排序位置)
final int clickPosition;
/// 搜索引擎 trace ID,未接搜索引擎时传空字符串
final String searchTraceId;
late final Map<String, dynamic> _commonFields;
KeywordClickEvent({
required this.keyword,
required this.clickItemId,
required this.clickItemType,
required this.clickPosition,
this.searchTraceId = '',
}) {
_commonFields = SdkContext.generateCommonFields(event);
eventId = SdkContext.generateEventIdFromCommonFields(_commonFields, [
keyword,
clickItemId,
clickItemType.key,
clickItemType.name,
clickPosition.toString(),
searchTraceId,
]);
}
Map<String, dynamic> toJson() {
Map<String, dynamic> payload = {
'keyword': keyword,
'click_item_id': clickItemId,
'click_item_type_key': clickItemType.key,
'click_item_type_name': clickItemType.name,
'click_position': clickPosition,
'search_trace_id': searchTraceId,
};
return {..._commonFields, 'event_id': eventId, "payload": payload};
}
factory KeywordClickEvent.fromJson(Map<String, dynamic> json) {
return KeywordClickEvent(
keyword: json['keyword'] as String,
clickItemId: json['click_item_id'] as String,
clickItemType: ClickItemTypeEnum(
json['click_item_type_key'] as String,
json['click_item_type_name'] as String,
),
clickPosition: json['click_position'] as int,
searchTraceId: json['search_trace_id'] as String? ?? '',
);
}
}
@@ -0,0 +1,65 @@
import 'package:analytics_sdk/const/event_type.dart';
import 'package:analytics_sdk/manager/sdk_context.dart';
/// 关键词搜索事件
class KeywordSearchEvent {
final String event = EventType.keywordSearch.event;
late final String eventId;
/// 搜索关键词
final String keyword;
/// 搜索结果数量
final int searchResultCount;
/// 搜索引擎 trace ID,未接搜索引擎时传空字符串
final String searchTraceId;
/// 搜索引擎 search ID,未接搜索引擎时传空字符串
final String searchId;
/// 搜索列表的内容类型:video / novel / comic
/// 未接搜索推荐引擎时传空字符串
final String searchContentType;
late final Map<String, dynamic> _commonFields;
KeywordSearchEvent({
required this.keyword,
required this.searchResultCount,
this.searchTraceId = '',
this.searchId = '',
this.searchContentType = '',
}) {
_commonFields = SdkContext.generateCommonFields(event);
eventId = SdkContext.generateEventIdFromCommonFields(_commonFields, [
keyword,
searchResultCount.toString(),
searchTraceId,
searchId,
searchContentType,
]);
}
Map<String, dynamic> toJson() {
Map<String, dynamic> payload = {
'keyword': keyword,
'search_result_count': searchResultCount,
'search_trace_id': searchTraceId,
'search_id': searchId,
'search_content_type': searchContentType,
};
return {..._commonFields, 'event_id': eventId, "payload": payload};
}
factory KeywordSearchEvent.fromJson(Map<String, dynamic> json) {
return KeywordSearchEvent(
keyword: json['keyword'] as String,
searchResultCount: json['search_result_count'] as int,
searchTraceId: json['search_trace_id'] as String? ?? '',
searchId: json['search_id'] as String? ?? '',
searchContentType: json['search_content_type'] as String? ?? '',
);
}
}
@@ -0,0 +1,67 @@
import 'package:analytics_sdk/const/event_type.dart';
import 'package:analytics_sdk/manager/page_name_manager.dart';
import 'package:analytics_sdk/manager/sdk_context.dart';
/// 导航菜单点击事件(纯手动上报)。
///
/// 仅在用户主动点击导航菜单时触发:底部 Tab、顶部 Tab、侧边抽屉等。
/// SDK 不会在路由跳转时自动发送此事件;触发方式:
/// - [AnalyticsSdk.trackNavigation] 手动调用
/// - [BottomNavigationBar.withAnalytics] / [TabBar.withAnalytics] 包装器内部自动处理
///
/// **page_key / page_name 填写规范**
///
/// 建议使用 [AppPageEnum] 预定义值,也可直接传入自定义字符串:
/// ```dart
/// NavigationEvent(
/// navigationKey: AppPageEnum.video.key, // 'video'
/// navigationName: AppPageEnum.video.name, // '影视'
/// )
/// ```
///
/// **上报时序**:应在同次导航的 [AppPageViewEvent] 之前发出。
class NavigationEvent {
final String event = EventType.navigation.event;
late final String eventId;
/// 导航目标的唯一标识,与对应页面的 [AppPageViewEvent.pageKey] 保持一致。
///
/// 示例:`home`(首页)、`video`(影视)、`video_hot`(影视·热门子 Tab)
final String navigationKey;
/// 导航目标的展示名称。
///
/// 示例:`首页`、`影视`、`影视·热门`
final String navigationName;
late final Map<String, dynamic> _commonFields;
NavigationEvent({
required String navigationKey,
required this.navigationName,
}) : navigationKey = PageNameMapper.normalizeKey(navigationKey) {
_commonFields = SdkContext.generateCommonFields(event);
eventId = SdkContext.generateEventIdFromCommonFields(_commonFields, [
this.navigationKey,
navigationName,
]);
}
/// 序列化为上报 JSON;payload 包含 `navigation_key` 和 `navigation_name`。
Map<String, dynamic> toJson() {
Map<String, dynamic> payload = {
'navigation_key': navigationKey,
'navigation_name': navigationName,
};
return {..._commonFields, 'event_id': eventId, "payload": payload};
}
/// 从 JSON 反序列化(通常用于缓存恢复场景)。
factory NavigationEvent.fromJson(Map<String, dynamic> json) {
return NavigationEvent(
navigationKey: json['navigation_key'] as String,
navigationName: json['navigation_name'] as String,
);
}
}
@@ -0,0 +1,89 @@
import 'dart:convert';
import 'package:analytics_sdk/const/event_type.dart';
import 'package:analytics_sdk/manager/sdk_context.dart';
import 'package:analytics_sdk/utils/network_type_resolver.dart';
/// 网络状态变化事件。
///
/// 文档要求除公共字段外,下表字段写入 `payload`。
/// 完整设备环境、SIM 详情和当前页面信息不在本事件重传。
class NetworkStateChangeEvent {
final String event = EventType.networkStateChange.event;
final Map<String, dynamic> _payloadFields;
late final String eventId;
late final Map<String, dynamic> _commonFields;
NetworkStateChangeEvent({
required String networkType,
required String fromNetworkType,
required int isOnline,
required int changeCountInSid,
String carrier = '',
Map<String, dynamic> webConnectionFields = const {},
}) : _payloadFields = _buildPayloadFields(
networkType: networkType,
fromNetworkType: fromNetworkType,
isOnline: isOnline,
changeCountInSid: changeCountInSid,
carrier: carrier,
webConnectionFields: webConnectionFields,
) {
_commonFields = SdkContext.generateCommonFields(event);
eventId = SdkContext.generateEventIdFromCommonFields(_commonFields, [
jsonEncode(_stablePayloadFields),
]);
}
Map<String, dynamic> toJson() {
return {
..._commonFields,
'event_id': eventId,
'payload': Map<String, dynamic>.from(_payloadFields),
};
}
Map<String, dynamic> get _stablePayloadFields {
final sortedKeys = _payloadFields.keys.toList()..sort();
return {
for (final key in sortedKeys) key: _payloadFields[key],
};
}
static Map<String, dynamic> _buildPayloadFields({
required String networkType,
required String fromNetworkType,
required int isOnline,
required int changeCountInSid,
required String carrier,
required Map<String, dynamic> webConnectionFields,
}) {
final normalizedNetworkType = _networkType(networkType);
final fields = <String, dynamic>{
'network_type': normalizedNetworkType,
'carrier': carrier.trim(),
'from_network_type': _networkType(fromNetworkType),
'to_network_type': normalizedNetworkType,
'is_online': isOnlineForNetworkType(normalizedNetworkType),
'change_count_in_sid': changeCountInSid < 1 ? 1 : changeCountInSid,
};
for (final key in const [
'connection_type',
'effective_type',
'downlink',
'rtt',
]) {
final value = webConnectionFields[key];
if (value == null) continue;
if (value is String && value.trim().isEmpty) continue;
fields[key] = value;
}
return fields;
}
static String _networkType(String value) {
return normalizeNetworkType(value);
}
}
+130
View File
@@ -0,0 +1,130 @@
import 'package:analytics_sdk/const/event_type.dart';
import 'package:analytics_sdk/manager/sdk_context.dart';
import 'package:analytics_sdk/enum/read_behavior_enum.dart';
/// 小说事件
class NovelEvent {
final String event = EventType.novelEvent.event;
late final String eventId;
/// 小说 id
final String novelId;
/// 小说标题
final String novelTitle;
/// 分类ID
final String novelTypeId;
/// 分类名称
final String novelTypeName;
/// 标签KEY, 多个标签使用英文逗号分隔
final String novelTagKey;
/// 标签名称,多个标签使用英文逗号分隔
final String novelTagName;
/// 推荐引擎的trace_id,未接推荐引擎传空字符串
final String recommendTraceId;
/// 阅读进度百分比(0-100)
final int readProgress;
/// 当前阅读第几页(从1开始)
final int pageNo;
/// 行为标识:view(展示), page_next(下一页), page_prev(上一页), complete(读完) 等
final ReadBehaviorEnum novelBehavior;
/// 章节ID,没有章节上报空字符串,没有对接搜索推荐引擎的上报空字符串
final String chapterId;
/// 章节名称,没有章节上报空字符串,没有对接搜索推荐引擎的上报空字符串
final String chapterName;
/// 老司机媒体资源ID,如未使用老司机库资源可传空字符串
final String mediaId;
late final Map<String, dynamic> _commonFields;
NovelEvent({
required this.novelId,
required this.novelTitle,
required this.novelTypeId,
required this.novelTypeName,
required this.novelTagKey,
required this.novelTagName,
required this.readProgress,
required this.pageNo,
required this.novelBehavior,
this.recommendTraceId = '',
this.mediaId = '',
this.chapterId = '',
this.chapterName = '',
}) {
_commonFields = SdkContext.generateCommonFields(event);
eventId = SdkContext.generateEventIdFromCommonFields(_commonFields, [
novelId,
novelTitle,
novelTypeId,
novelTypeName,
novelTagKey,
novelTagName,
readProgress.toString(),
pageNo.toString(),
novelBehavior.key,
novelBehavior.name,
recommendTraceId,
mediaId,
chapterId,
chapterName,
]);
}
Map<String, dynamic> toJson() {
Map<String, dynamic> payload = {
'novel_id': novelId,
'novel_title': novelTitle,
'novel_type_id': novelTypeId,
'novel_type_name': novelTypeName,
'novel_tag_key': novelTagKey,
'novel_tag_name': novelTagName,
'recommend_trace_id': recommendTraceId,
'read_progress': readProgress,
'page_no': pageNo,
'novel_behavior_key': novelBehavior.key,
'novel_behavior_name': novelBehavior.name,
'media_id': mediaId,
'chapter_id': chapterId,
'chapter_name': chapterName,
};
return {..._commonFields, 'event_id': eventId, "payload": payload};
}
factory NovelEvent.fromJson(Map<String, dynamic> json) {
final behaviorKey = json['novel_behavior_key'] as String? ?? '';
final behaviorName = json['novel_behavior_name'] as String? ?? '';
final behavior = ReadBehaviorEnum(
behaviorKey.isNotEmpty ? behaviorKey : ReadBehaviorEnum.VIEW.key,
behaviorName.isNotEmpty ? behaviorName : ReadBehaviorEnum.VIEW.name,
);
return NovelEvent(
novelId: json['novel_id'] as String,
novelTitle: json['novel_title'] as String,
novelTypeId: json['novel_type_id'] as String,
novelTypeName: json['novel_type_name'] as String,
novelTagKey: json['novel_tag_key'] as String,
novelTagName: json['novel_tag_name'] as String,
readProgress: json['read_progress'] as int,
pageNo: json['page_no'] as int,
novelBehavior: behavior,
recommendTraceId: json['recommend_trace_id'] as String? ?? '',
mediaId: json['media_id'] as String? ?? '',
chapterId: json['chapter_id'] as String? ?? '',
chapterName: json['chapter_name'] as String? ?? '',
);
}
}
@@ -0,0 +1,284 @@
import 'package:analytics_sdk/manager/page_name_manager.dart';
import 'package:analytics_sdk/const/event_type.dart';
import 'package:analytics_sdk/manager/page_click_auto_fields.dart';
import 'package:analytics_sdk/manager/sdk_context.dart';
/// 应用页面点击日志 (自动)
class PageClickEvent {
final String event = EventType.pageClick.event;
late final String eventId;
final String pageKey;
/// 页面名称:首页, 我的, 视频页
final String pageName;
/// 点击位置坐标x轴
final int clickPageX;
/// 点击位置坐标y轴
final int clickPageY;
/// SDK 标准化点击相对当前页面根容器可视区域左上角的 X 坐标。
final int clickViewportX;
/// SDK 标准化点击相对当前页面根容器可视区域左上角的 Y 坐标。
final int clickViewportY;
/// 点击相对当前页面主内容完整区域左上角的 X 坐标。
final int clickDocumentX;
/// 点击相对当前页面主内容完整区域左上角的 Y 坐标。
final int clickDocumentY;
/// 点击位置横坐标百分比(0-100)
final int clickXPercent;
/// 点击位置纵坐标百分比(0-100)
final int clickYPercent;
/// 屏幕宽度(像素)
final int screenWidth;
/// 屏幕高度(像素)
final int screenHeight;
/// 点击时当前页面根容器可交互可视区域宽度。
final int viewportWidth;
/// 点击时当前页面根容器可交互可视区域高度。
final int viewportHeight;
/// 点击时当前页面主内容容器完整宽度。
final int pageWidth;
/// 点击时当前页面主内容容器完整高度。
final int pageHeight;
/// 点击时当前页面主内容容器纵向滚动偏移。
final int scrollTop;
/// 点击时当前页面主内容容器横向滚动偏移。
final int scrollLeft;
/// 推荐引擎的trace_id,有多个推荐列表时用英文逗号分隔,未接推荐引擎传空字符串
final String recommendTraceId;
late final Map<String, dynamic> _commonFields;
PageClickEvent({
required String pageKey,
required this.pageName,
required this.clickPageX,
required this.clickPageY,
required this.clickXPercent,
required this.clickYPercent,
required this.screenWidth,
required this.screenHeight,
int? clickViewportX,
int? clickViewportY,
int? clickDocumentX,
int? clickDocumentY,
int? viewportWidth,
int? viewportHeight,
int? pageWidth,
int? pageHeight,
int? scrollTop,
int? scrollLeft,
this.recommendTraceId = '',
}) : pageKey = PageNameMapper.normalizeKey(pageKey),
clickViewportX = _coordinateOrFallback(clickViewportX, clickPageX),
clickViewportY = _coordinateOrFallback(clickViewportY, clickPageY),
viewportWidth = _dimensionOrFallback(viewportWidth, screenWidth),
viewportHeight = _dimensionOrFallback(viewportHeight, screenHeight),
clickDocumentX = _hasInvalidHorizontalDocumentMetrics(
pageWidth: pageWidth,
viewportWidth: viewportWidth,
screenWidth: screenWidth,
scrollLeft: scrollLeft,
)
? -1
: _unknownable(clickDocumentX),
clickDocumentY = _unknownable(clickDocumentY),
pageWidth = _hasInvalidHorizontalDocumentMetrics(
pageWidth: pageWidth,
viewportWidth: viewportWidth,
screenWidth: screenWidth,
scrollLeft: scrollLeft,
)
? -1
: _unknownable(pageWidth),
pageHeight = _unknownable(pageHeight),
scrollTop = _unknownable(scrollTop),
scrollLeft = _hasInvalidHorizontalDocumentMetrics(
pageWidth: pageWidth,
viewportWidth: viewportWidth,
screenWidth: screenWidth,
scrollLeft: scrollLeft,
)
? -1
: _unknownable(scrollLeft) {
_commonFields = SdkContext.generateCommonFields(event);
eventId = SdkContext.generateEventIdFromCommonFields(_commonFields, [
this.pageKey,
pageName,
clickPageX.toString(),
clickPageY.toString(),
this.clickViewportX.toString(),
this.clickViewportY.toString(),
this.clickDocumentX.toString(),
this.clickDocumentY.toString(),
clickXPercent.toString(),
clickYPercent.toString(),
screenWidth.toString(),
screenHeight.toString(),
this.viewportWidth.toString(),
this.viewportHeight.toString(),
this.pageWidth.toString(),
this.pageHeight.toString(),
this.scrollTop.toString(),
this.scrollLeft.toString(),
recommendTraceId,
]);
}
Map<String, dynamic> toJson() {
Map<String, dynamic> payload = {
'page_key': pageKey,
'page_name': pageName,
'click_page_x': clickPageX,
'click_page_y': clickPageY,
'click_viewport_x': clickViewportX,
'click_viewport_y': clickViewportY,
'click_document_x': clickDocumentX,
'click_document_y': clickDocumentY,
'click_x_percent': clickXPercent,
'click_y_percent': clickYPercent,
'screen_width': screenWidth,
'screen_height': screenHeight,
'viewport_width': viewportWidth,
'viewport_height': viewportHeight,
'page_width': pageWidth,
'page_height': pageHeight,
'scroll_top': scrollTop,
'scroll_left': scrollLeft,
'recommend_trace_id': recommendTraceId,
};
return {
..._commonFields,
'event_id': eventId,
"payload": payload,
};
}
factory PageClickEvent.fromJson(Map<String, dynamic> json) {
return PageClickEvent(
pageKey: json['page_key'] as String,
pageName: json['page_name'] as String,
clickPageX: json['click_page_x'] as int,
clickPageY: json['click_page_y'] as int,
clickXPercent: json['click_x_percent'] as int,
clickYPercent: json['click_y_percent'] as int,
screenWidth: json['screen_width'] as int,
screenHeight: json['screen_height'] as int,
clickViewportX: json['click_viewport_x'] as int?,
clickViewportY: json['click_viewport_y'] as int?,
clickDocumentX: json['click_document_x'] as int?,
clickDocumentY: json['click_document_y'] as int?,
viewportWidth: json['viewport_width'] as int?,
viewportHeight: json['viewport_height'] as int?,
pageWidth: json['page_width'] as int?,
pageHeight: json['page_height'] as int?,
scrollTop: json['scroll_top'] as int?,
scrollLeft: json['scroll_left'] as int?,
recommendTraceId: json['recommend_trace_id'] as String? ?? '',
);
}
static PageClickEvent fromViewport({
required String pageKey,
required String pageName,
required int clickPageX,
required int clickPageY,
required int clickViewportX,
required int clickViewportY,
required int viewportWidth,
required int viewportHeight,
int? clickDocumentX,
int? clickDocumentY,
int? pageWidth,
int? pageHeight,
int? scrollTop,
int? scrollLeft,
String recommendTraceId = '',
}) {
final documentMetrics = currentClickDocumentMetrics(
clickViewportX: clickViewportX,
clickViewportY: clickViewportY,
);
final clickXPercent = viewportWidth > 0
? (clickViewportX / viewportWidth * 100).round().clamp(0, 100)
: 0;
final clickYPercent = viewportHeight > 0
? (clickViewportY / viewportHeight * 100).round().clamp(0, 100)
: 0;
return PageClickEvent(
pageKey: pageKey,
pageName: pageName,
clickPageX: clickPageX,
clickPageY: clickPageY,
clickXPercent: clickXPercent,
clickYPercent: clickYPercent,
screenWidth: viewportWidth,
screenHeight: viewportHeight,
clickViewportX: clickViewportX,
clickViewportY: clickViewportY,
clickDocumentX: clickDocumentX ?? documentMetrics.clickDocumentX,
clickDocumentY: clickDocumentY ?? documentMetrics.clickDocumentY,
viewportWidth: viewportWidth,
viewportHeight: viewportHeight,
pageWidth: pageWidth ?? documentMetrics.pageWidth,
pageHeight: pageHeight ?? documentMetrics.pageHeight,
scrollTop: scrollTop ?? documentMetrics.scrollTop,
scrollLeft: scrollLeft ?? documentMetrics.scrollLeft,
recommendTraceId: recommendTraceId,
);
}
static int _coordinateOrFallback(int? value, int fallback) {
final resolved = value ?? fallback;
return resolved < 0 ? -1 : resolved;
}
static int _dimensionOrFallback(int? value, int fallback) {
final resolved = value ?? fallback;
return resolved < 0 ? -1 : resolved;
}
static int _unknownable(int? value) {
if (value == null || value < 0) return -1;
return value;
}
static bool _hasInvalidHorizontalDocumentMetrics({
required int? pageWidth,
required int? viewportWidth,
required int screenWidth,
required int? scrollLeft,
}) {
final resolvedPageWidth = _unknownable(pageWidth);
final resolvedViewportWidth =
_dimensionOrFallback(viewportWidth, screenWidth);
final resolvedScrollLeft = _unknownable(scrollLeft);
if (resolvedPageWidth < 0 ||
resolvedViewportWidth < 0 ||
resolvedScrollLeft <= 0) {
return false;
}
final maxScrollLeft = resolvedPageWidth - resolvedViewportWidth;
return maxScrollLeft < 0 || resolvedScrollLeft > maxScrollLeft;
}
}
@@ -0,0 +1,87 @@
import 'package:analytics_sdk/manager/page_name_manager.dart';
import 'package:analytics_sdk/const/event_type.dart';
import 'package:analytics_sdk/manager/sdk_context.dart';
import 'package:analytics_sdk/enum/recommend_content_type_enum.dart';
/// 推荐引擎列表点击事件(服务端/客户端)
class RecommendListClickEvent {
/// 事件类型(固定)
final String event = EventType.recommendListClick.event;
/// 去重 ID(动态计算)
late final String eventId;
/// 页面标识:page_home(首页), page_user(我的), page_video(视频页)等
final String pageKey;
/// 页面名称:首页, 我的, 视频页
final String pageName;
/// 推荐列表的类型: video(视频),novel(小说),comic(漫画)
final RecommendContentTypeEnum recommendContentType;
/// 推荐引擎的trace_id,未接推荐引擎传空字符串
final String recommendTraceId;
/// 点击内容的ID
final String recommendId;
/// 推荐引擎的trace_info,未接推荐引擎传空字符串
final String recommendTraceInfo;
/// 客户端版本,三位数字点分隔,例如:1.0.1
final String clientVersion;
late final Map<String, dynamic> _commonFields;
RecommendListClickEvent({
required String pageKey,
required this.pageName,
required this.recommendContentType,
required this.recommendId,
this.recommendTraceId = '',
this.recommendTraceInfo = '',
this.clientVersion = '',
}) : pageKey = PageNameMapper.normalizeKey(pageKey) {
_commonFields = SdkContext.generateCommonFields(event);
eventId = SdkContext.generateEventIdFromCommonFields(_commonFields, [
pageKey,
pageName,
recommendContentType.label,
recommendTraceId,
recommendId,
recommendTraceInfo,
clientVersion,
]);
}
Map<String, dynamic> toJson() {
final payload = <String, dynamic>{
'page_key': pageKey,
'page_name': pageName,
'recommend_content_type': recommendContentType.label,
'recommend_trace_id': recommendTraceId,
'recommend_id': recommendId,
'recommend_trace_info': recommendTraceInfo,
'client_version': clientVersion,
};
return {..._commonFields, 'event_id': eventId, 'payload': payload};
}
factory RecommendListClickEvent.fromJson(Map<String, dynamic> json) {
final contentTypeStr = json['recommend_content_type'] as String? ?? '';
final contentType = RecommendContentTypeEnum(
contentTypeStr.isNotEmpty ? contentTypeStr : RecommendContentTypeEnum.VIDEO.label,
);
return RecommendListClickEvent(
pageKey: json['page_key'] as String,
pageName: json['page_name'] as String,
recommendContentType: contentType,
recommendTraceId: json['recommend_trace_id'] as String? ?? '',
recommendId: json['recommend_id'] as String? ?? '',
recommendTraceInfo: json['recommend_trace_info'] as String? ?? '',
clientVersion: json['client_version'] as String? ?? '',
);
}
}
@@ -0,0 +1,88 @@
import 'package:analytics_sdk/manager/page_name_manager.dart';
import 'package:analytics_sdk/const/event_type.dart';
import 'package:analytics_sdk/manager/sdk_context.dart';
import 'package:analytics_sdk/enum/recommend_content_type_enum.dart';
/// 推荐列表内容展示事件(服务端/客户端)
class RecommendListViewEvent {
/// 事件类型(固定)
final String event = EventType.recommendListView.event;
/// 去重 ID(动态计算)
late final String eventId;
/// 页面标识:home(首页), video_detail(视频详情), user_center(个人中心), discover(发现页)等
final String pageKey;
/// 页面名称:首页, 视频详情页, 个人中心, 发现页
final String pageName;
/// 推荐列表的类型: video(视频),novel(小说),comic(漫画)
final RecommendContentTypeEnum recommendContentType;
/// 推荐引擎的trace_id
final String recommendTraceId;
/// 推荐引擎列表的video_id(视频),novel_id(小说),comic_id(漫画), 可视窗口有多个,使用英文逗号分隔
final String recommendId;
/// 推荐引擎的trace_info,未接推荐引擎传空字符串
final String recommendTraceInfo;
/// 客户端版本,三位数字点分隔,例如:1.0.1
final String clientVersion;
late final Map<String, dynamic> _commonFields;
RecommendListViewEvent({
required String pageKey,
required this.pageName,
required this.recommendContentType,
required this.recommendId,
this.recommendTraceId = '',
this.recommendTraceInfo = '',
this.clientVersion = '',
}) : pageKey = PageNameMapper.normalizeKey(pageKey) {
_commonFields = SdkContext.generateCommonFields(event);
eventId = SdkContext.generateEventIdFromCommonFields(_commonFields, [
pageKey,
pageName,
recommendContentType.label,
recommendTraceId,
recommendId,
recommendTraceInfo,
clientVersion,
]);
}
Map<String, dynamic> toJson() {
final payload = <String, dynamic>{
'event': event,
'page_key': pageKey,
'page_name': pageName,
'recommend_content_type': recommendContentType.label,
'recommend_trace_id': recommendTraceId,
'recommend_id': recommendId,
'recommend_trace_info': recommendTraceInfo,
'client_version': clientVersion,
};
return {..._commonFields, 'event_id': eventId, 'payload': payload};
}
factory RecommendListViewEvent.fromJson(Map<String, dynamic> json) {
final contentTypeStr = json['recommend_content_type'] as String? ?? '';
final contentType = RecommendContentTypeEnum(
contentTypeStr.isNotEmpty ? contentTypeStr : RecommendContentTypeEnum.VIDEO.label,
);
return RecommendListViewEvent(
pageKey: json['page_key'] as String,
pageName: json['page_name'] as String,
recommendContentType: contentType,
recommendTraceId: json['recommend_trace_id'] as String,
recommendId: json['recommend_id'] as String,
recommendTraceInfo: json['recommend_trace_info'] as String? ?? '',
clientVersion: json['client_version'] as String? ?? '',
);
}
}
@@ -0,0 +1,32 @@
import 'dart:convert';
import 'package:analytics_sdk/const/event_type.dart';
import 'package:analytics_sdk/manager/sdk_context.dart';
/// SDK 初始化快照事件。
///
/// `sdk_init` 不携带 `device_env`,硬件与环境快照字段直接写入 payload。
class SdkInitEvent {
final String event = EventType.sdkInit.event;
late final String eventId;
final Map<String, dynamic> payload;
late final Map<String, dynamic> _commonFields;
SdkInitEvent({required Map<String, dynamic> payload})
: payload = Map<String, dynamic>.from(payload) {
_commonFields = SdkContext.generateCommonFields(event);
eventId = SdkContext.generateEventIdFromCommonFields(_commonFields, [
jsonEncode(this.payload),
]);
}
Map<String, dynamic> toJson() {
return {
..._commonFields,
'event_id': eventId,
'payload': payload,
};
}
}
+132
View File
@@ -0,0 +1,132 @@
import 'package:analytics_sdk/const/event_type.dart';
import 'package:analytics_sdk/manager/sdk_context.dart';
import 'package:analytics_sdk/enum/video_content_type_enum.dart';
import 'package:analytics_sdk/enum/video_event_enum.dart';
/// 视频事件 (手动)
class VideoEvent {
final String event = EventType.videoEvent.event;
late final String eventId;
final String videoId;
/// 视频标题
final String videoTitle;
/// 视频分类ID
final String videoTypeId;
/// 视频分类名称
final String videoTypeName;
/// 标签名称,多个标签使用英文逗号分隔
final String videoTagName;
/// 标签KEY, 多个标签使用英文逗号分隔
final String videoTagKey;
/// 视频总时长(秒)
final int videoDuration;
/// 本次播放时长(秒)
final int playDuration;
/// 播放进度百分比(0-100)
final int playProgress;
/// 视频行为标识:video_play(播放), video_pause(暂停), video_share(分享), video_complete(播放完成), video_forward(快进), video_rewind(快退)等
final VideoEventEnum videoBehavior;
/// 视频内容类型:video(长视频), short_video(短视频)
final VideoContentTypeEnum? videoContentType;
/// 推荐引擎的trace_id,未接推荐引擎传空字符串
final String recommendTraceId;
/// 老司机媒体资源ID,视频分析日志新加字段,可为空字符串
final String mediaId;
late final Map<String, dynamic> _commonFields;
VideoEvent(
{required this.videoId,
required this.videoTitle,
required this.videoTypeId,
required this.videoTypeName,
required this.videoTagKey,
required this.videoTagName,
required this.videoDuration,
required this.playDuration,
required this.playProgress,
required this.videoBehavior,
required this.videoContentType,
this.recommendTraceId = '',
this.mediaId = ''}) {
_commonFields = SdkContext.generateCommonFields(event);
eventId = SdkContext.generateEventIdFromCommonFields(_commonFields, [
videoId,
videoTitle,
videoTypeId,
videoTypeName,
videoTagKey,
videoTagName,
videoDuration.toString(),
playDuration.toString(),
playProgress.toString(),
videoBehavior.key,
videoBehavior.name,
videoContentType?.label ?? '',
recommendTraceId,
]);
}
Map<String, dynamic> toJson() {
Map<String, dynamic> payload = {
'video_id': videoId,
'video_title': videoTitle,
'video_type_id': videoTypeId,
'video_type_name': videoTypeName,
'video_tag_key': videoTagKey,
'video_tag_name': videoTagName,
'video_duration': videoDuration,
'play_duration': playDuration,
'play_progress': playProgress,
'video_behavior_key': videoBehavior.key,
'video_behavior_name': videoBehavior.name,
'video_content_type': videoContentType?.label ?? '',
'recommend_trace_id': recommendTraceId,
'media_id': mediaId,
};
return {..._commonFields, 'event_id': eventId, "payload": payload};
}
factory VideoEvent.fromJson(Map<String, dynamic> json) {
final behaviorKey = json['video_behavior_key'] as String? ?? '';
final behaviorName = json['video_behavior_name'] as String? ?? '';
final behavior = VideoEventEnum(
behaviorKey.isNotEmpty ? behaviorKey : VideoEventEnum.VIDEO_PLAY.key,
behaviorName.isNotEmpty ? behaviorName : VideoEventEnum.VIDEO_PLAY.name,
);
final contentTypeStr = json['video_content_type'] as String? ?? '';
final VideoContentTypeEnum? contentType =
contentTypeStr.isNotEmpty ? VideoContentTypeEnum(contentTypeStr) : null;
return VideoEvent(
videoId: json['video_id'] as String,
videoTitle: json['video_title'] as String,
videoTypeId: json['video_type_id'] as String,
videoTypeName: json['video_type_name'] as String,
videoTagKey: json['video_tag_key'] as String,
videoTagName: json['video_tag_name'] as String,
videoDuration: json['video_duration'] as int,
playDuration: json['play_duration'] as int,
playProgress: json['play_progress'] as int,
videoBehavior: behavior,
videoContentType: contentType,
recommendTraceId: json['recommend_trace_id'] as String? ?? '',
mediaId: json['media_id'] as String? ?? '',
);
}
}
+40
View File
@@ -0,0 +1,40 @@
/// 广告类型枚举
/// 对应埋点中的 ad_type 字段:icon、banner、feed、player、splash、text
enum AdTypeEnum {
/// 图标广告
icon(label: 'icon'),
/// 横幅广告
banner(label: 'banner'),
/// 信息流广告
feed(label: 'feed'),
/// 播放器内广告
player(label: 'player'),
/// 开屏广告
splash(label: 'splash'),
/// 文字广告
text(label: 'text'),
;
/// 上报时使用的字符串值
final String label;
const AdTypeEnum({
required this.label,
});
/// 根据字符串解析为枚举,未匹配时返回 null
static AdTypeEnum? fromLabel(String? value) {
if (value == null || value.isEmpty) return null;
for (final e in AdTypeEnum.values) {
if (e.label == value) return e;
}
return null;
}
}
@@ -0,0 +1,25 @@
/// 认证/注册/登录方式枚举
/// 对应文档中的 type 字段:phone、deviceid、email、username
enum AuthTypeEnum {
/// 手机号
PHONE(label: 'phone'),
/// 设备ID
DEVICE_ID(label: 'deviceid'),
/// 邮箱
EMAIL(label: 'email'),
/// 用户名/账号
USERNAME(label: 'username'),
;
/// 上报时使用的字符串值
final String label;
const AuthTypeEnum({
required this.label,
});
}
@@ -0,0 +1,11 @@
enum BannerEventEnum{
CLICK(label: 'click'),
CLOSE(label: 'close'),
SHOW(label: 'show');
final String label;
const BannerEventEnum({
required this.label,
});
}
@@ -0,0 +1,34 @@
/// 点击项目类型。
///
/// 可直接使用预定义常量,也可传入自定义字符串:
/// ```dart
/// ClickItemTypeEnum.video // 预定义
/// ClickItemTypeEnum('manga', '漫画类') // 自定义
/// ```
///
/// 字段规则(debug 模式下触发 assert):
/// - [key]:仅限字母、数字、`_`、`-`
/// - [name]:字母、数字、`_`、`-`、中文
class ClickItemTypeEnum {
final String key;
final String name;
const ClickItemTypeEnum._internal(this.key, this.name);
/// 自定义类型。[key] 仅限字母/数字/`_`/`-`;[name] 允许中文。
factory ClickItemTypeEnum(String key, String name) {
assert(
RegExp(r'^[A-Za-z0-9_-]+$').hasMatch(key),
'ClickItemTypeEnum.key 仅支持字母、数字、_ 和 -,实际传入: "$key"',
);
assert(
RegExp(r'^[\u4e00-\u9fa5A-Za-z0-9_-]+$').hasMatch(name),
'ClickItemTypeEnum.name 仅支持字母、数字、_、- 和中文,实际传入: "$name"',
);
return ClickItemTypeEnum._internal(key, name);
}
static const VIDEO = ClickItemTypeEnum._internal('video', '视频');
static const NOVEL = ClickItemTypeEnum._internal('novel', '小说');
static const COMIC = ClickItemTypeEnum._internal('comic', '漫画');
}
@@ -0,0 +1,25 @@
/// 金币消耗原因枚举
/// 对应文档中的 consume_reason_key 字段:
/// - video_unlock(视频解锁)
/// - gift_send(礼物赠送)
/// - content_purchase(内容购买)
enum ConsumeReasonEnum {
/// 视频解锁
VIDEO_UNLOCK(label: 'video_unlock'),
/// 礼物赠送
GIFT_SEND(label: 'gift_send'),
/// 内容购买
CONTENT_PURCHASE(label: 'content_purchase'),
;
/// 上报时使用的字符串值
final String label;
const ConsumeReasonEnum({
required this.label,
});
}
+19
View File
@@ -0,0 +1,19 @@
/// 货币类型枚举
/// 对应文档中的 currency 字段:CNY(人民币), USD(美元)
enum CurrencyEnum {
/// 人民币
CNY(label: 'CNY'),
/// 美元
USD(label: 'USD'),
;
/// 上报时使用的字符串值
final String label;
const CurrencyEnum({
required this.label,
});
}
+11
View File
@@ -0,0 +1,11 @@
enum DeviceEnum {
Android(label: 'Android'),
iOS(label: 'iOS'),
PC(label: 'PC');
final String label;
const DeviceEnum({
required this.label,
});
}
@@ -0,0 +1,19 @@
/// 订单类型枚举
/// 对应文档中的 order_type 字段:coin_purchase(金币购买), vip_subscription(VIP订阅)
enum OrderTypeEnum {
/// 金币购买
COIN_PURCHASE(label: 'coin_purchase'),
/// VIP 订阅
VIP_SUBSCRIPTION(label: 'vip_subscription'),
;
/// 上报时使用的字符串值
final String label;
const OrderTypeEnum({
required this.label,
});
}
+174
View File
@@ -0,0 +1,174 @@
/// 预定义页面路由枚举,提供标准 [key](页面唯一标识)和 [name](展示名称)。
///
/// **用法示例**
///
/// ```dart
/// // 上报导航事件
/// AnalyticsSdk.instance.trackNavigation(
/// pageKey: AppPageEnum.video.key,
/// pageName: AppPageEnum.video.name,
/// );
///
/// // 配置底部/顶部 Tab
/// BottomNavigationBar(...).withAnalytics(tabs: [
/// AnalyticsTab(AppPageEnum.home.key, AppPageEnum.home.name),
/// AnalyticsTab(AppPageEnum.video.key, AppPageEnum.video.name),
/// AnalyticsTab(AppPageEnum.mine.key, AppPageEnum.mine.name),
/// ]);
///
/// // PageView 子页面同步
/// AnalyticsSdk.instance.updateCurrentPage(
/// pageKey: AppPageEnum.videoList.key,
/// pageName: AppPageEnum.videoList.name,
/// );
/// ```
///
/// **自定义页面**:直接传入字符串即可,无需扩展枚举:
/// ```dart
/// AnalyticsSdk.instance.trackNavigation(
/// pageKey: 'live_room',
/// pageName: '直播间',
/// );
/// ```
enum AppPageEnum {
// ── 公共类 ──────────────────────────────────────────────────
/// 启动页
launch('launch', '启动页'),
/// 引导页(新用户首次打开时展示)
guide('guide', '引导页'),
/// 首页(底部导航第一 Tab 常用值)
home('home', '首页'),
/// 主页
mainPage('main_page', '主页'),
/// 发现页
discover('discover', '发现页'),
/// 搜索页(搜索入口页,含搜索框和热搜)
search('search', '搜索页'),
/// 搜索结果页
searchResult('search_result', '搜索结果页'),
/// 应用中心
appCenter('app_center', '应用中心'),
/// 社区页
community('community', '社区页'),
/// 广场页
square('square', '广场页'),
/// 我的(底部导航"我的"Tab)
mine('mine', '我的'),
/// 个人中心(登录用户的详情页)
userCenter('user_center', '个人中心'),
/// 用户主页(查看他人主页)
userHome('user_home', '用户主页'),
/// 作者主页
authorHome('author_home', '作者主页'),
/// 登录页
login('login', '登录页'),
/// 注册页
register('register', '注册页'),
/// 消息中心
messageCenter('message_center', '消息中心'),
/// 设置页
setting('setting', '设置页'),
/// 充值页
recharge('recharge', '充值页'),
/// 充值记录页
rechargeRecord('recharge_record', '充值记录页'),
/// 我的收藏
mineCollect('mine_collect', '我的收藏'),
/// 我的历史
mineHistory('mine_history', '我的历史'),
/// 会员中心页
mineVip('mine_vip', '会员中心页'),
// ── 视频类 ──────────────────────────────────────────────────
/// 视频列表页
videoList('video_list', '视频列表页'),
/// 视频分类页
videoCategory('video_category', '视频分类页'),
/// 视频榜单页
videoRank('video_rank', '视频榜单页'),
/// 视频搜索页
videoSearch('video_search', '视频搜索页'),
/// 视频详情页
videoDetail('video_detail', '视频详情页'),
/// 视频播放页
videoPlay('video_play', '视频播放页'),
// ── 小说类 ──────────────────────────────────────────────────
/// 小说列表页
novelList('novel_list', '小说列表页'),
/// 小说分类页
novelCategory('novel_category', '小说分类页'),
/// 小说榜单页
novelRank('novel_rank', '小说榜单页'),
/// 小说详情页
novelDetail('novel_detail', '小说详情页'),
/// 小说阅读页
novelRead('novel_read', '小说阅读页'),
/// 小说章节页
novelChapterList('novel_chapter_list', '小说章节页'),
// ── 漫画类 ──────────────────────────────────────────────────
/// 漫画列表页
comicList('comic_list', '漫画列表页'),
/// 漫画分类页
comicCategory('comic_category', '漫画分类页'),
/// 漫画榜单页
comicRank('comic_rank', '漫画榜单页'),
/// 漫画详情页
comicDetail('comic_detail', '漫画详情页'),
/// 漫画阅读页
comicRead('comic_read', '漫画阅读页'),
/// 漫画章节页
comicChapterList('comic_chapter_list', '漫画章节页');
// ─────────────────────────────────────────────────────────────
const AppPageEnum(this.key, this.name);
/// 页面唯一标识,用于 `page_key` 字段。
final String key;
/// 页面展示名称,用于 `page_name` 字段。
final String name;
}
+25
View File
@@ -0,0 +1,25 @@
/// 支付方式枚举
/// 对应文档中的 pay_type 字段:wechat, alipay, bank_card, apple_pay 等
enum PayTypeEnum {
/// 微信支付
WECHAT(label: 'wechat'),
/// 支付宝
ALIPAY(label: 'alipay'),
/// 银行卡
BANK_CARD(label: 'bank_card'),
/// Apple Pay
APPLE_PAY(label: 'apple_pay'),
;
/// 上报时使用的字符串值
final String label;
const PayTypeEnum({
required this.label,
});
}
@@ -0,0 +1,35 @@
/// 阅读行为类型,用于小说和漫画事件。
///
/// 可使用预定义常量,也可传入自定义字符串:
/// ```dart
/// ReadBehaviorEnum.view
/// ReadBehaviorEnum('custom_key', '自定义行为')
/// ```
///
/// 字段规则(debug 模式下触发 assert):
/// - [key]:仅限字母、数字、`_`、`-`
/// - [name]:字母、数字、`_`、`-`、中文
class ReadBehaviorEnum {
final String key;
final String name;
const ReadBehaviorEnum._internal(this.key, this.name);
/// 自定义行为类型。[key] 仅限字母/数字/`_`/`-`;[name] 允许中文。
factory ReadBehaviorEnum(String key, String name) {
assert(
RegExp(r'^[A-Za-z0-9_-]+$').hasMatch(key),
'ReadBehaviorEnum.key 仅支持字母、数字、_ 和 -,实际传入: "$key"',
);
assert(
RegExp(r'^[\u4e00-\u9fa5A-Za-z0-9_-]+$').hasMatch(name),
'ReadBehaviorEnum.name 仅支持字母、数字、_、- 和中文,实际传入: "$name"',
);
return ReadBehaviorEnum._internal(key, name);
}
static const VIEW = ReadBehaviorEnum._internal('view', '展示');
static const PAGE_NEXT = ReadBehaviorEnum._internal('page_next', '下一页');
static const PAGE_PREV = ReadBehaviorEnum._internal('page_prev', '上一页');
static const COMPLETE = ReadBehaviorEnum._internal('complete', '读完');
}
@@ -0,0 +1,28 @@
/// 推荐列表内容类型,用于推荐列表展示/点击事件。
///
/// 可使用预定义常量,也可传入自定义字符串:
/// ```dart
/// RecommendContentTypeEnum.video
/// RecommendContentTypeEnum('podcast')
/// ```
///
/// 字段规则(debug 模式下触发 assert):
/// - [label]:仅限字母、数字、`_`、`-`
class RecommendContentTypeEnum {
final String label;
const RecommendContentTypeEnum._internal(this.label);
/// 自定义内容类型。[label] 仅限字母/数字/`_`/`-`。
factory RecommendContentTypeEnum(String label) {
assert(
RegExp(r'^[A-Za-z0-9_-]+$').hasMatch(label),
'RecommendContentTypeEnum.label 仅支持字母、数字、_ 和 -,实际传入: "$label"',
);
return RecommendContentTypeEnum._internal(label);
}
static const VIDEO = RecommendContentTypeEnum._internal('video');
static const NOVEL = RecommendContentTypeEnum._internal('novel');
static const COMIC = RecommendContentTypeEnum._internal('comic');
}
@@ -0,0 +1,28 @@
/// 用户类型,用于上报用户身份信息。
///
/// 可使用预定义常量,也可传入自定义字符串:
/// ```dart
/// UserTypeEnum.normal
/// UserTypeEnum.vip
/// UserTypeEnum('svip')
/// ```
///
/// 字段规则(debug 模式下触发 assert):
/// - [label]:仅限字母、数字、`_`、`-`
class UserTypeEnum {
final String label;
const UserTypeEnum._internal(this.label);
/// 自定义用户类型。[label] 仅限字母/数字/`_`/`-`。
factory UserTypeEnum(String label) {
assert(
RegExp(r'^[A-Za-z0-9_-]+$').hasMatch(label),
'UserTypeEnum.label 仅支持字母、数字、_ 和 -,实际传入: "$label"',
);
return UserTypeEnum._internal(label);
}
static const normal = UserTypeEnum._internal('normal');
static const vip = UserTypeEnum._internal('vip');
}
@@ -0,0 +1,30 @@
/// 视频内容类型,用于视频事件。
///
/// 可使用预定义常量,也可传入自定义字符串:
/// ```dart
/// VideoContentTypeEnum.video
/// VideoContentTypeEnum('live')
/// ```
///
/// 字段规则(debug 模式下触发 assert):
/// - [label]:仅限字母、数字、`_`、`-`
class VideoContentTypeEnum {
final String label;
const VideoContentTypeEnum._internal(this.label);
/// 自定义内容类型。[label] 仅限字母/数字/`_`/`-`。
factory VideoContentTypeEnum(String label) {
assert(
RegExp(r'^[A-Za-z0-9_-]+$').hasMatch(label),
'VideoContentTypeEnum.label 仅支持字母、数字、_ 和 -,实际传入: "$label"',
);
return VideoContentTypeEnum._internal(label);
}
/// 长视频
static const video = VideoContentTypeEnum._internal('video');
/// 短视频
static const shortVideo = VideoContentTypeEnum._internal('short_video');
}
@@ -0,0 +1,38 @@
/// 视频行为类型,用于视频事件。
///
/// 可使用预定义常量,也可传入自定义字符串:
/// ```dart
/// VideoEventEnum.play
/// VideoEventEnum('custom_key', '自定义行为')
/// ```
///
/// 字段规则(debug 模式下触发 assert):
/// - [key]:仅限字母、数字、`_`、`-`
/// - [name]:字母、数字、`_`、`-`、中文
class VideoEventEnum {
final String key;
final String name;
const VideoEventEnum._internal(this.key, this.name);
/// 自定义行为类型。[key] 仅限字母/数字/`_`/`-`;[name] 允许中文。
factory VideoEventEnum(String key, String name) {
assert(
RegExp(r'^[A-Za-z0-9_-]+$').hasMatch(key),
'VideoEventEnum.key 仅支持字母、数字、_ 和 -,实际传入: "$key"',
);
assert(
RegExp(r'^[\u4e00-\u9fa5A-Za-z0-9_-]+$').hasMatch(name),
'VideoEventEnum.name 仅支持字母、数字、_、- 和中文,实际传入: "$name"',
);
return VideoEventEnum._internal(key, name);
}
static const VIDEO_VIEW = VideoEventEnum._internal('video_view', '视频展示');
static const VIDEO_PLAY = VideoEventEnum._internal('video_play', '视频播放');
static const VIDEO_PAUSE = VideoEventEnum._internal('video_pause', '暂停');
static const VIDEO_SHARE = VideoEventEnum._internal('video_share', '分享');
static const VIDEO_COMPLETE = VideoEventEnum._internal('video_complete', '播放完成');
static const VIDEO_FORWARD = VideoEventEnum._internal('video_forward', '快进');
static const VIDEO_REWIND = VideoEventEnum._internal('video_rewind', '快退');
}
@@ -0,0 +1,51 @@
// lib/extension/tab_analytics_extension.dart
import 'package:analytics_sdk/entity/analytics_tab.dart';
import 'package:analytics_sdk/widget/analytics_bottom_nav_wrapper.dart';
import 'package:analytics_sdk/widget/analytics_tab_bar_wrapper.dart';
import 'package:flutter/material.dart';
/// 为 [BottomNavigationBar] 提供 analytics 自动埋点能力。
///
/// 示例:
/// ```dart
/// BottomNavigationBar(
/// currentIndex: _index,
/// onTap: (i) => setState(() => _index = i),
/// items: const [...],
/// ).withAnalytics(tabs: [
/// AnalyticsTab('home', '首页'),
/// AnalyticsTab('discover'), // pageName 走 PageNameMapper 自动解析
/// ])
/// ```
extension AnalyticsBottomNavExtension on BottomNavigationBar {
/// 切换 Tab 时自动上报 AppPageViewEvent + NavigationEvent,
/// 并更新 PageLifecycleObserver.currentPageKey。
Widget withAnalytics({required List<AnalyticsTab> tabs}) {
return AnalyticsBottomNavWrapper(
tabs: tabs,
child: this,
);
}
}
/// 为 [TabBar] 提供 analytics 轻导航埋点能力。
///
/// 示例:
/// ```dart
/// TabBar(
/// controller: _tabController,
/// tabs: const [Tab(text: '视频'), Tab(text: '小说')],
/// ).withAnalytics(tabs: [
/// AnalyticsTab('video', '视频'),
/// AnalyticsTab('novel'),
/// ])
/// ```
extension AnalyticsTabBarExtension on TabBar {
/// 切换 Tab 时自动上报 NavigationEvent(轻导航,不更新 currentPageKey)。
AnalyticsTabBarWrapper withAnalytics({required List<AnalyticsTab> tabs}) {
return AnalyticsTabBarWrapper(
tabs: tabs,
child: this,
);
}
}
@@ -0,0 +1,105 @@
import 'package:analytics_sdk/config/sdk_config.dart';
import 'package:analytics_sdk/utils/logger.dart';
/// 广告展示去重管理器
///
/// 以页面为粒度做去重:同一页面访问内不重复上报同一广告,
/// 页面退出后去重状态随之清除,下次进入同一页面广告可重新上报。
/// 新 session 时调用 [clear] 清除所有页面记录。
class AdImpressionManager {
static final AdImpressionManager instance = AdImpressionManager._internal();
factory AdImpressionManager() => instance;
AdImpressionManager._internal();
/// 页面级广告去重表:pageKey → 已上报 adId 集合
final Map<String, Set<String>> _reportedAdIdsByPage = {};
/// 广告 SDK 曝光已上报的 trace_id;同一 session 内全局去重。
final Set<String> _reportedSdkTraceIds = {};
/// 当前已存储的广告ID总数(跨所有页面)
int get reportedCount =>
_reportedAdIdsByPage.values.fold(0, (sum, s) => sum + s.length);
/// 批量检查多个广告ID,返回在指定页面内尚未上报的列表(不修改状态)
List<String> getUnreportedAdIds(String adIds, {required String pageKey}) {
if (adIds.isEmpty) return [];
final adIdList = adIds
.split(',')
.map((id) => id.trim())
.where((id) => id.isNotEmpty)
.toList();
if (adIdList.isEmpty) return [];
final pageSet = _reportedAdIdsByPage[pageKey];
if (pageSet == null || pageSet.isEmpty) return adIdList;
return adIdList.where((id) => !pageSet.contains(id)).toList();
}
/// 在指定页面内批量标记广告ID为已上报
void markAsReportedBatch(String adIds, {required String pageKey}) {
if (adIds.isEmpty) return;
final adIdList =
adIds.split(',').map((id) => id.trim()).where((id) => id.isNotEmpty);
final pageSet = _reportedAdIdsByPage.putIfAbsent(pageKey, () => {});
for (final id in adIdList) {
if (reportedCount >= SdkConfig.maxAdImpressionCapacity) {
Logger.adImpressionManager(
'广告去重表已达上限(${SdkConfig.maxAdImpressionCapacity}),停止写入,跳过 id: $id',
level: LogLevel.warn);
break;
}
pageSet.add(id);
}
}
/// 指定 trace_id 是否已作为广告 SDK 曝光上报。
bool isSdkTraceReported(String traceId) {
return _reportedSdkTraceIds.contains(traceId.trim());
}
/// 在广告 SDK 曝光成功入队后标记其 trace_id。
void markSdkTraceReported(String traceId) {
final normalized = traceId.trim();
if (normalized.isEmpty || isSdkTraceReported(normalized)) return;
if (_reportedSdkTraceIds.length >= SdkConfig.maxAdImpressionCapacity) {
Logger.adImpressionManager(
'广告 SDK 曝光 trace_id 去重表已达上限(${SdkConfig.maxAdImpressionCapacity}),停止写入: $normalized',
level: LogLevel.warn,
);
return;
}
_reportedSdkTraceIds.add(normalized);
}
/// 清除指定页面的广告去重记录(页面退出时调用,下次进入可重新上报)
void clearPage(String pageKey) {
if (_reportedAdIdsByPage.remove(pageKey) != null) {
Logger.adImpressionManager('页面 "$pageKey" 广告去重记录已清除');
}
}
/// 清除所有页面的广告去重记录(新 session 开始时调用)
void clear() {
_reportedAdIdsByPage.clear();
_reportedSdkTraceIds.clear();
}
// ── 以下方法供测试使用 ──────────────────────────────
/// 检查广告ID在指定页面内是否已上报(不修改状态)
bool isReported(String adId, {required String pageKey}) {
return _reportedAdIdsByPage[pageKey]?.contains(adId.trim()) ?? false;
}
/// [isReported] 的别名,用于断言
bool contains(String adId, {required String pageKey}) =>
isReported(adId, pageKey: pageKey);
}
@@ -0,0 +1,204 @@
import 'dart:async';
import 'dart:convert';
import 'package:analytics_sdk/config/sdk_config.dart';
import 'package:analytics_sdk/utils/logger.dart';
import 'package:analytics_sdk/utils/platform_storage.dart';
import 'package:http/http.dart' as http;
/// 域名管理器:对业务方传入的域名列表进行并发测速,选择最快的上报域名
class DomainManager {
static final DomainManager instance = DomainManager._internal();
factory DomainManager() => instance;
DomainManager._internal();
List<String> _reportDomains = [];
String? _fastestDomain;
/// 每次 initWithDomains / reset 时递增,用于丢弃过期测速结果
int _generation = 0;
http.Client? _httpClient;
http.Client get _client => _httpClient ??= http.Client();
/// 域名更新回调,当最快域名选出后调用
Function(String? fastestDomain)? onFastestDomainChanged;
/// 获取最快的上报域名
String? get fastestDomain => _fastestDomain;
/// 获取所有上报域名
List<String> get reportDomains => List.unmodifiable(_reportDomains);
/// 传入已解密的域名列表,执行并发测速并选出最快域名
///
/// [domains] 每项为带协议的完整域名,如 "https://api.example.com"
///
/// 注意:此方法设计为 fire-and-forget,调用方无需 await,
/// 测速完成后通过 [onFastestDomainChanged] 回调通知结果。
Future<String?> initWithDomains(List<String> domains) async {
// 记录本次调用的代次,测速完成后用于判断结果是否已过期
final gen = ++_generation;
try {
if (domains.isEmpty) {
Logger.domainManager('域名列表为空,跳过测速', level: LogLevel.warn);
return null;
}
_reportDomains = domains;
Logger.domainManager('开始域名测速,共 ${domains.length} 个: $domains');
String? fastest;
if (domains.length == 1) {
fastest = domains.first;
Logger.domainManager('只有一个域名,跳过测速: $fastest');
} else {
fastest = await _speedTestAndSelect(domains);
Logger.domainManager('测速完成,最快域名: $fastest');
}
// 若 reset() 或新一轮 initWithDomains 已被调用,丢弃本次过期结果
if (gen != _generation) {
Logger.domainManager('测速结果已过期(generation 不匹配),忽略本次结果');
return null;
}
_fastestDomain = fastest;
// 测速完成后持久化域名列表(供下次 init 时解密失败时作为降级)
_saveDomainCache();
if (onFastestDomainChanged != null && _fastestDomain != null) {
try {
onFastestDomainChanged!(_fastestDomain);
} catch (e) {
Logger.domainManager('域名更新回调异常: $e');
}
}
return _fastestDomain;
} catch (e) {
Logger.domainManager('域名测速失败: $e', level: LogLevel.warn);
return null;
}
}
/// 重置域名管理器状态,供 re-init 时使用
///
/// 清除内存中的域名列表和最快域名,不关闭 HttpClient(复用连接池)。
/// 不删除磁盘缓存(缓存代表最近已知可用状态,跨 init 保留)。
void reset() {
_generation++; // 使所有进行中的测速结果失效
_fastestDomain = null;
_reportDomains = [];
onFastestDomainChanged = null; // 防止旧回调在新一轮测速前残留
Logger.domainManager('域名管理器状态已重置');
}
/// 释放 HttpClient 资源,通常在 SDK dispose 时调用
void dispose() {
_httpClient?.close();
_httpClient = null;
Logger.domainManager('域名管理器已销毁');
}
/// 从磁盘加载上次缓存的域名列表
///
/// 返回非空列表时可直接传入 [initWithDomains]。
/// 无缓存文件或读取失败时返回空列表(不抛出异常)。
Future<List<String>> loadCachedDomains() async {
try {
final content =
await PlatformStorage.read(SdkConfig.domainCacheFileName);
if (content == null) return [];
final decoded = jsonDecode(content);
if (decoded is List) {
return (decoded as List)
.map<String>((item) => item.toString().trim())
.where((d) => d.isNotEmpty)
.toList();
}
return [];
} catch (e) {
Logger.domainManager('加载域名缓存失败: $e');
return [];
}
}
// ==================== 私有方法 ====================
/// 将当前 _reportDomains 写入磁盘缓存(fire-and-forget)
void _saveDomainCache() {
if (_reportDomains.isEmpty) return;
final domainsSnapshot = List<String>.from(_reportDomains);
Future(() async {
try {
await PlatformStorage.write(
SdkConfig.domainCacheFileName,
jsonEncode(domainsSnapshot),
);
Logger.domainManager('域名缓存已写入: $domainsSnapshot');
} catch (e) {
Logger.domainManager('写入域名缓存失败: $e');
}
});
}
/// 对所有域名进行并发测速,返回最快的域名
Future<String?> _speedTestAndSelect(List<String> domains) async {
if (domains.isEmpty) return null;
final speedResults = <String, Duration>{};
final futures = domains.map((domain) async {
try {
final speed = await _speedTest(domain);
if (speed != null) {
speedResults[domain] = speed;
Logger.domainManager('域名 $domain 测速: ${speed.inMilliseconds}ms');
}
} catch (e) {
Logger.domainManager('域名 $domain 测速失败: $e');
}
});
await Future.wait(futures);
if (speedResults.isEmpty) {
Logger.domainManager('所有域名测速失败,回退到使用第一个域名作为上报域名',
level: LogLevel.warn);
return domains.first;
}
final fastest =
speedResults.entries.reduce((a, b) => a.value < b.value ? a : b);
Logger.domainManager(
'最快域名: ${fastest.key} (${fastest.value.inMilliseconds}ms)');
return fastest.key;
}
/// 测速单个域名,返回连接耗时;连接超时或网络异常返回 null
///
/// 仅 HTTP 200 视为可达。
/// 非 200(3xx 重定向、4xx 客户端错误、5xx 服务端错误)/ 连接失败 / 超时 视为不可达,返回 null。
Future<Duration?> _speedTest(String domain) async {
try {
final uri = Uri.parse(domain);
final startTime = DateTime.now();
final response =
await _client.get(uri).timeout(SdkConfig.speedTestTimeout);
final elapsed = DateTime.now().difference(startTime);
if (response.statusCode != 200) {
Logger.domainManager(
'域名 $domain 返回 ${response.statusCode},视为不可达',
level: LogLevel.warn);
return null;
}
Logger.domainManager(
'域名 $domain 可达,状态码: ${response.statusCode}');
return elapsed;
} catch (e) {
Logger.domainManager('域名 $domain 测速失败: $e', level: LogLevel.warn);
return null;
}
}
}
@@ -0,0 +1,7 @@
export 'event_persistence_native.dart'
// ignore: uri_does_not_exist
if (dart.library.html) 'event_persistence_web.dart';
// EventPersistenceImpl 由条件导入文件提供
// Native:lib/manager/event_persistence_native.dart
// Web :lib/manager/event_persistence_web.dart
@@ -0,0 +1,398 @@
import 'dart:async';
import 'dart:collection';
import 'dart:convert';
import 'dart:io';
import 'package:analytics_sdk/config/sdk_config.dart';
import 'package:analytics_sdk/utils/logger.dart';
import 'package:path_provider/path_provider.dart';
import 'package:synchronized/synchronized.dart';
/// Native 平台事件持久化:基于本地文件 + tombstone 机制。
class EventPersistenceImpl {
String? _cacheFilePath;
String? _debugReportEventLogFilePath;
String? _tombstoneFilePath;
String? _inFlightFilePath;
final Lock _cacheLock = Lock();
final List<Map<String, dynamic>> _cacheBuffer = [];
static int get _cacheBufferThreshold => SdkConfig.cacheBufferThreshold;
static Duration get _cacheFlushInterval => SdkConfig.cacheFlushInterval;
Timer? _cacheFlushTimer;
Future<void> init() async {
try {
final dir = await getApplicationDocumentsDirectory();
_cacheFilePath = '${dir.path}/${SdkConfig.cacheFileName}';
_debugReportEventLogFilePath =
'${dir.path}/${SdkConfig.debugReportEventLogFileName}';
_tombstoneFilePath = '${dir.path}/${SdkConfig.tombstoneFileName}';
_inFlightFilePath = '${dir.path}/data_plus_events_inflight.txt';
} catch (e) {
Logger.analyticsSdk('持久化初始化失败: $e');
_cacheFilePath = null;
_debugReportEventLogFilePath = null;
_tombstoneFilePath = null;
}
}
String? get debugReportEventLogFilePath =>
SdkConfig.enableDebugReportLog ? _debugReportEventLogFilePath : null;
void appendDebugReportEvent(Map<String, dynamic> event) {
if (!SdkConfig.enableDebugReportLog ||
_debugReportEventLogFilePath == null) {
return;
}
try {
File(_debugReportEventLogFilePath!).writeAsStringSync(
'${jsonEncode(event)}\n',
mode: FileMode.append,
flush: true,
);
} catch (e) {
Logger.analyticsSdk('debug 完整事件 JSONL 写入失败: $e', level: LogLevel.warn);
}
}
void bufferEvent(Map<String, dynamic> event) {
if (_cacheFilePath == null) return;
_cacheBuffer.add(event);
if (_cacheBuffer.length >= _cacheBufferThreshold) {
flushBuffer();
} else {
_cacheFlushTimer ??= Timer(_cacheFlushInterval, () {
_cacheFlushTimer = null;
flushBuffer();
});
}
}
/// 同步写入关键事件,供应用终止阶段的会话汇总使用。
///
/// 此时 Dart isolate 可能在下一个 await 前被系统终止,不能依赖延迟
/// buffer 或异步文件写入。事件已包含 event_id,后续仍通过 tombstone
/// 和服务端 event_id 去重保证幂等。
void persistCriticalEvent(Map<String, dynamic> event) {
if (_cacheFilePath == null) return;
try {
final line = jsonEncode(event);
File(_cacheFilePath!).writeAsStringSync(
'$line\n',
mode: FileMode.append,
flush: true,
);
} catch (e) {
Logger.analyticsSdk('关键事件同步缓存写入失败: $e', level: LogLevel.warn);
}
}
Future<List<Map<String, dynamic>>> loadEvents({
required Queue<Map<String, dynamic>> queue,
required int maxQueueSize,
required int maxCacheLines,
required bool Function(String) isEventTypeEnabled,
}) async {
if (_cacheFilePath == null) return const [];
final file = File(_cacheFilePath!);
if (!await file.exists()) return const [];
final recovered = <Map<String, dynamic>>[];
try {
await _cacheLock.synchronized(() async {
if (await file.length() > SdkConfig.maxCacheBytes) {
await _trimCacheFileIfNeeded(file);
}
final tombstonedIds = await _loadTombstoneIds();
final inFlightIds = await _loadInFlightIds();
// 请求开始不代表服务端已经确认。进程在请求途中被终止时,in-flight
// 事件必须随缓存一起恢复,交由服务端按 event_id 去重,不能在客户端
// 为了避免重复而丢失会话汇总等终止事件。
if (inFlightIds.isNotEmpty) {
Logger.analyticsSdk(
'检测到 ${inFlightIds.length} 条未确认 in-flight 事件,将恢复重试');
}
final lines = await file.readAsLines();
final linesToProcess = lines.length > maxCacheLines
? lines.sublist(lines.length - maxCacheLines)
: lines;
for (final line in linesToProcess) {
if (line.trim().isEmpty) continue;
try {
final decoded = jsonDecode(line);
if (decoded is Map<String, dynamic>) {
final eventId = decoded['event_id'];
if (eventId != null && tombstonedIds.contains(eventId)) {
continue;
}
final eventType = decoded['event'] as String?;
if (eventType != null && !isEventTypeEnabled(eventType)) {
Logger.analyticsSdk('缓存恢复:事件类型 $eventType 未启用,跳过');
continue;
}
recovered.add(decoded);
}
} catch (_) {
continue;
}
}
// 缓存和 tombstone 都不能在“恢复到内存队列”时删除。恢复后的
// 事件尚未得到本次请求的 200 确认,进程再次终止时仍需能从磁盘
// 重试;只有 removeEvents() 在 200 后写入 tombstone。
if (_inFlightFilePath != null) {
final inFlight = File(_inFlightFilePath!);
if (await inFlight.exists()) await inFlight.delete();
}
});
} catch (e) {
Logger.analyticsSdk('缓存加载失败: $e');
try {
await file.delete();
} catch (_) {}
}
return recovered;
}
Future<void> removeEvents(List<Map<String, dynamic>> reportedEvents) async {
if (_tombstoneFilePath == null || reportedEvents.isEmpty) return;
final ids = reportedEvents
.map((e) => e['event_id'])
.whereType<String>()
.where((id) => id.isNotEmpty && !id.contains('\n'))
.toList();
if (ids.isEmpty) return;
try {
await _cacheLock.synchronized(() async {
try {
final tombstone = File(_tombstoneFilePath!);
await tombstone.writeAsString(
'${ids.join('\n')}\n',
mode: FileMode.append,
);
Logger.analyticsSdk('tombstone 追加 ${ids.length} 条 event_id');
final tombstoneContent = await tombstone.readAsString();
final tombstoneCount =
tombstoneContent.split('\n').where((l) => l.isNotEmpty).length;
if (tombstoneCount >= SdkConfig.tombstoneCompactionThreshold) {
await _compactCache(tombstoneContent);
}
} catch (e) {
Logger.analyticsSdk('tombstone 写入失败: $e');
}
});
} catch (e) {
Logger.analyticsSdk('缓存移除操作异常: $e');
}
}
Future<void> clearCache() async {
if (_cacheFilePath == null) return;
try {
await _cacheLock.synchronized(() async {
try {
final file = File(_cacheFilePath!);
if (await file.exists()) await file.delete();
if (_tombstoneFilePath != null) {
final tombstone = File(_tombstoneFilePath!);
if (await tombstone.exists()) await tombstone.delete();
}
} catch (e) {
Logger.analyticsSdk('缓存清除失败: $e');
}
});
} catch (e) {
Logger.analyticsSdk('缓存清除操作异常: $e');
}
}
void flushBuffer() {
if (_cacheFilePath == null || _cacheBuffer.isEmpty) return;
_cacheFlushTimer?.cancel();
_cacheFlushTimer = null;
final batch = List<Map<String, dynamic>>.from(_cacheBuffer);
_cacheBuffer.clear();
Future.microtask(() async {
await _saveBatchToCache(batch);
});
}
Future<void> flushBufferAsync() async {
if (_cacheFilePath == null || _cacheBuffer.isEmpty) return;
_cacheFlushTimer?.cancel();
_cacheFlushTimer = null;
final batch = List<Map<String, dynamic>>.from(_cacheBuffer);
_cacheBuffer.clear();
await _saveBatchToCache(batch);
}
/// 发送前记录 batch 中的 event_id,便于诊断未确认请求。
///
/// 崩溃重启后这些事件会与缓存一起恢复并重试,采用 at-least-once
/// 语义;服务端应按 event_id 去重。
Future<void> markInFlight(List<Map<String, dynamic>> batch) async {
if (_inFlightFilePath == null) return;
final ids = batch
.map((e) => e['event_id'])
.whereType<String>()
.where((id) => id.isNotEmpty && !id.contains('\n'))
.toList();
if (ids.isEmpty) return;
try {
await File(_inFlightFilePath!).writeAsString('${ids.join('\n')}\n');
} catch (e) {
Logger.analyticsSdk('in-flight 标记写入失败: $e');
}
}
/// 清除 in-flight 文件(上报完成或明确失败后调用,幂等)。
Future<void> clearInFlight() async {
if (_inFlightFilePath == null) return;
try {
final file = File(_inFlightFilePath!);
if (await file.exists()) await file.delete();
} catch (e) {
Logger.analyticsSdk('in-flight 文件清除失败: $e');
}
}
// ==================== 私有方法 ====================
Future<void> _trimCacheFileIfNeeded(File file) async {
try {
final len = await file.length();
if (len <= SdkConfig.maxCacheBytes) return;
final lines = await file.readAsLines();
int total = 0;
int startIndex = 0;
for (int i = lines.length - 1; i >= 0; i--) {
total += utf8.encode(lines[i]).length + 1;
if (total > SdkConfig.maxCacheBytes) {
startIndex = i + 1;
break;
}
}
if (startIndex <= 0) return;
final kept = lines.sublist(startIndex);
await file.writeAsString(kept.isEmpty ? '' : '${kept.join('\n')}\n');
Logger.analyticsSdk(
'缓存文件超过 ${SdkConfig.maxCacheBytes} 字节,已裁剪掉前 $startIndex 条');
} catch (e) {
Logger.analyticsSdk('缓存文件裁剪失败: $e');
}
}
Future<void> _saveBatchToCache(List<Map<String, dynamic>> batch) async {
if (_cacheFilePath == null || batch.isEmpty) return;
try {
await _cacheLock.synchronized(() async {
try {
final file = File(_cacheFilePath!);
final lines = batch
.map((item) {
try {
return jsonEncode(item);
} catch (e) {
Logger.analyticsSdk('单个事件编码失败,跳过: $e');
return null;
}
})
.where((line) => line != null)
.join('\n');
if (lines.isNotEmpty) {
await file.writeAsString('$lines\n', mode: FileMode.append);
await _trimCacheFileIfNeeded(file);
}
} catch (e) {
Logger.analyticsSdk('批量缓存写入失败: $e');
}
});
} catch (e) {
Logger.analyticsSdk('缓存保存异常: $e');
}
}
Future<Set<String>> _loadTombstoneIds() async {
if (_tombstoneFilePath == null) return {};
try {
final tombstone = File(_tombstoneFilePath!);
if (!await tombstone.exists()) return {};
final content = await tombstone.readAsString();
return content.split('\n').where((l) => l.isNotEmpty).toSet();
} catch (e) {
Logger.analyticsSdk('tombstone 读取失败: $e');
return {};
}
}
Future<Set<String>> _loadInFlightIds() async {
if (_inFlightFilePath == null) return {};
try {
final file = File(_inFlightFilePath!);
if (!await file.exists()) return {};
final content = await file.readAsString();
return content.split('\n').where((l) => l.isNotEmpty).toSet();
} catch (e) {
Logger.analyticsSdk('in-flight 文件读取失败: $e');
return {};
}
}
Future<void> _compactCache(String tombstoneContent) async {
if (_cacheFilePath == null || _tombstoneFilePath == null) return;
try {
final tombstonedIds =
tombstoneContent.split('\n').where((l) => l.isNotEmpty).toSet();
final cacheFile = File(_cacheFilePath!);
if (!await cacheFile.exists()) {
await File(_tombstoneFilePath!).delete();
return;
}
final lines = await cacheFile.readAsLines();
final kept = <String>[];
int removed = 0;
for (final line in lines) {
if (line.trim().isEmpty) continue;
try {
final decoded = jsonDecode(line);
if (decoded is Map<String, dynamic>) {
final id = decoded['event_id'];
if (id != null && tombstonedIds.contains(id)) {
removed++;
} else {
kept.add(line);
}
} else {
kept.add(line);
}
} catch (_) {
kept.add(line);
}
}
if (kept.isEmpty) {
await cacheFile.delete();
} else {
await cacheFile.writeAsString('${kept.join('\n')}\n');
}
try {
await File(_tombstoneFilePath!).delete();
} catch (e) {
Logger.analyticsSdk('tombstone 删除失败(可忽略,下次压缩会重试): $e');
}
Logger.analyticsSdk('缓存压缩完成:删除 $removed 条,保留 ${kept.length} 条');
} catch (e) {
Logger.analyticsSdk('缓存压缩失败: $e');
}
}
}
@@ -0,0 +1,35 @@
import 'dart:collection';
/// Web 平台事件持久化:内存 only,所有持久化操作为空实现。
/// Web 会话通常短暂,不需要跨页面持久化事件。
class EventPersistenceImpl {
Future<void> init() async {}
String? get debugReportEventLogFilePath => null;
void appendDebugReportEvent(Map<String, dynamic> event) {}
void bufferEvent(Map<String, dynamic> event) {}
void persistCriticalEvent(Map<String, dynamic> event) {}
Future<List<Map<String, dynamic>>> loadEvents({
required Queue<Map<String, dynamic>> queue,
required int maxQueueSize,
required int maxCacheLines,
required bool Function(String) isEventTypeEnabled,
}) async =>
const [];
Future<void> removeEvents(List<Map<String, dynamic>> events) async {}
Future<void> markInFlight(List<Map<String, dynamic>> events) async {}
Future<void> clearInFlight() async {}
Future<void> clearCache() async {}
void flushBuffer() {}
Future<void> flushBufferAsync() async {}
}
@@ -0,0 +1,552 @@
import 'dart:async';
import 'dart:collection';
import 'dart:convert';
import 'package:analytics_sdk/manager/event_persistence.dart';
import 'package:http/http.dart' as http;
import 'dart:math';
import 'package:analytics_sdk/config/sdk_config.dart';
import 'package:analytics_sdk/manager/event_type_config_manager.dart';
import 'package:analytics_sdk/manager/session_behavior_manager.dart';
import 'package:analytics_sdk/manager/session_manager.dart';
import 'package:analytics_sdk/manager/session_metrics_manager.dart';
import 'package:analytics_sdk/manager/session_sensor_sampler.dart';
import 'package:analytics_sdk/manager/sdk_context.dart';
import 'package:analytics_sdk/utils/logger.dart';
import 'package:flutter/foundation.dart';
import 'package:synchronized/synchronized.dart';
/// 事件处理与上报管理器
///
/// 负责:
/// - 维护事件队列
/// - 本地持久化(缓存文件)
/// - 批量上报 & 指数退避重试
/// - 定时上报定时器
///
/// 对外由 `AnalyticsSdk` 持有并调用,不直接暴露给业务方。
class EventProcessor {
String? _reportUrl;
final Queue<Map<String, dynamic>> _eventQueue = Queue<Map<String, dynamic>>();
Timer? _uploadTimer;
final EventPersistenceImpl _persistence = EventPersistenceImpl();
int _backoffLevel = 0;
Timer? _retryTimer;
/// JSON 过大时折半缩小批次;成功后重置为 null(恢复用 maxBatchSize)
int? _overrideBatchSize;
http.Client? _httpClient;
http.Client get _client => _httpClient ??= http.Client();
/// 上报锁,防止并发上报
bool _isUploading = false;
final Lock _uploadLock = Lock();
/// event_id 防抖:记录最近见到该 id 的时间,窗口内重复则丢弃
final Map<String, DateTime> _recentEventIds = {};
/// 防抖窗口:1000ms 秒级时间戳粒度 + 100ms 时钟抖动容差。
/// event_id 的哈希因子包含秒级 client_ts,同秒内同事件会产生同一 id;
/// 额外 100ms 用于覆盖调用链路上的微小时间偏移,避免临界点误判。
static const _dedupWindow = Duration(milliseconds: 1100);
/// 测试包用:最近一次上报结果
bool? lastUploadSuccess;
String? lastUploadError;
DateTime? lastUploadTime;
/// 是否已配置可用的上报 URL
bool get hasValidReportUrl =>
_reportUrl != null && _reportUrl!.isNotEmpty == true;
/// 测试包用:队列中待上报事件数
int get queueLength => _eventQueue.length;
/// 测试包用:当前上报地址(脱敏)
String? get debugReportUrl => _reportUrl;
/// 测试包用:完整事件 JSONL 调试文件路径(仅 Native debug 包)
String? get debugReportEventLogPath =>
kDebugMode && SdkConfig.enableDebugReportLog
? _persistence.debugReportEventLogFilePath
: null;
/// 更新上报 URL(域名管理器回调时调用)
void updateReportUrl(String? reportUrl) {
_reportUrl = reportUrl;
}
/// 启动定时上报定时器
void startAutoUploadTimer() {
_uploadTimer?.cancel();
_uploadTimer = Timer.periodic(SdkConfig.uploadInterval, (_) {
try {
uploadBatch().catchError((e) {
Logger.analyticsSdk('定时上报 Future 异常: $e');
});
} catch (e) {
Logger.analyticsSdk('定时上报异常: $e');
}
});
}
/// 停止定时上报定时器
void stopAutoUploadTimer() {
_uploadTimer?.cancel();
_uploadTimer = null;
}
/// 重置退避和上报状态(SDK 重新初始化时调用)
void resetState() {
_backoffLevel = 0;
_overrideBatchSize = null;
_retryTimer?.cancel();
_retryTimer = null;
lastUploadSuccess = null;
lastUploadError = null;
lastUploadTime = null;
_isUploading = false;
_recentEventIds.clear();
}
/// 事件入队:
/// - 负责队列容量检查
/// - 触发必要的上报
/// - 写入本地缓存
///
/// 返回值:
/// - `true`:事件成功进入队列
/// - `false`:因队列已满等原因被丢弃
bool enqueueEvent(Map<String, dynamic> json) {
// 队列容量检查,防止内存无限增长
if (_eventQueue.length >= SdkConfig.maxQueueSize) {
Logger.analyticsSdk('警告:事件队列已满(${_eventQueue.length}),尝试立即上报');
// 尝试立即上报以释放队列空间(不 await)
try {
uploadBatch().catchError((e) {
Logger.analyticsSdk('立即上报 Future 异常: $e');
});
} catch (e) {
Logger.analyticsSdk('立即上报异常: $e');
}
// 队列已满时直接丢弃新事件
return false;
}
// event_id 防抖:1100ms 窗口内同一 id 只保留第一条
final eventId = json['event_id'] as String?;
if (eventId != null) {
final now = DateTime.now();
_recentEventIds.removeWhere((_, t) => now.difference(t) > _dedupWindow);
final lastSeen = _recentEventIds[eventId];
if (lastSeen != null && now.difference(lastSeen) <= _dedupWindow) {
Logger.analyticsSdk('event_id 防抖,已丢弃: $eventId');
return false;
}
_recentEventIds[eventId] = now;
}
_eventQueue.add(json);
SessionManager.instance.recordActivityForEvent(json);
SessionMetricsManager.instance.recordAcceptedEvent(json);
SessionBehaviorManager.instance.recordAcceptedEvent(json);
SessionSensorSampler.instance.recordAcceptedEvent(json);
if (json['event'] == 'app_session_summary') {
_persistence.persistCriticalEvent(json);
Logger.analyticsSdk(
'app_session_summary 已同步写入本地缓存: ${json['event_id'] ?? ''}');
} else {
_persistence.bufferEvent(json);
}
_writeDebugReportEvent(json);
_logAcceptedEvent(json);
_flushIfNeeded();
return true;
}
/// 当队列长度达到阈值时触发上报
void _flushIfNeeded() {
if (_eventQueue.length >= SdkConfig.autoUploadThreshold) {
uploadBatch().catchError((e) {
Logger.analyticsSdk('阈值触发上报 Future 异常: $e');
});
}
}
/// 强制立即上报
/// 该方法永远不会抛出异常
Future<void> flush() async {
try {
await uploadBatch();
} catch (e) {
Logger.analyticsSdk('flush() 发生异常,已安全处理: $e');
}
}
/// 批量上报队列中的事件
Future<void> uploadBatch() async {
if (_eventQueue.isEmpty || _reportUrl == null) return;
return _uploadLock.synchronized(() async {
if (_eventQueue.isEmpty || _reportUrl == null || _isUploading) return;
_isUploading = true;
// 限制单次上报的批次大小,避免请求过大导致超时或内存问题
// _overrideBatchSize 在 JSON 过大时折半缩小,上报成功后重置
final maxBatch = _overrideBatchSize ?? SdkConfig.maxBatchSize;
final batchSize =
_eventQueue.length > maxBatch ? maxBatch : _eventQueue.length;
// 安全检查:确保批次大小有效
if (batchSize <= 0) {
Logger.analyticsSdk('批次大小无效: $batchSize');
_isUploading = false;
return;
}
// 取出批次但不从队列移除,上报成功后再移除(at-least-once 语义)
final batch = List<Map<String, dynamic>>.from(
_eventQueue.take(batchSize),
);
try {
// 安全检查:确保批次不为空
if (batch.isEmpty) {
Logger.analyticsSdk('批次为空,跳过上报');
return;
}
final requestBatch = batch
.map(_ensureEventSignatureBeforeUpload)
.toList(growable: false);
// 在后台线程执行 JSON 编码,避免阻塞主线程
final batchJson = await _encodeBatchAsync(requestBatch);
// 检查编码结果
if (batchJson == '[]' || batchJson.isEmpty) {
Logger.analyticsSdk('JSON 编码失败或结果为空');
_scheduleRetry();
return;
}
// 检查 JSON 大小,避免请求过大
final jsonSize = utf8.encode(batchJson).length;
if (jsonSize > SdkConfig.maxJsonSize) {
Logger.analyticsSdk(
'警告:JSON 大小过大 (${(jsonSize / 1024 / 1024).toStringAsFixed(2)}MB),缩小批次重试');
if (batchSize > 1) {
// 折半批次后重试,避免同一批次反复触发 JSON 过大
_overrideBatchSize = (batchSize / 2).ceil();
_isUploading = false;
Future.microtask(() {
uploadBatch().catchError((e) {
Logger.analyticsSdk('缩小批次后重试上报 Future 异常: $e');
});
});
} else {
// 单条事件过大,移除该事件避免阻塞队列
if (_eventQueue.isNotEmpty) {
_eventQueue.removeFirst();
}
Logger.analyticsSdk(
'警告:单条事件过大 (${(jsonSize / 1024).toStringAsFixed(2)}KB),已丢弃');
}
return;
}
try {
// 解析 URL,添加错误处理
Uri uri;
try {
uri = Uri.parse(_reportUrl!);
if (!uri.hasScheme || (!uri.scheme.startsWith('http'))) {
throw const FormatException('无效的 URL scheme');
}
} catch (e) {
Logger.analyticsSdk('URL 解析失败: $_reportUrl, 错误: $e');
_scheduleRetry();
return;
}
// 终止窗口内也要保证批次已落盘:否则请求已开始但进程被杀时,
// in-flight 文件虽存在,主缓存却没有这批事件可供重试。
// 服务端通过 event_id 去重,客户端采用 at-least-once 投递。
await _persistence.flushBufferAsync();
await _persistence.markInFlight(requestBatch);
Logger.analyticsSdk(
'上报中:${requestBatch.length} 条事件,JSON大小: ${(jsonSize / 1024).toStringAsFixed(2)}KB(已落盘)');
// 复用 http.Client,利用连接池
final response = await _client
.post(
uri,
headers: {
'Content-Type': 'application/json; charset=utf-8',
},
body: batchJson,
)
.timeout(SdkConfig.totalRequestTimeout);
final statusCode = response.statusCode;
if (statusCode == 200) {
// 上报成功,按 event_id 从队列中移除已上报的事件
final reportedIds =
batch.map((e) => e['event_id']).whereType<String>().toSet();
if (reportedIds.isNotEmpty) {
_eventQueue
.removeWhere((e) => reportedIds.contains(e['event_id']));
} else {
// event_id 全缺失时回退到按数量移除(兜底)
for (int i = 0; i < batchSize && _eventQueue.isNotEmpty; i++) {
_eventQueue.removeFirst();
}
}
lastUploadSuccess = true;
lastUploadError = null;
lastUploadTime = DateTime.now();
_overrideBatchSize = null; // 上报成功,恢复正常批次大小
_resetBackoff();
await _persistence.removeEvents(requestBatch);
final confirmedEvents = requestBatch
.map((event) =>
'${event['event'] ?? 'unknown'}(${event['event_id'] ?? 'missing_event_id'})')
.join(', ');
Logger.analyticsSdk(
'上报成功: ${requestBatch.length} 条事件,HTTP $statusCode 批次确认,'
'已写入 tombstone;确认事件: [$confirmedEvents]');
} else {
lastUploadSuccess = false;
lastUploadError = 'HTTP $statusCode';
lastUploadTime = DateTime.now();
_scheduleRetry();
if (statusCode >= 400 && statusCode < 500) {
Logger.analyticsSdk('上报失败: 客户端错误,状态码: $statusCode(可能是数据格式问题)');
} else if (statusCode >= 500) {
Logger.analyticsSdk('上报失败: 服务器错误,状态码: $statusCode(服务器问题,将重试)');
} else {
Logger.analyticsSdk('上报失败: 未知状态码 $statusCode');
}
}
} catch (e) {
lastUploadSuccess = false;
lastUploadError = e is TimeoutException
? '请求超时'
: e is http.ClientException
? '网络连接失败'
: e.toString().length > 30
? '${e.toString().substring(0, 30)}...'
: e.toString();
lastUploadTime = DateTime.now();
_scheduleRetry();
if (e is TimeoutException) {
Logger.analyticsSdk('上报异常: 请求超时 - ${e.message ?? "未知超时"}',
level: LogLevel.error);
} else if (e is http.ClientException) {
Logger.analyticsSdk('上报异常: 网络连接失败 - ${e.message}',
level: LogLevel.error);
} else {
final errorMessage = e.toString();
Logger.analyticsSdk('上报异常: $errorMessage', level: LogLevel.error);
}
} finally {
// 无论成功、失败还是异常,确保 in-flight 文件被清除(幂等)
try {
await _persistence.clearInFlight();
} catch (_) {}
}
} finally {
_isUploading = false;
}
});
}
Map<String, dynamic> _ensureEventSignatureBeforeUpload(
Map<String, dynamic> event,
) {
final eventSig = event['event_sig'];
if (eventSig is String && eventSig.trim().isNotEmpty) {
return event;
}
final signed = SdkContext.attachDeviceEnvIfNeeded(event);
final signedEventSig = signed['event_sig'];
if (signedEventSig is String && signedEventSig.trim().isNotEmpty) {
return signed;
}
return event;
}
void _logAcceptedEvent(Map<String, dynamic> json) {
if (!SdkConfig.enableDebugReportLog) return;
try {
Logger.analyticsSdkDeveloperLog(
'accepted event json: ${jsonEncode(json)}',
);
} catch (e) {
Logger.analyticsSdk('事件日志打印失败: $e', level: LogLevel.warn);
}
}
void _writeDebugReportEvent(Map<String, dynamic> json) {
if (!kDebugMode || !SdkConfig.enableDebugReportLog) return;
try {
_persistence.appendDebugReportEvent(json);
} catch (e) {
Logger.analyticsSdk('debug 完整事件 JSONL 记录失败: $e', level: LogLevel.warn);
}
}
/// JSON 编码的静态方法,用于 compute isolate
static String _encodeBatchJson(List<Map<String, dynamic>> batch) {
return jsonEncode(batch);
}
/// 在后台 isolate 中异步执行 JSON 编码,避免阻塞主线程
Future<String> _encodeBatchAsync(List<Map<String, dynamic>> batch) async {
try {
if (batch.isEmpty) {
return '[]';
}
if (batch.length <= SdkConfig.isolateEncodingThreshold) {
return jsonEncode(batch);
}
try {
return await compute(_encodeBatchJson, batch);
} catch (e) {
Logger.analyticsSdk('compute 失败,回退到主线程编码: $e');
return jsonEncode(batch);
}
} catch (e) {
Logger.analyticsSdk('JSON 编码失败: $e');
return '[]';
}
}
/// 安排指数退避重试(事件仍在队列中,无需重新入队)
void _scheduleRetry() {
_backoffLevel = (_backoffLevel + 1).clamp(0, SdkConfig.maxBackoffLevel);
_retryTimer?.cancel();
final delay = _nextDelay();
_retryTimer = Timer(delay, () {
try {
Logger.analyticsSdk('指数退避重试 (延迟 ${delay.inSeconds}s)');
uploadBatch();
} catch (e) {
Logger.analyticsSdk('重试上报异常: $e');
}
});
}
/// 计算下次延迟时间(指数退避 + 上限)
Duration _nextDelay() {
final exponential = SdkConfig.baseRetryDelay * pow(2, _backoffLevel);
return exponential > SdkConfig.maxRetryDelay
? SdkConfig.maxRetryDelay
: exponential;
}
/// 上报成功时重置退避状态
void _resetBackoff() {
if (_backoffLevel > 0) {
_backoffLevel = 0;
_retryTimer?.cancel();
_retryTimer = null;
Logger.analyticsSdk('上报成功,退避机制已重置');
}
}
Future<void> initPersistence() async {
try {
await _persistence.init();
final debugPath = debugReportEventLogPath;
if (debugPath != null && debugPath.isNotEmpty) {
Logger.analyticsSdk('debug 完整事件 JSONL 文件: $debugPath');
}
} catch (e) {
Logger.analyticsSdk('持久化初始化失败: $e');
}
}
Future<void> loadCachedEvents() async {
try {
final recovered = await _persistence.loadEvents(
queue: _eventQueue,
maxQueueSize: SdkConfig.maxQueueSize,
maxCacheLines: SdkConfig.maxCacheLines,
isEventTypeEnabled: EventTypeConfigManager.instance.isEventTypeEnabled,
);
if (recovered.isNotEmpty) {
final availableSpace = SdkConfig.maxQueueSize - _eventQueue.length;
if (availableSpace > 0) {
final eventsToRecover = recovered.length > availableSpace
? recovered.sublist(0, availableSpace)
: recovered;
_eventQueue.addAll(eventsToRecover);
Logger.analyticsSdk('从缓存恢复 ${eventsToRecover.length} 条事件');
} else {
Logger.analyticsSdk('警告:队列已满,无法恢复缓存事件');
}
}
_flushIfNeeded();
} catch (e) {
Logger.analyticsSdk('缓存加载失败: $e');
}
}
Future<void> clearCache() async {
await _persistence.clearCache();
}
/// 释放资源(定时器、HttpClient、缓存 buffer 等)
void dispose() {
try {
stopAutoUploadTimer();
_retryTimer?.cancel();
_retryTimer = null;
_isUploading = false;
_persistence.flushBuffer();
_httpClient?.close();
_httpClient = null;
} catch (e) {
Logger.analyticsSdk('EventProcessor.dispose 异常: $e');
}
}
/// 测试专用入口:触发 tombstone 写入以供单元测试验证缓存移除行为
@visibleForTesting
Future<void> removeEventsFromCacheForTest(
List<Map<String, dynamic>> events) =>
_persistence.removeEvents(events);
/// 测试专用入口:注入 HTTP client 以验证最终请求体。
@visibleForTesting
void setHttpClientForTest(http.Client client) {
_httpClient?.close();
_httpClient = client;
}
/// 异步释放资源(先 await 缓存刷写,再释放其他资源)
///
/// 推荐在 SDK dispose 时使用此方法替代 [dispose],确保缓存 buffer 写入磁盘。
Future<void> disposeAsync() async {
try {
stopAutoUploadTimer();
_retryTimer?.cancel();
_retryTimer = null;
_isUploading = false;
await _persistence.flushBufferAsync();
_httpClient?.close();
_httpClient = null;
} catch (e) {
Logger.analyticsSdk('EventProcessor.disposeAsync 异常: $e');
}
}
}
@@ -0,0 +1,168 @@
import 'dart:convert';
import 'package:analytics_sdk/config/sdk_config.dart';
import 'package:analytics_sdk/utils/logger.dart';
import 'package:analytics_sdk/utils/platform_storage.dart';
/// 事件类型配置管理器:解析并缓存业务方传入的事件类型配置
class EventTypeConfigManager {
static final EventTypeConfigManager instance =
EventTypeConfigManager._internal();
factory EventTypeConfigManager() => instance;
EventTypeConfigManager._internal();
Set<String> _enabledEventTypes = {};
/// 获取已启用的事件类型集合
Set<String> get enabledEventTypes => Set.unmodifiable(_enabledEventTypes);
/// 检查某个事件类型是否被启用
/// 如果配置为空,默认允许所有事件上报(向后兼容)
bool isEventTypeEnabled(String eventType) {
if (_enabledEventTypes.isEmpty) return true;
return _enabledEventTypes.contains(eventType);
}
/// 传入已解密的事件类型配置 JSON 字符串,解析后写入本地缓存
///
/// 支持格式:
/// - 字符串数组:`["app_page_view", "ad_impression", ...]`
/// - JSON 对象:`{"enabled_event_types": [...]}`
///
/// 返回解析到的事件类型数量
Future<int> initWithConfig(String decryptedJson) async {
try {
final eventTypes = _parseEventTypes(decryptedJson);
if (eventTypes.isNotEmpty) {
_enabledEventTypes = eventTypes;
await _saveConfigToCache(eventTypes);
Logger.eventTypeConfigManager(
'事件类型配置已更新,共 ${eventTypes.length} 个: ${eventTypes.join(", ")}');
} else {
Logger.eventTypeConfigManager('解析结果为空,保持原有配置');
}
return eventTypes.length;
} catch (e) {
Logger.eventTypeConfigManager('initWithConfig 异常: $e');
return 0;
}
}
/// 从本地缓存加载配置(若无缓存则保持 allow-all 状态)
Future<void> loadCachedConfig() async {
try {
await _loadCachedConfig();
} catch (e) {
Logger.eventTypeConfigManager('加载缓存配置异常: $e');
}
}
/// 清理资源
void dispose() {
_enabledEventTypes.clear();
}
/// 解析 JSON 字符串,提取事件类型列表(供外部验证用,如 validateEncryptedConfig)
///
/// 返回 List<String>,解析失败或结果为空则返回空列表,不抛出异常。
static List<String> parseForValidation(String json) {
try {
dynamic decoded = jsonDecode(json);
if (decoded is String && decoded.trim().isNotEmpty) {
try {
decoded = jsonDecode(decoded);
} catch (_) {}
}
if (decoded is List) {
return (decoded as List)
.map<String>((e) => e.toString().trim())
.where((e) => e.isNotEmpty)
.toList();
} else if (decoded is Map<String, dynamic>) {
final list = decoded['enabled_event_types'] as List? ??
decoded['event_types'] as List? ??
decoded['data'] as List?;
if (list != null) {
return list
.map<String>((e) => e.toString().trim())
.where((e) => e.isNotEmpty)
.toList();
}
}
} catch (_) {}
return [];
}
// ==================== 私有方法 ====================
/// 解析 JSON 字符串,提取事件类型列表
Set<String> _parseEventTypes(String json) {
try {
dynamic decoded = jsonDecode(json);
// 兼容外层为字符串的格式:"[\"event_type\", ...]"
if (decoded is String && decoded.trim().isNotEmpty) {
try {
decoded = jsonDecode(decoded);
} catch (_) {}
}
if (decoded is List) {
return (decoded as List)
.map<String>((e) => e.toString().trim())
.where((e) => e.isNotEmpty)
.toSet();
} else if (decoded is Map<String, dynamic>) {
final list = decoded['enabled_event_types'] as List? ??
decoded['event_types'] as List? ??
decoded['data'] as List?;
if (list != null) {
return list
.map<String>((e) => e.toString().trim())
.where((e) => e.isNotEmpty)
.toSet();
}
}
} catch (e) {
Logger.eventTypeConfigManager('事件类型 JSON 解析失败: $e');
}
return {};
}
Future<void> _saveConfigToCache(Set<String> eventTypes) async {
try {
await PlatformStorage.write(
SdkConfig.eventTypeConfigCacheFileName,
jsonEncode({
'enabled_event_types': eventTypes.toList(),
'updated_at': DateTime.now().toIso8601String(),
}),
);
Logger.eventTypeConfigManager('配置已保存到缓存');
} catch (e) {
Logger.eventTypeConfigManager('保存缓存失败: $e');
}
}
Future<void> _loadCachedConfig() async {
try {
final content =
await PlatformStorage.read(SdkConfig.eventTypeConfigCacheFileName);
if (content == null) {
Logger.eventTypeConfigManager('缓存文件不存在');
return;
}
final json = jsonDecode(content) as Map<String, dynamic>;
final list = json['enabled_event_types'];
if (list is List) {
_enabledEventTypes = (list as List)
.map<String>((e) => e.toString().trim())
.where((e) => e.isNotEmpty)
.toSet();
Logger.eventTypeConfigManager(
'从缓存加载 ${_enabledEventTypes.length} 个事件类型');
}
} catch (e) {
Logger.eventTypeConfigManager('加载缓存失败: $e');
}
}
}
@@ -0,0 +1,3 @@
Future<Map<String, dynamic>> collectNetworkStateFields() async {
return const {};
}
@@ -0,0 +1,65 @@
// ignore: avoid_web_libraries_in_flutter
import 'dart:html' as html;
import 'package:analytics_sdk/utils/js_interop_compat.dart';
Future<Map<String, dynamic>> collectNetworkStateFields() async {
final connection = _connection(html.window.navigator);
if (connection == null) return const {};
final fields = <String, dynamic>{};
fields['connection_type'] = _string(connection, 'type', 'unknown');
fields['effective_type'] = _effectiveType(connection);
fields['downlink'] = _downlinkKbps(connection);
fields['rtt'] = _number(connection, 'rtt', -1).round();
fields.removeWhere((key, value) {
if (value is String) return value.trim().isEmpty;
if (value is num) return value < 0;
return value == null;
});
return fields;
}
Object? _connection(html.Navigator navigator) {
for (final key in ['connection', 'mozConnection', 'webkitConnection']) {
try {
if (hasJsProperty(navigator, key)) {
return getJsProperty(navigator, key);
}
} catch (_) {}
}
return null;
}
String _string(Object? object, String key, String fallback) {
if (object == null) return fallback;
try {
final value = getJsProperty(object, key);
final string = value?.toString().trim() ?? '';
return string.isEmpty ? fallback : string;
} catch (_) {
return fallback;
}
}
num _number(Object? object, String key, num fallback) {
if (object == null) return fallback;
try {
final value = getJsProperty(object, key);
if (value is num && value.isFinite) return value;
return num.tryParse(value?.toString() ?? '') ?? fallback;
} catch (_) {
return fallback;
}
}
String _effectiveType(Object? connection) {
final value = _string(connection, 'effectiveType', 'unknown').toLowerCase();
if (value == 'slow-2g') return '2g';
const allowed = {'2g', '3g', '4g', '5g'};
return allowed.contains(value) ? value : 'unknown';
}
int _downlinkKbps(Object? connection) {
final downlink = _number(connection, 'downlink', -1);
return downlink >= 0 ? (downlink * 1000).round() : -1;
}
@@ -0,0 +1,2 @@
export 'page_click_auto_fields_stub.dart'
if (dart.library.html) 'page_click_auto_fields_web.dart';
@@ -0,0 +1,33 @@
class ClickDocumentMetrics {
final int clickDocumentX;
final int clickDocumentY;
final int pageWidth;
final int pageHeight;
final int scrollTop;
final int scrollLeft;
const ClickDocumentMetrics({
required this.clickDocumentX,
required this.clickDocumentY,
required this.pageWidth,
required this.pageHeight,
required this.scrollTop,
required this.scrollLeft,
});
static const unknown = ClickDocumentMetrics(
clickDocumentX: -1,
clickDocumentY: -1,
pageWidth: -1,
pageHeight: -1,
scrollTop: -1,
scrollLeft: -1,
);
}
ClickDocumentMetrics currentClickDocumentMetrics({
required int clickViewportX,
required int clickViewportY,
}) {
return ClickDocumentMetrics.unknown;
}
@@ -0,0 +1,77 @@
// ignore: avoid_web_libraries_in_flutter
import 'dart:html' as html;
class ClickDocumentMetrics {
final int clickDocumentX;
final int clickDocumentY;
final int pageWidth;
final int pageHeight;
final int scrollTop;
final int scrollLeft;
const ClickDocumentMetrics({
required this.clickDocumentX,
required this.clickDocumentY,
required this.pageWidth,
required this.pageHeight,
required this.scrollTop,
required this.scrollLeft,
});
static const unknown = ClickDocumentMetrics(
clickDocumentX: -1,
clickDocumentY: -1,
pageWidth: -1,
pageHeight: -1,
scrollTop: -1,
scrollLeft: -1,
);
}
ClickDocumentMetrics currentClickDocumentMetrics({
required int clickViewportX,
required int clickViewportY,
}) {
try {
final documentElement = html.document.documentElement;
final body = html.document.body;
final pageWidth = _maxPositive([
documentElement?.scrollWidth,
body?.scrollWidth,
documentElement?.clientWidth,
body?.clientWidth,
]);
final pageHeight = _maxPositive([
documentElement?.scrollHeight,
body?.scrollHeight,
documentElement?.clientHeight,
body?.clientHeight,
]);
if (pageWidth <= 0 || pageHeight <= 0) {
return ClickDocumentMetrics.unknown;
}
final scrollLeft = _nonNegative(html.window.pageXOffset.round());
final scrollTop = _nonNegative(html.window.pageYOffset.round());
return ClickDocumentMetrics(
clickDocumentX: _nonNegative(clickViewportX + scrollLeft),
clickDocumentY: _nonNegative(clickViewportY + scrollTop),
pageWidth: pageWidth,
pageHeight: pageHeight,
scrollTop: scrollTop,
scrollLeft: scrollLeft,
);
} catch (_) {
return ClickDocumentMetrics.unknown;
}
}
int _maxPositive(List<int?> values) {
var max = -1;
for (final value in values) {
if (value != null && value > max) max = value;
}
return max > 0 ? max : -1;
}
int _nonNegative(int value) => value < 0 ? 0 : value;
@@ -0,0 +1,177 @@
// lib/utils/page_name_mapper.dart
import 'package:analytics_sdk/utils/logger.dart';
/// 统一的页面 pageKey → pageName 映射管理器
/// 支持静态默认映射 + 动态添加/覆盖(业务方可在初始化时自定义)
///
/// ## 两种注册格式
///
/// **老格式(精确匹配)**:值为字符串,pageKey 与路由名称完全一致时命中
/// ```dart
/// PageNameMapper.addMappings({'home': '首页'});
/// ```
///
/// **新格式(支持 `:param` 动态路由)**:值为 `{'key': ..., 'name': ...}`
/// - `key`:上报时使用的稳定 pageKey(替换掉含真实 ID 的原始路由名)
/// - `name`:页面展示名称
/// ```dart
/// PageNameMapper.addMappings({
/// '/video/:id': {'key': 'video_detail', 'name': '视频详情'},
/// });
/// ```
class PageNameMapper {
// 默认映射表(SDK 内置,精确匹配)
static const Map<String, String> _defaultMap = {};
// 老格式:精确匹配映射表(路由名 → pageName)
static final Map<String, String> _customMap = {};
// 新格式::param 模式映射表(pattern → ResolvedPageInfo)
static final Map<String, ResolvedPageInfo> _patternMap = {};
// 新格式 resolvedKey 缓存(resolvedKey → pageName),与 _customMap 隔离
// 供 getPageName 在 currentPageKey 已被解析为稳定 key 时反查 pageName
static final Map<String, String> _resolvedKeyMap = {};
static const String _tag = 'PageNameMapper';
/// 去除 pageKey 前导斜杠,结果为空时保留原值(如纯 "/" 保持为 "/")
static String normalizeKey(String pageKey) {
if (!pageKey.startsWith('/')) return pageKey;
final stripped = pageKey.substring(1);
return stripped.isEmpty ? pageKey : stripped;
}
/// 获取页面名称(老格式精确匹配,行为与之前完全一致)
///
/// 同时查 _resolvedKeyMap,确保新格式解析后的稳定 key 也能反查到 pageName
static String getPageName(String pageKey) {
final key = normalizeKey(pageKey);
return _customMap[key] ?? _resolvedKeyMap[key] ?? _defaultMap[key] ?? key;
}
/// 解析路由名称,返回上报用的稳定 [ResolvedPageInfo]。
///
/// 查找优先级:
/// 1. 老格式精确匹配(_customMap / _defaultMap)
/// 2. 新格式精确匹配(_patternMap)
/// 3. 新格式 `:param` 通配匹配(_patternMap)
/// 4. Fallback:key = name = 归一化后的路由名
static ResolvedPageInfo resolvePageInfo(String routeName) {
final normalized = normalizeKey(routeName);
// 1. 老格式精确匹配(只查 _customMap / _defaultMap,不查 _resolvedKeyMap)
final directName = _customMap[normalized] ?? _defaultMap[normalized];
if (directName != null) return ResolvedPageInfo(normalized, directName);
// 2. 新格式精确匹配
final exactPattern = _patternMap[normalized];
if (exactPattern != null) return exactPattern;
// 3. 新格式 :param 通配匹配
for (final entry in _patternMap.entries) {
if (_matchesPattern(entry.key, normalized)) {
return entry.value;
}
}
// 4. Fallback
return ResolvedPageInfo(normalized, normalized);
}
/// 动态添加或覆盖单个映射
///
/// [value] 可为字符串(老格式)或 `{'key': String, 'name': String}`(新格式)
static void addMapping(String routePattern, dynamic value) {
final normalized = normalizeKey(routePattern);
if (value is String) {
_customMap[normalized] = value;
} else if (value is Map) {
// ── 风险 1 修复:严格校验 key / name 字段,避免运行时 crash ──
final resolvedKey = value['key'];
final name = value['name'];
if (resolvedKey is! String || resolvedKey.isEmpty) {
Logger.warn(_tag, 'addMapping("$routePattern") 的 key 字段无效或为空,已忽略');
return;
}
if (name is! String || name.isEmpty) {
Logger.warn(_tag, 'addMapping("$routePattern") 的 name 字段无效或为空,已忽略');
return;
}
// ── 风险 3 修复:检测与已注册 pattern 的歧义冲突 ──
if (_hasConflictingPattern(normalized)) {
Logger.warn(
_tag,
'addMapping("$routePattern") 与已注册的某个 pattern 存在歧义,'
'将按注册顺序匹配,请检查是否重复注册',
);
}
_patternMap[normalized] = ResolvedPageInfo(resolvedKey, name);
// ── 风险 2 修复:写入独立的 _resolvedKeyMap,不污染 _customMap ──
_resolvedKeyMap[resolvedKey] = name;
}
}
/// 批量动态添加或覆盖映射,兼容老格式与新格式混传
static void addMappings(Map<String, dynamic> mappings) {
for (final entry in mappings.entries) {
addMapping(entry.key, entry.value);
}
}
/// 清空自定义映射(测试或重置用)
static void clearCustom() {
_customMap.clear();
_patternMap.clear();
_resolvedKeyMap.clear();
}
/// 判断路由名称是否匹配某个 :param 模式
static bool _matchesPattern(String pattern, String routeName) {
final patternParts = pattern.split('/');
final routeParts = routeName.split('/');
if (patternParts.length != routeParts.length) return false;
for (int i = 0; i < patternParts.length; i++) {
if (patternParts[i].startsWith(':')) continue; // 通配符,跳过
if (patternParts[i] != routeParts[i]) return false;
}
return true;
}
/// 检测新 pattern 是否与已注册的 pattern 存在歧义(可能命中同一路由)
static bool _hasConflictingPattern(String newPattern) {
for (final existing in _patternMap.keys) {
if (existing == newPattern) continue; // 相同 pattern,属于覆盖,不是冲突
if (_patternsConflict(existing, newPattern)) return true;
}
return false;
}
/// 判断两个 pattern 是否可能匹配同一条路由
static bool _patternsConflict(String p1, String p2) {
final parts1 = p1.split('/');
final parts2 = p2.split('/');
if (parts1.length != parts2.length) return false;
for (int i = 0; i < parts1.length; i++) {
final isParam1 = parts1[i].startsWith(':');
final isParam2 = parts2[i].startsWith(':');
// 两段都是固定字面量且不相等 → 不可能同时匹配
if (!isParam1 && !isParam2 && parts1[i] != parts2[i]) return false;
}
return true;
}
}
/// [PageNameMapper.resolvePageInfo] 的返回值:路由解析后的稳定标识
class ResolvedPageInfo {
/// 上报用的稳定 pageKey(新格式下为业务方自定义 key,老格式下为归一化路由名)
final String key;
/// 页面展示名称
final String name;
const ResolvedPageInfo(this.key, this.name);
}
@@ -0,0 +1,41 @@
import 'package:analytics_sdk/utils/logger.dart';
/// 校验页面生命周期 Observer 的接入状态,并避免重复刷屏。
class PageObserverRegistrationGuard {
bool _missingWarningLogged = false;
bool _duplicateWarningLogged = false;
void reset() {
_missingWarningLogged = false;
_duplicateWarningLogged = false;
}
void check({
required bool pageObserverAttached,
required bool sessionObserverAttached,
}) {
if (!pageObserverAttached && !sessionObserverAttached) {
if (_missingWarningLogged) return;
_missingWarningLogged = true;
Logger.pageLifecycleObserver(
'未检测到页面 Observer:请在 MaterialApp.navigatorObservers 中注册 '
'AnalyticsSdk.instance.pageObserver 或 '
'AnalyticsSdk.instance.sessionObserver,二选一。否则 '
'app_session_summary 的 page_stay_list 可能不完整。',
level: LogLevel.warn,
);
return;
}
if (pageObserverAttached && sessionObserverAttached) {
if (_duplicateWarningLogged) return;
_duplicateWarningLogged = true;
Logger.pageLifecycleObserver(
'检测到 pageObserver 与 sessionObserver 同时注册:两者必须二选一。'
'SDK 已对页面停留和自动 app_page_view 做幂等保护,'
'但请尽快修正接入配置。',
level: LogLevel.warn,
);
}
}
}
@@ -0,0 +1,2 @@
export 'page_view_auto_fields_stub.dart'
if (dart.library.html) 'page_view_auto_fields_web.dart';
@@ -0,0 +1 @@
String currentPageUrl() => '';
@@ -0,0 +1,27 @@
// ignore: avoid_web_libraries_in_flutter
import 'dart:html' as html;
String currentPageUrl() {
try {
final uri = Uri.tryParse(html.window.location.href);
if (uri == null) return '';
final buffer = StringBuffer();
if (uri.scheme.isNotEmpty) {
buffer
..write(uri.scheme)
..write('://');
}
if (uri.host.isNotEmpty) {
buffer.write(uri.host);
}
if (uri.hasPort) {
buffer.write(':${uri.port}');
}
if (uri.path.isNotEmpty) {
buffer.write(uri.path);
}
return buffer.toString();
} catch (_) {
return '';
}
}
@@ -0,0 +1,21 @@
import 'package:analytics_sdk/utils/platform_storage.dart';
class SdkActivationStore {
SdkActivationStore._();
static const String _storageKey = 'analytics_sdk_activation_time.txt';
static Future<int> getOrCreateActivationTime() async {
try {
final cached = await PlatformStorage.read(_storageKey);
final cachedTs = int.tryParse(cached?.trim() ?? '');
if (cachedTs != null && cachedTs > 0) return cachedTs;
final now = DateTime.now().millisecondsSinceEpoch ~/ 1000;
await PlatformStorage.write(_storageKey, now.toString());
return now;
} catch (_) {
return 0;
}
}
}
+439
View File
@@ -0,0 +1,439 @@
import 'dart:convert';
import 'package:analytics_sdk/utils/device_fingerprint_util.dart';
import 'package:analytics_sdk/utils/logger.dart';
import 'package:crypto/crypto.dart';
import 'package:flutter/foundation.dart';
/// SDK 全局上下文:持有所有公共字段、提供 event_id 计算。
///
/// 作为 SDK 内部的单一状态来源(SSOT),所有事件构造时从此处读取公共字段。
/// 不对外暴露,接入方无需直接使用。
class SdkContext {
/// SDK 版本号(写死,与 pubspec.yaml 保持一致)
static const String kSdkVersion = '1.4.6';
/// appVersion 公共字段默认值。
/// 接入方未传 `appVersion`、或 release 模式下传入非法值时均使用该值,
/// 保证事件 payload 与 `AnalyticsSdk.getParams()` 返回值保持一致。
static const String kDefaultAppVersion = '1.0.0';
/// 客户端事件签名规则版本。
static const String kEventSignatureVersion = '2.0.0';
static String _eventSignatureHmacKey = '';
static String _eventSignatureVersion = kEventSignatureVersion;
static String? _appId;
static String? _channel;
static String? _uid;
static String? _appVersion;
static String? _device;
static String? _deviceId;
static String? _deviceBrand;
static String? _deviceModel;
static String? _userAgent;
static String? _systemName;
static String? _systemVersion;
static String? _deviceFingerprint;
static String _fingerprintVersion = DeviceFingerprintUtil.kVersion;
static Map<String, dynamic> _environmentFields = _defaultEnvironmentFields();
/// SID 提供者(由 AnalyticsSdk.init() 注入)。
/// 调用时同时返回当前 SID 并记录用户行为,避免 entity 层直接依赖 SessionManager。
static String Function()? sidProvider;
/// 配置公共字段
///
/// 所有参数均为可选,非 null 值才会覆盖现有值。
/// 通常在 [AnalyticsSdk.init] 中调用,也可在 init 之后按需更新。
/// deviceFingerprint / fingerprintVersion 不再支持从外部传入。
static void configure({
String? appId,
String? channel,
String? uid,
String? appVersion,
String? device,
String? deviceId,
String? deviceBrand,
String? deviceModel,
String? userAgent,
String? systemName,
String? systemVersion,
String? deviceFingerprint,
String? fingerprintVersion,
Map<String, dynamic>? environmentFields,
}) {
if (appId != null) _appId = appId;
if (channel != null) _channel = channel;
if (uid != null) _uid = uid;
if (appVersion != null) {
if (RegExp(r'^\d+\.\d+\.\d+$').hasMatch(appVersion)) {
_appVersion = appVersion;
} else if (kDebugMode) {
Logger.analyticsSdk('appVersion 格式不正确,应为 x.y.z,当前值:$appVersion,已忽略',
level: LogLevel.error);
} else {
_appVersion = kDefaultAppVersion;
}
}
if (device != null) _device = device;
if (deviceId != null) _deviceId = deviceId;
if (deviceBrand != null) _deviceBrand = deviceBrand;
if (deviceModel != null) _deviceModel = deviceModel;
if (userAgent != null) _userAgent = userAgent;
if (systemName != null) _systemName = systemName;
if (systemVersion != null) _systemVersion = systemVersion;
if (environmentFields != null) {
_environmentFields = {
..._defaultEnvironmentFields(),
..._normalizeEnvironmentFields(environmentFields),
};
}
}
/// SDK 内部调用:重置自动生成的设备指纹状态。
static void resetGeneratedDeviceFingerprint() {
_deviceFingerprint = null;
_fingerprintVersion = DeviceFingerprintUtil.kVersion;
}
/// SDK 内部调用:写入已生成的设备指纹。
static void configureGeneratedDeviceFingerprint(String deviceFingerprint) {
final normalized = deviceFingerprint.trim();
_deviceFingerprint = normalized.isEmpty ? null : normalized;
_fingerprintVersion = DeviceFingerprintUtil.kVersion;
}
/// 更新用户 ID(登录或切换账号时调用)
static void setUid(String newUid) {
_uid = newUid;
}
/// 更新渠道(渠道变更时调用)
static void setChannel(String channel) {
_channel = channel;
}
/// 增量更新系统、网络、硬件和浏览器环境快照字段。
///
/// 用于网络、电量、充电状态等运行期可能变化的字段刷新。
static void updateEnvironmentFields(Map<String, dynamic> fields) {
if (fields.isEmpty) return;
_environmentFields = {
..._environmentFields,
..._normalizeEnvironmentFields(fields),
};
}
/// 配置客户端事件签名密钥。
///
/// 密钥来自加密配置解密后的 `signKey`,不写入事件、payload、日志或缓存。
static void configureEventSignature({
String? signKey,
String? version,
}) {
if (signKey != null) {
_eventSignatureHmacKey = signKey.trim();
}
final normalizedVersion = version?.trim();
if (normalizedVersion != null && normalizedVersion.isNotEmpty) {
_eventSignatureVersion = normalizedVersion;
}
}
/// 重置所有公共字段为 null(SDK 重新初始化时使用)
///
/// 与 [configure] 不同,此方法会将所有字段清为 null,
/// 而 configure 中传 null 表示"不修改"。
static void reset() {
_appId = null;
_channel = null;
_uid = null;
_appVersion = null;
_device = null;
_deviceId = null;
_deviceBrand = null;
_deviceModel = null;
_userAgent = null;
_systemName = null;
_systemVersion = null;
_deviceFingerprint = null;
_fingerprintVersion = DeviceFingerprintUtil.kVersion;
_environmentFields = _defaultEnvironmentFields();
_eventSignatureHmacKey = '';
_eventSignatureVersion = kEventSignatureVersion;
sidProvider = null;
}
// ──────── Getters(调试 / 外部复用) ────────────────────────────────────
static String? get appId => _appId;
static String? get channel => _channel;
static String? get uid => _uid;
static String? get appVersion => _appVersion;
static String? get deviceId => _deviceId;
static String? get deviceBrand => _deviceBrand;
static String? get deviceModel => _deviceModel;
static String get deviceFingerprint => _deviceFingerprint ?? '';
static String get fingerprintVersion => _fingerprintVersion;
static Map<String, dynamic> get environmentFields =>
Map<String, dynamic>.from(_environmentFields);
/// 获取设备公共字段(供产品方在服务端上报事件时复用)
///
/// 返回的 Map 包含:sid、device、device_id、device_brand、device_model、
/// user_agent、system_name、system_version、device_fingerprint、fp_version。
/// 未配置的字段返回空字符串。
static Map<String, String> getDeviceCommonFields() {
String sid = '';
try {
sid = sidProvider?.call() ?? '';
} catch (_) {
// 未初始化或 SID 获取异常时保持为空,确保该读取 API 不抛异常。
}
return {
'sid': sid,
'device': _device ?? '',
'device_id': _deviceId ?? '',
'device_brand': _deviceBrand ?? '',
'device_model': _deviceModel ?? '',
'user_agent': _userAgent ?? '',
'system_name': _systemName ?? '',
'system_version': _systemVersion ?? '',
'device_fingerprint': _deviceFingerprint ?? '',
'fp_version': _fingerprintVersion,
};
}
/// 获取系统、网络、硬件和浏览器环境快照字段。
///
/// 仅供 SDK 内部构建 `sdk_init` 启动快照和少量汇总事件使用,普通事件公共字段
/// 不再重复携带这些环境快照。
static Map<String, dynamic> getEnvironmentSnapshotFields() {
return Map<String, dynamic>.from(_environmentFields);
}
/// 给 SDK 客户端事件补充外层签名字段。
///
/// 历史版本曾把轻量环境签名写入 payload;这里会清理这些保留字段,
/// 避免同一事件同时出现新旧两套签名。
static Map<String, dynamic> attachDeviceEnvIfNeeded(
Map<String, dynamic> event,
) {
final eventName = event['event']?.toString() ?? '';
final isServerOnlyEvent = _serverOnlyEvents.contains(eventName);
final result = Map<String, dynamic>.from(event);
final existingPayload = result['payload'];
final existingDeviceEnv = result.remove('device_env');
if (existingPayload is Map || existingPayload == null) {
final payload = <String, dynamic>{
if (existingPayload is Map)
...Map<String, dynamic>.from(existingPayload),
if (existingDeviceEnv is Map)
...Map<String, dynamic>.from(existingDeviceEnv),
};
payload.removeWhere(
(key, _) => _reservedPayloadSignatureFields.contains(key));
result['payload'] = payload;
}
if (!isServerOnlyEvent && _eventSignatureHmacKey.isNotEmpty) {
final eventForSignature = Map<String, dynamic>.from(result);
result['event_sig_version'] = _eventSignatureVersion;
result['event_sig'] = _eventSignature(eventForSignature);
}
return result;
}
/// 生成公共字段 Map
///
/// 包含所有客户端事件上报所需的公共字段:
/// event、channel、app_id、uid、sid、client_ts、
/// device、device_id、device_brand、device_model、
/// user_agent、system_name、system_version、
/// device_fingerprint、fp_version、sdk_version、app_version。
///
/// 本方法永远不会抛出异常。
static Map<String, dynamic> generateCommonFields(String event) {
final now = DateTime.now();
final clientTs = now.millisecondsSinceEpoch ~/ 1000;
String sid;
try {
sid = sidProvider?.call() ?? 'fallback_${now.millisecondsSinceEpoch}';
} catch (_) {
sid = 'fallback_${now.millisecondsSinceEpoch}';
}
return {
'event': event,
'channel': _channel ?? '',
'app_id': _appId ?? '',
'uid': _uid ?? '',
'sid': sid,
'client_ts': clientTs,
'device': _device ?? '',
'device_id': _deviceId ?? '',
'device_brand': _deviceBrand ?? '',
'device_model': _deviceModel ?? '',
'user_agent': _userAgent ?? '',
'system_name': _systemName ?? '',
'system_version': _systemVersion ?? '',
'device_fingerprint': _deviceFingerprint ?? '',
'fp_version': _fingerprintVersion,
'sdk_version': kSdkVersion,
'app_version': _appVersion ?? kDefaultAppVersion,
};
}
/// 根据字段列表生成 event_id
///
/// 计算规则:
/// 1. 对每个字段值单独做 MD5
/// 2. 将所有 MD5 用 `|` 拼接
/// 3. 对拼接结果再做一次 MD5,作为最终 event_id
///
/// **注意**:`device_fingerprint` 不参与 event_id 计算。
/// 本方法永远不会抛出异常,失败时返回基于时间戳的备选 ID。
static String generateEventId(List<String> fields) {
try {
final fieldDigests = <String>[];
for (final value in fields) {
final v = value;
final bytes = utf8.encode(v);
final digest = md5.convert(bytes);
fieldDigests.add(digest.toString());
}
final joined = fieldDigests.join('|');
final finalDigest = md5.convert(utf8.encode(joined));
return finalDigest.toString();
} catch (e) {
try {
final content = fields.join('|');
return 'h${(content.hashCode & 0x7FFFFFFF).toRadixString(16)}';
} catch (_) {
return 't${DateTime.now().millisecondsSinceEpoch}';
}
}
}
static Map<String, dynamic> _defaultEnvironmentFields() => {
'language': '',
'timezone': '',
'package_name': '',
'screen_width': -1,
'screen_height': -1,
'screen_dpi': -1,
'device_pixel_ratio': -1,
'orientation': '',
'screen_brightness': -1,
'volume': -1,
'is_screen_on': -1,
'network_type': 'unknown',
'battery_level': -1,
'is_charging': -1,
'battery_state': 'unknown',
'cpu_cores': -1,
'device_memory_gb': -1,
'browser_name': '',
'browser_version': '',
'cookie_enabled': -1,
'local_storage_enabled': -1,
'touch_support': -1,
'max_touch_points': -1,
'color_depth': -1,
'is_pwa': -1,
};
static Map<String, dynamic> _normalizeEnvironmentFields(
Map<String, dynamic> fields,
) {
final normalized = Map<String, dynamic>.from(fields);
if (normalized.containsKey('device_pixel_ratio')) {
normalized['device_pixel_ratio'] =
_normalizeDevicePixelRatio(normalized['device_pixel_ratio']);
}
return normalized;
}
static int _normalizeDevicePixelRatio(dynamic value) {
if (value == null) return -1;
final ratio =
value is num ? value.toDouble() : double.tryParse(value.toString());
if (ratio == null || !ratio.isFinite) return -1;
if (ratio > 10) return ratio.round();
return (ratio * 100).round();
}
static const Set<String> _serverOnlyEvents = {
'user_register',
'user_login',
'realtime_online',
'order_created',
'order_paid',
'coin_consume',
'video_status_change',
};
static const Set<String> _reservedPayloadSignatureFields = {
'sdk_type',
'client_type',
'id_type',
'event_sig',
'event_sig_version',
'security_key_version',
'sign_algorithm',
'sign_ts',
};
static String _eventSignature(
Map<String, dynamic> event,
) {
try {
if (_eventSignatureHmacKey.isEmpty) return '';
final factors = [
event['event']?.toString() ?? '',
event['event_id']?.toString() ?? '',
event['app_id']?.toString() ?? '',
event['client_ts']?.toString() ?? '',
event['device_fingerprint']?.toString() ?? '',
];
final signatureInput = factors.join('|');
final signature = Hmac(sha256, utf8.encode(_eventSignatureHmacKey))
.convert(utf8.encode(signatureInput))
.toString();
Logger.analyticsSdk(
'event_sig input="$signatureInput" '
'event_sig=$signature '
'event_sig_version=$_eventSignatureVersion',
);
return signature;
} catch (_) {
return '';
}
}
/// 从公共字段 Map 和业务特有字段列表生成 event_id。
///
/// 本方法永远不会抛出异常。
static String generateEventIdFromCommonFields(
Map<String, dynamic> commonFields, List<String> extraFields) {
return generateEventId([
commonFields['event'] as String? ?? '',
commonFields['app_id'] as String? ?? '',
commonFields['channel'] as String? ?? '',
commonFields['uid'] as String? ?? '',
commonFields['device'] as String? ?? '',
commonFields['device_id'] as String? ?? '',
commonFields['device_brand'] as String? ?? '',
commonFields['device_model'] as String? ?? '',
(commonFields['client_ts'] ?? '').toString(),
commonFields['sid'] as String? ?? '',
...extraFields,
]);
}
}
@@ -0,0 +1,239 @@
import 'package:flutter/foundation.dart';
/// Builds the sdk_init payload from the SDK common fields plus platform-only
/// fields. Web and app clients intentionally carry different field families.
class SdkInitPayloadBuilder {
SdkInitPayloadBuilder._();
static Map<String, dynamic> build({
required Map<String, dynamic> commonFields,
required bool isWeb,
Map<String, dynamic> platformFields = const {},
}) {
final fields = {
...commonFields,
...platformFields,
};
final osFamily = _osFamily(_string(fields['system_name'], 'unknown'));
final payload = <String, dynamic>{
'platform': isWeb ? 'web' : 'flutter',
'os_family': osFamily,
'device_type': _deviceType(_string(fields['device'], '')),
'language': _string(fields['language'], 'unknown'),
'timezone': _string(fields['timezone'], 'unknown'),
'network_type': _string(fields['network_type'], 'unknown'),
'screen_width': _int(fields['screen_width'], -1),
'screen_height': _int(fields['screen_height'], -1),
'screen_dpi': _int(fields['screen_dpi'], -1),
'device_pixel_ratio': _devicePixelRatio(fields['device_pixel_ratio']),
'orientation': _string(fields['orientation'], 'unknown'),
'cpu_cores': _int(fields['cpu_cores'], -1),
'device_memory_gb': _int(fields['device_memory_gb'], -1),
'is_debug': kDebugMode ? 1 : 0,
'flutter_build_mode': _string(fields['flutter_build_mode'], _buildMode()),
'touch_support': _int(fields['touch_support'], -1),
'max_touch_points': _int(fields['max_touch_points'], -1),
};
if (isWeb) {
payload.addAll(_webPayload(fields));
} else if (osFamily == 'android') {
payload.addAll(_androidPayload(fields));
} else if (osFamily == 'ios') {
payload.addAll(_iosPayload(fields));
} else {
payload.addAll(_flutterPayload());
}
return payload;
}
static Map<String, dynamic> _nativeSharedPayload(
Map<String, dynamic> fields,
) {
return {
'carrier': _string(fields['carrier'], ''),
'mcc': _string(fields['mcc'], 'unknown'),
'mnc': _string(fields['mnc'], 'unknown'),
'has_sim': _int(fields['has_sim'], -1),
'storage': _int(fields['storage'], -1),
'available_storage': _int(fields['available_storage'], -1),
'battery_level': _int(fields['battery_level'], -1),
'is_charging': _int(fields['is_charging'], -1),
'vpn_enabled': _int(fields['vpn_enabled'], -1),
'proxy_enabled': _int(fields['proxy_enabled'], -1),
};
}
static Map<String, dynamic> _androidPayload(Map<String, dynamic> fields) {
return {
..._nativeSharedPayload(fields),
'activation_time': _int(fields['activation_time'], 0),
'install_source': _string(fields['install_source'], ''),
'android_id': _string(fields['android_id'], ''),
'android_id_o': _string(fields['android_id_o'], ''),
'android_id_status': _string(fields['android_id_status'], 'unknown'),
'oaid': _string(fields['oaid'], ''),
'oaid_o': _string(fields['oaid_o'], ''),
'aaid': _string(fields['aaid'], ''),
'aaid_o': _string(fields['aaid_o'], ''),
'guid': _string(fields['guid'], ''),
'guid_o': _string(fields['guid_o'], ''),
'brand':
_string(fields['brand'], _string(fields['device_brand'], 'unknown')),
'manufacturer': _string(fields['manufacturer'], 'unknown'),
'model':
_string(fields['model'], _string(fields['device_model'], 'unknown')),
'package_name': _string(fields['package_name'], 'unknown'),
'abi': _string(fields['abi'], 'unknown'),
'is_rooted': _int(fields['is_rooted'], -1),
'usb_debug_enabled': _int(fields['usb_debug_enabled'], -1),
'developer_options_enabled':
_int(fields['developer_options_enabled'], -1),
'adb_enabled': _int(fields['adb_enabled'], -1),
'has_baseband': _int(fields['has_baseband'], -1),
'sensor_count_supported': _int(fields['sensor_count_supported'], -1),
'has_accelerometer': _int(fields['has_accelerometer'], -1),
'has_gyroscope': _int(fields['has_gyroscope'], -1),
'has_magnetometer': _int(fields['has_magnetometer'], -1),
'has_light_sensor': _int(fields['has_light_sensor'], -1),
'has_proximity_sensor': _int(fields['has_proximity_sensor'], -1),
'build_hardware': _string(fields['build_hardware'], 'unknown'),
'build_product': _string(fields['build_product'], 'unknown'),
'build_fingerprint': _string(fields['build_fingerprint'], 'unknown'),
};
}
static Map<String, dynamic> _iosPayload(Map<String, dynamic> fields) {
return {
..._nativeSharedPayload(fields),
'bundle_id': _string(fields['bundle_id'], ''),
'ios_device_model_raw': _string(fields['ios_device_model_raw'], ''),
'idfv': _string(fields['idfv'], ''),
'idfa': _string(fields['idfa'], ''),
'att_status': _string(fields['att_status'], 'unknown'),
'lat': _int(fields['lat'], -1),
'is_jailbroken': _int(fields['is_jailbroken'], -1),
};
}
static Map<String, dynamic> _flutterPayload() {
return const {};
}
static Map<String, dynamic> _webPayload(Map<String, dynamic> fields) {
return {
'current_url': _string(fields['current_url'], ''),
'current_domain': _string(fields['current_domain'], ''),
'current_path': _string(fields['current_path'], ''),
'current_query_hash': _string(fields['current_query_hash'], ''),
'referer': _string(fields['referer'], ''),
'referer_domain': _string(fields['referer_domain'], ''),
'referer_path': _string(fields['referer_path'], ''),
'browser_name': _string(fields['browser_name'], 'unknown'),
'browser_version': _string(fields['browser_version'], 'unknown'),
'browser_kernel': _string(fields['browser_kernel'], 'unknown'),
'display_mode': _string(fields['display_mode'], 'unknown'),
'visibility_state': _string(fields['visibility_state'], 'unknown'),
'cookie_enabled': _int(fields['cookie_enabled'], -1),
'local_storage_enabled': _int(fields['local_storage_enabled'], -1),
'session_storage_enabled': _int(fields['session_storage_enabled'], -1),
'indexeddb_enabled': _int(fields['indexeddb_enabled'], -1),
'service_worker_enabled': _int(fields['service_worker_enabled'], -1),
'connection_type': _string(fields['connection_type'], 'unknown'),
'effective_type': _string(fields['effective_type'], 'unknown'),
'downlink': _int(fields['downlink'], -1),
'rtt': _int(fields['rtt'], -1),
'canvas_fp': _string(fields['canvas_fp'], ''),
'webgl_fp': _string(fields['webgl_fp'], ''),
'webgl_vendor': _string(fields['webgl_vendor'], 'unknown'),
'webgl_renderer': _string(fields['webgl_renderer'], 'unknown'),
'webgl_status': _string(fields['webgl_status'], 'unknown'),
'webgl_extensions_hash': _string(fields['webgl_extensions_hash'], ''),
'webgl_params_hash': _string(fields['webgl_params_hash'], ''),
'audio_sample_rate': _int(fields['audio_sample_rate'], -1),
'fonts': _string(fields['fonts'], ''),
'fonts_hash': _string(fields['fonts_hash'], ''),
'font_count': _int(fields['font_count'], -1),
'plugins': _string(fields['plugins'], ''),
'plugins_hash': _string(fields['plugins_hash'], ''),
'plugin_count': _int(fields['plugin_count'], -1),
'mimes': _string(fields['mimes'], ''),
'mimes_hash': _string(fields['mimes_hash'], ''),
'mime_count': _int(fields['mime_count'], -1),
'has_webdriver': _int(fields['has_webdriver'], 0),
'hardware_concurrency': _int(fields['hardware_concurrency'], -1),
'browser_platform': _string(fields['browser_platform'], 'unknown'),
'color_depth': _int(fields['color_depth'], -1),
'audio_fp': _string(fields['audio_fp'], ''),
'viewport_width': _int(fields['viewport_width'], -1),
'viewport_height': _int(fields['viewport_height'], -1),
'is_headless': _int(fields['is_headless'], -1),
'headless_reason': _string(fields['headless_reason'], ''),
};
}
static String _string(dynamic value, String fallback) {
if (value == null) return fallback;
final string = value.toString().trim();
return string.isEmpty ? fallback : string;
}
static int _int(dynamic value, int fallback) {
if (value is int) return value;
if (value is double && value.isFinite) return value.round();
if (value is String) {
final asInt = int.tryParse(value.trim());
if (asInt != null) return asInt;
final asDouble = double.tryParse(value.trim());
if (asDouble != null && asDouble.isFinite) return asDouble.round();
}
return fallback;
}
static int _devicePixelRatio(dynamic value) {
if (value == null) return -1;
final ratio =
value is num ? value.toDouble() : double.tryParse(value.toString());
if (ratio == null || !ratio.isFinite) return -1;
if (ratio > 10) return ratio.round();
return (ratio * 100).round();
}
static String _deviceType(String device) {
switch (device.toLowerCase()) {
case 'android':
case 'ios':
return 'phone';
case 'pc':
return 'desktop';
default:
return 'unknown';
}
}
static String _osFamily(String systemName) {
switch (systemName.toLowerCase()) {
case 'android':
return 'android';
case 'ios':
return 'ios';
case 'macos':
return 'macos';
case 'windows':
return 'windows';
case 'linux':
return 'linux';
default:
return 'unknown';
}
}
static String _buildMode() {
if (kReleaseMode) return 'release';
if (kProfileMode) return 'profile';
if (kDebugMode) return 'debug';
return 'unknown';
}
}
@@ -0,0 +1,3 @@
export 'sdk_init_platform_fields_native.dart'
// ignore: uri_does_not_exist
if (dart.library.html) 'sdk_init_platform_fields_web.dart';
@@ -0,0 +1,61 @@
import 'dart:io';
import 'package:device_info_plus/device_info_plus.dart';
import 'package:flutter/services.dart';
import 'package:package_info_plus/package_info_plus.dart';
const MethodChannel _nativeChannel = MethodChannel('analytics_sdk');
Future<Map<String, dynamic>> collectSdkInitPlatformFields() async {
final fields = <String, dynamic>{};
try {
final info = await PackageInfo.fromPlatform();
fields['package_name'] = info.packageName;
fields['bundle_id'] = info.packageName;
} catch (_) {}
try {
final plugin = DeviceInfoPlugin();
if (Platform.isAndroid) {
final info = await plugin.androidInfo;
fields.addAll({
'brand': info.brand,
'manufacturer': info.manufacturer,
'model': info.model,
'abi': info.supportedAbis.isNotEmpty
? info.supportedAbis.first
: 'unknown',
'is_rooted': -1,
'build_hardware': info.hardware,
'build_product': info.product,
'build_fingerprint': info.fingerprint,
});
} else if (Platform.isIOS) {
final info = await plugin.iosInfo;
fields.addAll({
'bundle_id': fields['bundle_id'] ?? '',
'ios_device_model_raw': info.utsname.machine,
'is_jailbroken': -1,
});
}
} catch (_) {}
fields.addAll(await _collectNativeChannelFields());
return fields;
}
Future<Map<String, dynamic>> _collectNativeChannelFields() async {
try {
final result = await _nativeChannel
.invokeMapMethod<String, dynamic>('getSdkInitPlatformFields');
if (result == null) return const {};
return Map<String, dynamic>.from(result)
..removeWhere((key, value) => value == null);
} on MissingPluginException {
return const {};
} catch (_) {
return const {};
}
}
@@ -0,0 +1,540 @@
// ignore: avoid_web_libraries_in_flutter
import 'dart:convert';
import 'dart:html' as html;
import 'package:crypto/crypto.dart';
import 'package:analytics_sdk/utils/js_interop_compat.dart';
Future<Map<String, dynamic>> collectSdkInitPlatformFields() async {
final navigator = html.window.navigator;
final screen = html.window.screen;
final uri = Uri.tryParse(html.window.location.href);
final refererUri = Uri.tryParse(html.document.referrer);
final connection = _connection(navigator);
final browser = _parseBrowser(navigator.userAgent);
final canvasFp = _canvasFingerprint();
final webgl = _webglInfo();
final audio = _audioInfo();
final fonts = _fonts();
final plugins = _plugins(navigator);
final mimes = _mimes(navigator);
final headless = _headlessReasons(navigator, plugins);
headless.addAll(webgl.headlessReasons);
if (fonts != null && fonts.isNotEmpty && fonts.length < 3) {
headless.add('fonts_low');
}
return {
'current_url': _urlWithoutQueryAndFragment(uri),
'current_domain': uri?.host ?? '',
'current_path': uri?.path ?? '',
'current_query_hash': _hash(uri?.query ?? ''),
'referer': _urlWithoutQueryAndFragment(refererUri),
'referer_domain': refererUri?.host ?? '',
'referer_path': refererUri?.path ?? '',
'browser_name': browser.name,
'browser_version': browser.version,
'browser_kernel': _browserKernel(navigator.userAgent),
'display_mode': _displayMode(),
'visibility_state': _nonEmpty(html.document.visibilityState, 'unknown'),
'cookie_enabled': navigator.cookieEnabled == true ? 1 : 0,
'local_storage_enabled': _storageEnabled(html.window.localStorage),
'session_storage_enabled': _storageEnabled(html.window.sessionStorage),
'indexeddb_enabled': _hasProperty(html.window, 'indexedDB'),
'service_worker_enabled': _hasProperty(navigator, 'serviceWorker'),
'connection_type': _string(connection, 'type', 'unknown'),
'effective_type': _effectiveType(connection),
'downlink': _downlinkKbps(connection),
'rtt': _number(connection, 'rtt', -1).round(),
'canvas_fp': canvasFp,
'webgl_fp': webgl.fp,
'webgl_vendor': webgl.vendor,
'webgl_renderer': webgl.renderer,
'webgl_status': webgl.status,
'webgl_extensions_hash': webgl.extensionsHash,
'webgl_params_hash': webgl.paramsHash,
'audio_sample_rate': audio.sampleRate,
'fonts': fonts?.join(',') ?? '',
'fonts_hash': _hash(fonts?.join('|') ?? ''),
'font_count': fonts?.length ?? -1,
'plugins': plugins.join(','),
'plugins_hash': _hash(plugins.join('|')),
'plugin_count': plugins.length,
'mimes': mimes.join(','),
'mimes_hash': _hash(mimes.join('|')),
'mime_count': mimes.length,
'has_webdriver': navigator.webdriver == true ? 1 : 0,
'hardware_concurrency': navigator.hardwareConcurrency ?? -1,
'browser_platform': _nonEmpty(navigator.platform, 'unknown'),
'color_depth': screen?.colorDepth ?? -1,
'audio_fp': audio.fp,
'viewport_width': html.window.innerWidth ?? -1,
'viewport_height': html.window.innerHeight ?? -1,
'touch_support': (navigator.maxTouchPoints ?? 0) > 0 ? 1 : 0,
'max_touch_points': navigator.maxTouchPoints ?? -1,
'is_headless': headless.isEmpty ? 0 : 1,
'headless_reason': _unique(headless).join(','),
};
}
class _BrowserInfo {
final String name;
final String version;
const _BrowserInfo(this.name, this.version);
}
class _WebglInfo {
final String fp;
final String vendor;
final String renderer;
final String status;
final String extensionsHash;
final String paramsHash;
final List<String> headlessReasons;
const _WebglInfo({
required this.fp,
required this.vendor,
required this.renderer,
required this.status,
required this.extensionsHash,
required this.paramsHash,
required this.headlessReasons,
});
}
class _AudioInfo {
final int sampleRate;
final String fp;
const _AudioInfo(this.sampleRate, this.fp);
}
String _urlWithoutQueryAndFragment(Uri? uri) {
if (uri == null) return '';
final buffer = StringBuffer();
if (uri.scheme.isNotEmpty) {
buffer
..write(uri.scheme)
..write('://');
}
if (uri.host.isNotEmpty) {
buffer.write(uri.host);
}
if (uri.hasPort) {
buffer.write(':${uri.port}');
}
if (uri.path.isNotEmpty) {
buffer.write(uri.path);
}
return buffer.toString();
}
Object? _connection(html.Navigator navigator) {
for (final key in ['connection', 'mozConnection', 'webkitConnection']) {
try {
if (hasJsProperty(navigator, key)) {
return getJsProperty(navigator, key);
}
} catch (_) {}
}
return null;
}
_BrowserInfo _parseBrowser(String ua) {
final rules = <String, RegExp>{
'Edge': RegExp(r'(?:Edg|EdgA|EdgiOS|Edge)/([\d.]+)'),
'Opera': RegExp(r'(?:OPR|Opera)/([\d.]+)'),
'Chrome': RegExp(r'(?:Chrome|CriOS)/([\d.]+)'),
'Firefox': RegExp(r'(?:Firefox|FxiOS)/([\d.]+)'),
'Safari': RegExp(r'Version/([\d.]+).*Safari'),
};
for (final entry in rules.entries) {
final match = entry.value.firstMatch(ua);
if (match != null) {
return _BrowserInfo(entry.key, _nonEmpty(match.group(1), 'unknown'));
}
}
return const _BrowserInfo('unknown', 'unknown');
}
String _string(Object? object, String key, String fallback) {
if (object == null) return fallback;
try {
final value = getJsProperty(object, key);
final string = value?.toString().trim() ?? '';
return string.isEmpty ? fallback : string;
} catch (_) {
return fallback;
}
}
num _number(Object? object, String key, num fallback) {
if (object == null) return fallback;
try {
final value = getJsProperty(object, key);
if (value is num && value.isFinite) return value;
return num.tryParse(value?.toString() ?? '') ?? fallback;
} catch (_) {
return fallback;
}
}
String _effectiveType(Object? connection) {
final value = _string(connection, 'effectiveType', 'unknown').toLowerCase();
if (value == 'slow-2g') return '2g';
const allowed = {'2g', '3g', '4g', '5g'};
return allowed.contains(value) ? value : 'unknown';
}
int _downlinkKbps(Object? connection) {
final downlink = _number(connection, 'downlink', -1);
return downlink >= 0 ? (downlink * 1000).round() : -1;
}
int _hasProperty(Object object, String key) {
try {
return hasJsProperty(object, key) ? 1 : 0;
} catch (_) {
return -1;
}
}
int _storageEnabled(html.Storage storage) {
try {
const key = '__analytics_sdk_storage_test__';
storage[key] = '1';
final ok = storage[key] == '1';
storage.remove(key);
return ok ? 1 : 0;
} catch (_) {
return 0;
}
}
String _canvasFingerprint() {
try {
final canvas = html.CanvasElement(width: 240, height: 60);
final context = canvas.context2D;
context
..textBaseline = 'top'
..font = '14px Arial'
..fillStyle = '#f60';
context.fillRect(125, 1, 62, 20);
context
..fillStyle = '#069'
..fillText('analytics_sdk_canvas_fp', 2, 15)
..fillStyle = 'rgba(102, 204, 0, 0.7)'
..fillText('analytics_sdk_canvas_fp', 4, 17);
return _hash(canvas.toDataUrl());
} catch (_) {
return '';
}
}
_WebglInfo _webglInfo() {
Object? gl;
try {
final canvas = html.CanvasElement(width: 1, height: 1);
gl = canvas.getContext('webgl') ?? canvas.getContext('experimental-webgl');
} catch (_) {
return const _WebglInfo(
fp: '',
vendor: 'unknown',
renderer: 'unknown',
status: 'error',
extensionsHash: '',
paramsHash: '',
headlessReasons: ['webgl_unavailable'],
);
}
if (gl == null) {
return const _WebglInfo(
fp: '',
vendor: 'unknown',
renderer: 'unknown',
status: 'unsupported',
extensionsHash: '',
paramsHash: '',
headlessReasons: ['webgl_unavailable'],
);
}
try {
final debugInfo = callJsMethod(gl, 'getExtension', [
'WEBGL_debug_renderer_info',
]);
final vendor =
_webglParameter(gl, debugInfo, 'UNMASKED_VENDOR_WEBGL') ??
_webglParameterByConstant(gl, 0x1F00) ??
'unknown';
final renderer =
_webglParameter(gl, debugInfo, 'UNMASKED_RENDERER_WEBGL') ??
_webglParameterByConstant(gl, 0x1F01) ??
'unknown';
final extensions = _jsStringArray(
callJsMethod(gl, 'getSupportedExtensions'),
)..sort();
final params = _webglParams(gl);
final extensionsHash = _hash(extensions.join('|'));
final paramsHash = _hash(params.join('|'));
final fp = _hash(
[vendor, renderer, extensions.join('|'), params.join('|')].join('|'),
);
return _WebglInfo(
fp: fp,
vendor: vendor,
renderer: renderer,
status: 'available',
extensionsHash: extensionsHash,
paramsHash: paramsHash,
headlessReasons: const [],
);
} catch (_) {
return const _WebglInfo(
fp: '',
vendor: 'unknown',
renderer: 'unknown',
status: 'error',
extensionsHash: '',
paramsHash: '',
headlessReasons: ['webgl_unavailable'],
);
}
}
String? _webglParameter(Object gl, Object? debugInfo, String key) {
if (debugInfo == null) return null;
try {
final parameter = getJsProperty(debugInfo, key);
if (parameter == null) return null;
final value = callJsMethod(gl, 'getParameter', [parameter]);
final string = _jsValueToString(value).trim();
return string.isEmpty ? null : string;
} catch (_) {
return null;
}
}
String? _webglParameterByConstant(Object gl, int parameter) {
try {
final value = callJsMethod(gl, 'getParameter', [parameter]);
final string = _jsValueToString(value).trim();
return string.isEmpty ? null : string;
} catch (_) {
return null;
}
}
List<String> _webglParams(Object gl) {
const parameters = <int>[
0x0D33, // MAX_TEXTURE_SIZE
0x0D3A, // MAX_VIEWPORT_DIMS
0x846D, // ALIASED_POINT_SIZE_RANGE
0x846E, // ALIASED_LINE_WIDTH_RANGE
0x84E8, // MAX_RENDERBUFFER_SIZE
0x8869, // MAX_VERTEX_ATTRIBS
0x8872, // MAX_TEXTURE_IMAGE_UNITS
0x8B4C, // MAX_VERTEX_TEXTURE_IMAGE_UNITS
0x8B8C, // SHADING_LANGUAGE_VERSION
];
final values = <String>[];
for (final parameter in parameters) {
final value = _webglParameterByConstant(gl, parameter);
if (value != null) values.add('$parameter=$value');
}
return values;
}
_AudioInfo _audioInfo() {
Object? context;
try {
final constructor =
getJsProperty(html.window, 'AudioContext') ??
getJsProperty(html.window, 'webkitAudioContext');
if (constructor == null) return const _AudioInfo(-1, '');
context = constructJsObject(constructor);
final sampleRate = _number(context, 'sampleRate', -1).round();
final fp = sampleRate > 0 ? _hash('sampleRate:$sampleRate') : '';
return _AudioInfo(sampleRate, fp);
} catch (_) {
return const _AudioInfo(-1, '');
} finally {
if (context != null) {
try {
callJsMethod(context, 'close');
} catch (_) {}
}
}
}
List<String>? _fonts() {
try {
final canvas = html.CanvasElement(width: 200, height: 50);
final context = canvas.context2D;
const text = 'mmmmmmmmmmlli';
const baseFonts = ['monospace', 'serif', 'sans-serif'];
final baseline = <String, num>{};
for (final base in baseFonts) {
context.font = '72px $base';
baseline[base] = context.measureText(text).width ?? -1;
}
const candidates = [
'Arial',
'Arial Black',
'Calibri',
'Cambria',
'Comic Sans MS',
'Courier New',
'Georgia',
'Helvetica',
'Impact',
'Menlo',
'Microsoft YaHei',
'Monaco',
'Noto Sans',
'PingFang SC',
'Roboto',
'San Francisco',
'Segoe UI',
'Tahoma',
'Times New Roman',
'Trebuchet MS',
'Verdana',
];
final detected = <String>[];
for (final font in candidates) {
var found = false;
for (final base in baseFonts) {
context.font = '72px "$font", $base';
final width = context.measureText(text).width ?? -1;
if (width != baseline[base]) {
found = true;
break;
}
}
if (found) detected.add(font);
}
return detected;
} catch (_) {
return null;
}
}
List<String> _plugins(html.Navigator navigator) {
try {
return _indexedPropertyList(getJsProperty(navigator, 'plugins'), 'name');
} catch (_) {
return const [];
}
}
List<String> _mimes(html.Navigator navigator) {
try {
return _indexedPropertyList(getJsProperty(navigator, 'mimeTypes'), 'type');
} catch (_) {
return const [];
}
}
List<String> _indexedPropertyList(Object? collection, String property) {
if (collection == null) return const [];
final length = _number(collection, 'length', 0).round();
if (length <= 0) return const [];
final values = <String>[];
for (var i = 0; i < length; i++) {
try {
final item = getJsProperty(collection, i);
if (item == null) continue;
final value = getJsProperty(item, property);
final string = value?.toString().trim() ?? '';
if (string.isNotEmpty) values.add(string);
} catch (_) {}
}
return values;
}
List<String> _jsStringArray(Object? collection) {
if (collection == null) return const [];
final length = _number(collection, 'length', 0).round();
if (length <= 0) return const [];
final values = <String>[];
for (var i = 0; i < length; i++) {
try {
final value = getJsProperty(collection, i);
final string = value?.toString().trim() ?? '';
if (string.isNotEmpty) values.add(string);
} catch (_) {}
}
return values;
}
String _jsValueToString(Object? value) {
if (value == null) return '';
if (value is String || value is num || value is bool) return '$value';
try {
final values = _jsStringArray(value);
if (values.isNotEmpty) return values.join(',');
} catch (_) {}
return value.toString();
}
List<String> _headlessReasons(html.Navigator navigator, List<String> plugins) {
final reasons = <String>[];
try {
if (navigator.webdriver == true) reasons.add('webdriver');
} catch (_) {}
if (plugins.isEmpty) reasons.add('plugins_empty');
try {
final width = html.window.innerWidth ?? 0;
final height = html.window.innerHeight ?? 0;
if (width <= 0 || height <= 0) reasons.add('viewport_zero');
} catch (_) {}
return reasons;
}
String _displayMode() {
const modes = ['standalone', 'minimal-ui', 'fullscreen', 'browser'];
for (final mode in modes) {
try {
if (html.window.matchMedia('(display-mode: $mode)').matches) {
return mode;
}
} catch (_) {}
}
return 'unknown';
}
String _browserKernel(String ua) {
if (ua.contains('Edge/')) return 'edgehtml';
if (ua.contains('Edg/')) return 'chromium';
if (ua.contains('OPR/') || ua.contains('Opera/')) return 'chromium';
if (ua.contains('Chrome/') || ua.contains('Chromium/')) return 'chromium';
if (ua.contains('Firefox/')) return 'gecko';
if (ua.contains('Trident/')) return 'trident';
if (ua.contains('Safari/')) return 'webkit';
return 'unknown';
}
String _nonEmpty(String? value, String fallback) {
final string = value?.trim() ?? '';
return string.isEmpty ? fallback : string;
}
List<String> _unique(List<String> values) {
final seen = <String>{};
return [
for (final value in values)
if (seen.add(value)) value,
];
}
String _hash(String value) {
if (value.isEmpty) return '';
return md5.convert(utf8.encode(value)).toString();
}
@@ -0,0 +1,292 @@
class SessionBehaviorManager {
static final SessionBehaviorManager instance =
SessionBehaviorManager._internal();
factory SessionBehaviorManager() => instance;
SessionBehaviorManager._internal() : _nowProvider = DateTime.now;
SessionBehaviorManager.forTest({
DateTime Function()? nowProvider,
}) : _nowProvider = nowProvider ?? DateTime.now;
static const int _scrollThresholdPx = 8;
static const int _bottomTolerancePx = 1;
static const _scrollMinInterval = Duration(milliseconds: 200);
static const _touchDedupWindow = Duration(milliseconds: 50);
final DateTime Function() _nowProvider;
String _sid = '';
DateTime? _pageStartedAt;
int? _lastScrollTop;
DateTime? _lastScrollProcessedAt;
DateTime? _lastTouchStartAt;
DateTime? _lastSwipeAt;
DateTime? _lastLongPressAt;
DateTime? _lastPinchAt;
int _lastScrollDirection = 0;
int _scrollTotalCount = 0;
int _scrollTotalDistancePx = 0;
int _scrollUpCount = 0;
int _scrollDownCount = 0;
int _scrollDirectionChangeCount = 0;
int _firstScrollDelayMs = -1;
int _maxScrollDepthPercent = -1;
int _isReachBottom = -1;
int _contentHeight = -1;
int _touchTotalCount = 0;
int _swipeTotalCount = 0;
int _longPressTotalCount = 0;
int _pinchTotalCount = 0;
int _firstInteractionDelayMs = -1;
void reset({String sid = ''}) {
_sid = sid;
_pageStartedAt = _nowProvider();
_lastScrollTop = null;
_lastScrollProcessedAt = null;
_lastTouchStartAt = null;
_lastSwipeAt = null;
_lastLongPressAt = null;
_lastPinchAt = null;
_lastScrollDirection = 0;
_scrollTotalCount = 0;
_scrollTotalDistancePx = 0;
_scrollUpCount = 0;
_scrollDownCount = 0;
_scrollDirectionChangeCount = 0;
_firstScrollDelayMs = -1;
_maxScrollDepthPercent = -1;
_isReachBottom = -1;
_contentHeight = -1;
_touchTotalCount = 0;
_swipeTotalCount = 0;
_longPressTotalCount = 0;
_pinchTotalCount = 0;
_firstInteractionDelayMs = -1;
}
void recordAcceptedEvent(Map<String, dynamic> event) {
final eventName = event['event']?.toString() ?? '';
if (eventName.isEmpty || eventName == 'app_session_summary') return;
final sid = event['sid']?.toString() ?? '';
if (sid != _sid) {
reset(sid: sid);
}
if (eventName == 'app_page_view') {
recordPageEnter(_pageEnterTime(event) ?? _nowProvider());
}
}
void recordPageEnter(DateTime enteredAt) {
_pageStartedAt = enteredAt;
_lastScrollTop = null;
_lastScrollProcessedAt = null;
_lastScrollDirection = 0;
}
void recordScroll({
required int scrollTop,
required int viewportHeight,
required int contentHeight,
DateTime? at,
}) {
final observedAt = at ?? _nowProvider();
final normalizedScrollTop = _nonNegative(scrollTop);
final normalizedViewportHeight = _nonNegative(viewportHeight);
final normalizedContentHeight = _nonNegative(contentHeight);
_updateScrollExtent(
scrollTop: normalizedScrollTop,
viewportHeight: normalizedViewportHeight,
contentHeight: normalizedContentHeight,
);
final previousScrollTop = _lastScrollTop;
if (previousScrollTop == null) {
_lastScrollTop = normalizedScrollTop;
_lastScrollProcessedAt = observedAt;
return;
}
final previousProcessedAt = _lastScrollProcessedAt;
if (previousProcessedAt != null &&
observedAt.difference(previousProcessedAt) < _scrollMinInterval) {
return;
}
final delta = normalizedScrollTop - previousScrollTop;
final distance = delta.abs();
if (distance < _scrollThresholdPx) {
_lastScrollTop = normalizedScrollTop;
_lastScrollProcessedAt = observedAt;
return;
}
final direction = delta > 0 ? 1 : -1;
_scrollTotalCount += 1;
_scrollTotalDistancePx += distance;
if (direction > 0) {
_scrollDownCount += 1;
} else {
_scrollUpCount += 1;
}
if (_lastScrollDirection != 0 && _lastScrollDirection != direction) {
_scrollDirectionChangeCount += 1;
}
_lastScrollDirection = direction;
_lastScrollTop = normalizedScrollTop;
_lastScrollProcessedAt = observedAt;
_recordFirstScrollDelay(observedAt);
}
void recordTouchStart({DateTime? at}) {
final observedAt = at ?? _nowProvider();
if (_isDuplicate(_lastTouchStartAt, observedAt)) return;
_lastTouchStartAt = observedAt;
_touchTotalCount += 1;
_recordFirstInteractionDelay(observedAt);
}
void recordSwipe({DateTime? at}) {
final observedAt = at ?? _nowProvider();
if (_isDuplicate(_lastSwipeAt, observedAt)) return;
_lastSwipeAt = observedAt;
_swipeTotalCount += 1;
_recordFirstInteractionDelay(observedAt);
}
void recordLongPress({DateTime? at}) {
final observedAt = at ?? _nowProvider();
if (_isDuplicate(_lastLongPressAt, observedAt)) return;
_lastLongPressAt = observedAt;
_longPressTotalCount += 1;
_recordFirstInteractionDelay(observedAt);
}
void recordPinch({DateTime? at}) {
final observedAt = at ?? _nowProvider();
if (_isDuplicate(_lastPinchAt, observedAt)) return;
_lastPinchAt = observedAt;
_pinchTotalCount += 1;
_recordFirstInteractionDelay(observedAt);
}
SessionBehaviorSnapshot snapshot() {
return SessionBehaviorSnapshot(
scrollTotalCount: _scrollTotalCount,
scrollTotalDistancePx: _scrollTotalDistancePx,
scrollUpCount: _scrollUpCount,
scrollDownCount: _scrollDownCount,
scrollDirectionChangeCount: _scrollDirectionChangeCount,
firstScrollDelayMs: _firstScrollDelayMs,
maxScrollDepthPercent: _maxScrollDepthPercent,
isReachBottom: _isReachBottom,
contentHeight: _contentHeight,
touchTotalCount: _touchTotalCount,
swipeTotalCount: _swipeTotalCount,
longPressTotalCount: _longPressTotalCount,
pinchTotalCount: _pinchTotalCount,
firstInteractionDelayMs: _firstInteractionDelayMs,
);
}
void _updateScrollExtent({
required int scrollTop,
required int viewportHeight,
required int contentHeight,
}) {
if (contentHeight <= 0 || viewportHeight <= 0) return;
_contentHeight = contentHeight;
final depth = ((scrollTop + viewportHeight) / contentHeight * 100)
.round()
.clamp(0, 100);
if (_maxScrollDepthPercent < 0 || depth > _maxScrollDepthPercent) {
_maxScrollDepthPercent = depth;
}
if (scrollTop + viewportHeight >= contentHeight - _bottomTolerancePx) {
_isReachBottom = 1;
} else if (_isReachBottom < 0) {
_isReachBottom = 0;
}
}
void _recordFirstScrollDelay(DateTime at) {
if (_firstScrollDelayMs >= 0) return;
_firstScrollDelayMs = _delaySincePageStart(at);
}
void _recordFirstInteractionDelay(DateTime at) {
if (_firstInteractionDelayMs >= 0) return;
_firstInteractionDelayMs = _delaySincePageStart(at);
}
int _delaySincePageStart(DateTime at) {
final pageStartedAt = _pageStartedAt;
if (pageStartedAt == null) return -1;
final delay = at.difference(pageStartedAt).inMilliseconds;
return delay < 0 ? 0 : delay;
}
DateTime? _pageEnterTime(Map<String, dynamic> event) {
final payload = event['payload'];
if (payload is! Map) return null;
final value = payload['page_enter_ts'];
if (value is int) {
return DateTime.fromMillisecondsSinceEpoch(value * 1000);
}
if (value is num) {
return DateTime.fromMillisecondsSinceEpoch(value.round() * 1000);
}
final parsed = int.tryParse(value?.toString() ?? '');
if (parsed == null) return null;
return DateTime.fromMillisecondsSinceEpoch(parsed * 1000);
}
bool _isDuplicate(DateTime? previous, DateTime current) {
if (previous == null) return false;
final diffMs = current.difference(previous).inMilliseconds.abs();
return diffMs < _touchDedupWindow.inMilliseconds;
}
int _nonNegative(int value) => value < 0 ? 0 : value;
}
class SessionBehaviorSnapshot {
final int scrollTotalCount;
final int scrollTotalDistancePx;
final int scrollUpCount;
final int scrollDownCount;
final int scrollDirectionChangeCount;
final int firstScrollDelayMs;
final int maxScrollDepthPercent;
final int isReachBottom;
final int contentHeight;
final int touchTotalCount;
final int swipeTotalCount;
final int longPressTotalCount;
final int pinchTotalCount;
final int firstInteractionDelayMs;
const SessionBehaviorSnapshot({
required this.scrollTotalCount,
required this.scrollTotalDistancePx,
required this.scrollUpCount,
required this.scrollDownCount,
required this.scrollDirectionChangeCount,
required this.firstScrollDelayMs,
required this.maxScrollDepthPercent,
required this.isReachBottom,
required this.contentHeight,
required this.touchTotalCount,
required this.swipeTotalCount,
required this.longPressTotalCount,
required this.pinchTotalCount,
required this.firstInteractionDelayMs,
});
}
@@ -0,0 +1,2 @@
export 'session_behavior_monitor_stub.dart'
if (dart.library.html) 'session_behavior_monitor_web.dart';
@@ -0,0 +1,10 @@
class SessionBehaviorMonitor {
static final SessionBehaviorMonitor instance =
SessionBehaviorMonitor._internal();
SessionBehaviorMonitor._internal();
void start() {}
void dispose() {}
}
@@ -0,0 +1,195 @@
// ignore: avoid_web_libraries_in_flutter
import 'dart:async';
import 'dart:html' as html;
import 'dart:math' as math;
import 'package:analytics_sdk/manager/session_behavior_manager.dart';
class SessionBehaviorMonitor {
static final SessionBehaviorMonitor instance =
SessionBehaviorMonitor._internal();
SessionBehaviorMonitor._internal();
static const int _swipeThresholdPx = 50;
static const int _pinchThresholdPx = 20;
static const int _longPressMoveTolerancePx = 10;
static const _longPressDuration = Duration(milliseconds: 500);
final List<StreamSubscription<dynamic>> _subscriptions = [];
final Map<int, _TouchStart> _touchStarts = <int, _TouchStart>{};
final Map<int, math.Point<num>> _activeTouches = <int, math.Point<num>>{};
double? _pinchStartDistance;
bool _pinchRecorded = false;
bool _started = false;
void start() {
if (_started) return;
_started = true;
_subscriptions
..add(html.window.onScroll.listen(_handleScroll))
..add(html.document.onTouchStart.listen(_handleTouchStart))
..add(html.document.onTouchMove.listen(_handleTouchMove))
..add(html.document.onTouchEnd.listen(_handleTouchEnd))
..add(html.document.onTouchCancel.listen(_handleTouchCancel));
_handleScroll(null);
}
void dispose() {
for (final subscription in _subscriptions) {
subscription.cancel();
}
_subscriptions.clear();
_touchStarts.clear();
_activeTouches.clear();
_pinchStartDistance = null;
_pinchRecorded = false;
_started = false;
}
void _handleScroll(Object? _) {
try {
final element = html.document.scrollingElement ??
html.document.documentElement ??
html.document.body;
final viewportHeight = html.window.innerHeight ?? element?.clientHeight;
final contentHeight = element?.scrollHeight;
final scrollTop = element?.scrollTop ?? html.window.pageYOffset.round();
if (viewportHeight == null || contentHeight == null) return;
SessionBehaviorManager.instance.recordScroll(
scrollTop: scrollTop,
viewportHeight: viewportHeight,
contentHeight: contentHeight,
);
} catch (_) {}
}
void _handleTouchStart(html.TouchEvent event) {
try {
final touches = event.touches;
final changedTouches = event.changedTouches;
if (touches == null || changedTouches == null) return;
final now = DateTime.now();
if (touches.length == 1) {
SessionBehaviorManager.instance.recordTouchStart(at: now);
}
for (final touch in changedTouches) {
final identifier = touch.identifier ?? 0;
final point = touch.client;
_touchStarts[identifier] = _TouchStart(point: point, startedAt: now);
_activeTouches[identifier] = point;
}
_updatePinchBaseline();
} catch (_) {}
}
void _handleTouchMove(html.TouchEvent event) {
try {
final touches = event.touches;
if (touches == null) return;
for (final touch in touches) {
_activeTouches[touch.identifier ?? 0] = touch.client;
}
if (_activeTouches.length < 2) return;
final distance = _firstTwoTouchDistance();
if (distance == null) return;
_pinchStartDistance ??= distance;
if (!_pinchRecorded &&
(distance - _pinchStartDistance!).abs() >= _pinchThresholdPx) {
_pinchRecorded = true;
SessionBehaviorManager.instance.recordPinch();
}
} catch (_) {}
}
void _handleTouchEnd(html.TouchEvent event) {
try {
final changedTouches = event.changedTouches;
if (changedTouches == null) return;
final now = DateTime.now();
final wasSingleTouch = _activeTouches.length == 1;
for (final touch in changedTouches) {
final identifier = touch.identifier ?? 0;
final start = _touchStarts[identifier];
if (start != null && wasSingleTouch) {
_recordFinishedSingleTouch(
start: start,
endPoint: touch.client,
endedAt: now,
);
}
_touchStarts.remove(identifier);
_activeTouches.remove(identifier);
}
_updatePinchBaseline();
} catch (_) {}
}
void _handleTouchCancel(html.TouchEvent event) {
try {
final changedTouches = event.changedTouches;
if (changedTouches == null) return;
for (final touch in changedTouches) {
final identifier = touch.identifier ?? 0;
_touchStarts.remove(identifier);
_activeTouches.remove(identifier);
}
_updatePinchBaseline();
} catch (_) {}
}
void _recordFinishedSingleTouch({
required _TouchStart start,
required math.Point<num> endPoint,
required DateTime endedAt,
}) {
final distance = _distance(start.point, endPoint);
if (distance >= _swipeThresholdPx) {
SessionBehaviorManager.instance.recordSwipe(at: endedAt);
return;
}
if (endedAt.difference(start.startedAt) >= _longPressDuration &&
distance <= _longPressMoveTolerancePx) {
SessionBehaviorManager.instance.recordLongPress(at: endedAt);
}
}
void _updatePinchBaseline() {
if (_activeTouches.length < 2) {
_pinchStartDistance = null;
_pinchRecorded = false;
return;
}
_pinchStartDistance = _firstTwoTouchDistance();
_pinchRecorded = false;
}
double? _firstTwoTouchDistance() {
if (_activeTouches.length < 2) return null;
final iterator = _activeTouches.values.iterator;
iterator.moveNext();
final first = iterator.current;
iterator.moveNext();
final second = iterator.current;
return _distance(first, second);
}
double _distance(math.Point<num> a, math.Point<num> b) {
final dx = a.x - b.x;
final dy = a.y - b.y;
return math.sqrt(dx * dx + dy * dy);
}
}
class _TouchStart {
final math.Point<num> point;
final DateTime startedAt;
const _TouchStart({
required this.point,
required this.startedAt,
});
}
@@ -0,0 +1,316 @@
import 'package:flutter/foundation.dart';
import 'package:analytics_sdk/utils/logger.dart';
import 'package:analytics_sdk/utils/uuid_util.dart';
/// 会话管理器:管理会话ID(sid)的生成和更新
///
/// 会话定义:用户在一次连续、自然的产品使用过程中产生的一组行为集合
///
/// **sid 说明**:sid 由客户端 SDK 自动生成,每个事件均会携带。
/// 服务端上报事件时,需通过 [AnalyticsSdk.getParams] 获取当前 sid 并一同回传,
/// 以便服务端事件与客户端事件归属同一会话。
///
/// 会话中断阈值:30 分钟无任何有效行为,生成新会话ID
///
/// APP产品定义方案,任意满足一条就重新生成会话ID:
/// 1. App 冷启动(进程首次启动)
/// 2. App 从后台切换前台,且后台停留 > 30 分钟
/// 3. 上一次行为距离当前 > 30 分钟
class SessionManager {
static final SessionManager instance = SessionManager._internal();
factory SessionManager() => instance;
SessionManager._internal();
/// 当前会话ID
String? _currentSessionId;
/// 会话创建时间
DateTime? _sessionStartTime;
/// 最后一次行为时间
DateTime? _lastActivityTime;
/// 应用进入后台的时间
DateTime? _backgroundTime;
/// 当前正在通知结束的旧 session 逻辑结束时间。
DateTime? _notifyingSessionEndTime;
/// 会话中断阈值(30分钟)
static const Duration sessionTimeout = Duration(minutes: 30);
static const Set<String> _effectiveLifecycleKeys = {
'cold_start',
'web_load',
'warm_start',
'foreground',
};
static const Set<String> _nonBusinessActivityEvents = {
'sdk_init',
'network_state_change',
'app_session_summary',
'navigation',
'page_lifecycle',
};
/// 是否已初始化(用于判断冷启动)
bool _isInitialized = false;
/// 新 session 创建时的回调(由外部注入,避免直接依赖 AdImpressionManager)
/// 通常由 AnalyticsSdk.init() 注入:`() => AdImpressionManager.instance.clear()`
void Function()? onNewSession;
/// 旧 session 即将结束时的回调。
///
/// 在新 sid 生成前调用,使调用方仍能用旧 sid 补发 app_session_summary。
void Function(String sessionEndReason)? onSessionEnding;
/// 获取当前会话ID
/// 如果当前没有会话或会话已过期,会自动创建新会话
String getSessionId() {
final now = DateTime.now();
// 检查是否需要生成新会话
// 注意:这里只检查和创建会话,不更新 _lastActivityTime
// 调用方应通过 recordActivity() 显式记录行为时间,避免非行为调用(如 debug 面板)误延长会话
if (_shouldCreateNewSession(now)) {
_createNewSession(now);
}
// 安全检查:确保会话ID不为null(理论上不会发生,但作为保护措施)
if (_currentSessionId == null) {
Logger.analyticsSdk('警告:会话ID为null,强制创建新会话', level: LogLevel.warn);
_createNewSession(now);
}
return _currentSessionId!;
}
/// 检查是否需要创建新会话
bool _shouldCreateNewSession(DateTime now) {
// 1. 如果还没有会话ID,需要创建
if (_currentSessionId == null) {
return true;
}
// 2. 如果还未初始化,说明是冷启动,需要创建新会话
if (!_isInitialized) {
return true;
}
// 3. 如果从后台恢复,且后台停留时间 > 30 分钟
// 注意:这里只在超时时清空 _backgroundTime(并返回 true 触发新会话)。
// 非超时分支不清空,保留给 onAppForeground() 统一处理,
// 避免后台期间有代码调用 getSessionId()(如推送通知触发 track())时
// 提前清空导致 onAppForeground() 无法正确判断后台时长。
if (_backgroundTime != null) {
final backgroundDuration = now.difference(_backgroundTime!);
if (backgroundDuration > sessionTimeout) {
Logger.analyticsSdk(
'后台停留时间 ${backgroundDuration.inMinutes} 分钟,超过阈值,创建新会话');
_backgroundTime = null;
return true;
}
// 未超时:不清空 _backgroundTime,由 onAppForeground() 负责清空
}
// 4. 如果上一次行为距离当前 > 30 分钟
if (_lastActivityTime != null) {
final timeSinceLastActivity = now.difference(_lastActivityTime!);
if (timeSinceLastActivity > sessionTimeout) {
Logger.analyticsSdk(
'距离上次行为 ${timeSinceLastActivity.inMinutes} 分钟,超过阈值,创建新会话');
return true;
}
}
return false;
}
/// 创建新会话
void _createNewSession(DateTime now) {
try {
_notifySessionEndingIfNeeded('idle');
_currentSessionId = generateUuidV4().replaceAll("-", "");
_sessionStartTime = now;
_lastActivityTime = now;
_isInitialized = true;
// 新 session 开始时通知外部清理广告去重状态
onNewSession?.call();
Logger.analyticsSdk('创建新会话ID: $_currentSessionId');
} catch (e) {
// UUID 生成失败时使用时间戳作为备选方案
final timestamp = now.millisecondsSinceEpoch;
_currentSessionId = 'session_${timestamp}_${now.microsecondsSinceEpoch}';
_sessionStartTime = now;
_lastActivityTime = now;
_isInitialized = true;
onNewSession?.call();
Logger.analyticsSdk('UUID 生成失败,使用时间戳创建会话ID: $_currentSessionId');
}
}
/// 初始化会话管理器
/// 在 SDK 初始化时调用,标记为冷启动
void initialize() {
if (!_isInitialized) {
final now = DateTime.now();
_createNewSession(now);
Logger.analyticsSdk('会话管理器初始化(冷启动),创建新会话');
}
}
/// 应用进入后台
/// 在应用生命周期变为后台时调用
void onAppBackground() {
_backgroundTime = DateTime.now();
Logger.analyticsSdk('应用进入后台,记录时间: $_backgroundTime');
}
/// 应用从后台恢复
/// 在应用生命周期变为前台时调用
void onAppForeground() {
final now = DateTime.now();
// 如果从后台恢复,检查是否需要创建新会话
if (_backgroundTime != null) {
final backgroundDuration = now.difference(_backgroundTime!);
if (backgroundDuration > sessionTimeout) {
Logger.analyticsSdk(
'从后台恢复,后台停留 ${backgroundDuration.inMinutes} 分钟,超过阈值,创建新会话');
_createNewSession(now);
} else {
Logger.analyticsSdk(
'从后台恢复,后台停留 ${backgroundDuration.inMinutes} 分钟,继续使用当前会话');
// 更新最后行为时间,表示用户重新活跃
_lastActivityTime = now;
}
_backgroundTime = null; // 清空后台时间
} else {
// 如果没有后台时间记录,直接更新最后行为时间
_lastActivityTime = now;
}
}
/// 记录用户行为
/// 每次生成事件时调用,用于更新最后行为时间
void recordActivity() {
_lastActivityTime = DateTime.now();
}
/// 仅在成功进入 SDK 队列且属于有效行为时更新最后行为时间。
void recordActivityForEvent(Map<String, dynamic> event, {DateTime? at}) {
if (!_isEffectiveActivityEvent(event)) return;
_lastActivityTime = at ?? DateTime.now();
}
/// 获取当前会话ID(不触发新会话创建)
String? get currentSessionId => _currentSessionId;
/// 获取会话开始时间
DateTime? get sessionStartTime => _sessionStartTime;
/// 获取最后行为时间
DateTime? get lastActivityTime => _lastActivityTime;
/// 当前 session 结束回调内可读取的逻辑结束时间。
DateTime? get sessionEndTimeForNotification => _notifyingSessionEndTime;
/// 仅供测试使用:获取后台时间(验证 _shouldCreateNewSession 不提前清空)
@visibleForTesting
DateTime? get backgroundTimeForTest => _backgroundTime;
@visibleForTesting
void setBackgroundTimeForTest(DateTime? value) {
_backgroundTime = value;
}
@visibleForTesting
DateTime? get sessionEndTimeForNotificationForTest =>
_notifyingSessionEndTime;
@visibleForTesting
void setLastActivityTimeForTest(DateTime? value) {
_lastActivityTime = value;
}
@visibleForTesting
void setSessionStartTimeForTest(DateTime? value) {
_sessionStartTime = value;
}
/// QA 调试用:把后台停留时间虚拟为指定时长,便于手工触发 idle summary。
///
/// Release 包中不生效,避免接入方误用影响真实 session 规则。
bool debugSetBackgroundElapsedForQa(Duration elapsed) {
if (kReleaseMode) return false;
if (elapsed <= Duration.zero) return false;
final now = DateTime.now();
_backgroundTime = now.subtract(elapsed);
_lastActivityTime = now.subtract(elapsed);
return true;
}
/// 手动重置会话(用于测试或特殊场景)
void reset() {
_currentSessionId = null;
_sessionStartTime = null;
_lastActivityTime = null;
_backgroundTime = null;
_notifyingSessionEndTime = null;
_isInitialized = false;
Logger.analyticsSdk('会话已重置');
}
void _notifySessionEndingIfNeeded(String reason) {
if (_currentSessionId == null || !_isInitialized) return;
final previousEndTime = _notifyingSessionEndTime;
_notifyingSessionEndTime = _logicalSessionEndTime(
reason: reason,
now: DateTime.now(),
);
try {
onSessionEnding?.call(reason);
} catch (e) {
Logger.analyticsSdk('旧 session 结束回调执行失败: $e', level: LogLevel.warn);
} finally {
_notifyingSessionEndTime = previousEndTime;
}
}
DateTime _logicalSessionEndTime({
required String reason,
required DateTime now,
}) {
if (reason != 'idle') return now;
final sessionStart = _sessionStartTime ?? now;
final rawLastActivity = _lastActivityTime ?? sessionStart;
final lastActivity =
rawLastActivity.isBefore(sessionStart) ? sessionStart : rawLastActivity;
final logicalEnd = lastActivity.add(sessionTimeout);
return logicalEnd.isAfter(now) ? now : logicalEnd;
}
bool _isEffectiveActivityEvent(Map<String, dynamic> event) {
final eventName = event['event']?.toString() ?? '';
if (eventName.isEmpty) return false;
if (eventName == 'app_lifecycle') {
final key = _lifecycleEventKey(event);
return _effectiveLifecycleKeys.contains(key);
}
if (_nonBusinessActivityEvents.contains(eventName)) return false;
return true;
}
String _lifecycleEventKey(Map<String, dynamic> event) {
final payload = event['payload'];
if (payload is Map) {
final value = payload['lifecycle_event_key']?.toString().trim() ?? '';
if (value.isNotEmpty) return value;
}
return event['lifecycle_event_key']?.toString().trim() ?? '';
}
}
@@ -0,0 +1,300 @@
import 'dart:convert';
import 'package:crypto/crypto.dart';
/// Session 内自动汇总指标。
///
/// 只记录成功进入 SDK 队列的事件。这里不采集滚动轨迹、键盘输入内容、
/// 长按、双指缩放等高风险/不稳定指标,避免为了汇总事件扩大监听面。
class SessionMetricsManager {
static final SessionMetricsManager instance =
SessionMetricsManager._internal();
factory SessionMetricsManager() => instance;
SessionMetricsManager._internal();
static const int _maxBehaviorSequence = 200;
static const int _maxPageStayRecords = 100;
static const Set<String> _nonBusinessEvents = {
'sdk_init',
'app_lifecycle',
'network_state_change',
'app_session_summary',
'app_page_view',
'page_click',
'navigation',
'page_lifecycle',
};
String _sid = '';
int _eventCountTotal = 0;
int _businessEventCount = 0;
int _foregroundCount = 0;
int _backgroundCountInSid = 0;
int _pageViewCount = 0;
int _pageClickCount = 0;
int _networkChangeCount = 0;
String _lastEventName = '';
final Set<String> _pageKeys = <String>{};
final Set<String> _pagesWithInteraction = <String>{};
final List<String> _behaviorSequence = <String>[];
final List<_PageStayRecord> _activePageStays = <_PageStayRecord>[];
final List<_PageStayRecord> _completedPageStays = <_PageStayRecord>[];
void recordAcceptedEvent(Map<String, dynamic> event) {
final eventName = event['event']?.toString() ?? '';
if (eventName.isEmpty || eventName == 'app_session_summary') return;
final sid = event['sid']?.toString() ?? '';
if (sid != _sid) {
reset(sid: sid);
}
_eventCountTotal += 1;
_lastEventName = eventName;
_recordBehaviorSequence(eventName, _payloadPageKey(event));
if (!_nonBusinessEvents.contains(eventName)) {
_businessEventCount += 1;
}
if (eventName == 'app_page_view') {
_pageViewCount += 1;
_recordPageKey(event);
} else if (eventName == 'page_click') {
_pageClickCount += 1;
_recordPageKey(event);
final pageKey = _payloadPageKey(event);
if (pageKey.isNotEmpty) _pagesWithInteraction.add(pageKey);
} else if (eventName == 'app_lifecycle') {
final key = _payloadString(event, 'lifecycle_event_key',
fallback: event['lifecycle_event_key']);
if (key == 'foreground') {
_foregroundCount += 1;
} else if (key == 'background') {
_backgroundCountInSid += 1;
}
} else if (eventName == 'network_state_change') {
_networkChangeCount += 1;
}
}
void recordPageEnter({
required String pageKey,
required String pageName,
required DateTime enterTime,
}) {
final normalizedPageKey = pageKey.trim();
if (normalizedPageKey.isEmpty) return;
_pageKeys.add(normalizedPageKey);
_closeActivePageStays(exitTime: enterTime);
_activePageStays.add(_PageStayRecord(
pageKey: normalizedPageKey,
pageName: pageName,
enterTime: enterTime,
));
}
void recordPageExit({
required String pageKey,
required String pageName,
required DateTime enterTime,
required DateTime exitTime,
}) {
final normalizedPageKey = pageKey.trim();
if (normalizedPageKey.isEmpty) return;
_pageKeys.add(normalizedPageKey);
final index = _findActivePageStayIndex(normalizedPageKey, enterTime);
if (index < 0) return;
final record = _activePageStays.removeAt(index);
_completedPageStays.add(record.close(
exitTime: exitTime,
hasInteraction: _pagesWithInteraction.contains(normalizedPageKey),
));
_trimCompletedPageStays();
}
SessionMetricsSnapshot snapshot({DateTime? endedAt}) {
final now = endedAt ?? DateTime.now();
return SessionMetricsSnapshot(
eventCountTotal: _eventCountTotal,
businessEventCount: _businessEventCount,
foregroundCount: _foregroundCount,
backgroundCountInSid: _backgroundCountInSid,
pageCount: _pageKeys.length,
pageViewCount: _pageViewCount,
pageClickCount: _pageClickCount,
networkChangeCount: _networkChangeCount,
sessionSid: _sid,
lastEventName: _lastEventName,
pageStayList: _pageStayList(now),
behaviorSequenceHash: _behaviorSequenceHash(),
);
}
void reset({String sid = ''}) {
_sid = sid;
_eventCountTotal = 0;
_businessEventCount = 0;
_foregroundCount = 0;
_backgroundCountInSid = 0;
_pageViewCount = 0;
_pageClickCount = 0;
_networkChangeCount = 0;
_lastEventName = '';
_pageKeys.clear();
_pagesWithInteraction.clear();
_behaviorSequence.clear();
_activePageStays.clear();
_completedPageStays.clear();
}
void _recordPageKey(Map<String, dynamic> event) {
final pageKey = _payloadPageKey(event);
if (pageKey.isNotEmpty) _pageKeys.add(pageKey);
}
String _payloadPageKey(Map<String, dynamic> event) {
return _payloadString(event, 'page_key');
}
String _payloadString(
Map<String, dynamic> event,
String key, {
Object? fallback,
}) {
final payload = event['payload'];
if (payload is Map) {
final value = payload[key]?.toString().trim() ?? '';
if (value.isNotEmpty) return value;
}
return fallback?.toString().trim() ?? '';
}
void _recordBehaviorSequence(String eventName, String pageKey) {
final token = pageKey.isEmpty ? eventName : '$eventName:$pageKey';
_behaviorSequence.add(token);
if (_behaviorSequence.length > _maxBehaviorSequence) {
_behaviorSequence.removeAt(0);
}
}
String _behaviorSequenceHash() {
if (_behaviorSequence.isEmpty) return '';
return sha256.convert(utf8.encode(_behaviorSequence.join('|'))).toString();
}
List<Map<String, dynamic>> _pageStayList(DateTime endedAt) {
final all = [
..._completedPageStays,
..._activePageStays.map((record) => record.close(
exitTime: endedAt,
hasInteraction: _pagesWithInteraction.contains(record.pageKey),
)),
]..sort((a, b) => a.enterTime.compareTo(b.enterTime));
final capped = all.length > _maxPageStayRecords
? all.sublist(all.length - _maxPageStayRecords)
: all;
return capped.map((record) => record.toJson()).toList();
}
void _closeActivePageStays({required DateTime exitTime}) {
if (_activePageStays.isEmpty) return;
for (final record in _activePageStays) {
_completedPageStays.add(record.close(
exitTime: exitTime,
hasInteraction: _pagesWithInteraction.contains(record.pageKey),
));
}
_activePageStays.clear();
_trimCompletedPageStays();
}
int _findActivePageStayIndex(String pageKey, DateTime enterTime) {
for (var i = _activePageStays.length - 1; i >= 0; i--) {
final record = _activePageStays[i];
if (record.pageKey == pageKey && record.enterTime == enterTime) return i;
}
return -1;
}
void _trimCompletedPageStays() {
while (_completedPageStays.length > _maxPageStayRecords) {
_completedPageStays.removeAt(0);
}
}
}
class SessionMetricsSnapshot {
final int eventCountTotal;
final int businessEventCount;
final int foregroundCount;
final int backgroundCountInSid;
final int pageCount;
final int pageViewCount;
final int pageClickCount;
final int networkChangeCount;
final String sessionSid;
final String lastEventName;
final List<Map<String, dynamic>> pageStayList;
final String behaviorSequenceHash;
const SessionMetricsSnapshot({
required this.eventCountTotal,
required this.businessEventCount,
required this.foregroundCount,
required this.backgroundCountInSid,
required this.pageCount,
required this.pageViewCount,
required this.pageClickCount,
required this.networkChangeCount,
required this.sessionSid,
required this.lastEventName,
required this.pageStayList,
required this.behaviorSequenceHash,
});
}
class _PageStayRecord {
final String pageKey;
final String pageName;
final DateTime enterTime;
final DateTime? exitTime;
final bool hasInteraction;
const _PageStayRecord({
required this.pageKey,
required this.pageName,
required this.enterTime,
this.exitTime,
this.hasInteraction = false,
});
_PageStayRecord close({
required DateTime exitTime,
required bool hasInteraction,
}) {
return _PageStayRecord(
pageKey: pageKey,
pageName: pageName,
enterTime: enterTime,
exitTime: exitTime,
hasInteraction: hasInteraction,
);
}
Map<String, dynamic> toJson() {
final endTime = exitTime ?? DateTime.now();
final stayDurationMs = endTime.difference(enterTime).inMilliseconds;
return {
'page_key': pageKey,
'page_name': pageName,
'stay_duration_ms': stayDurationMs < 0 ? 0 : stayDurationMs,
'enter_time': enterTime.millisecondsSinceEpoch ~/ 1000,
'has_interaction': hasInteraction ? 1 : 0,
};
}
}
@@ -0,0 +1,2 @@
export 'session_sensor_proximity_stub.dart'
if (dart.library.io) 'session_sensor_proximity_native.dart';
@@ -0,0 +1,17 @@
import 'dart:io';
import 'package:flutter/services.dart';
const EventChannel _proximityChannel = EventChannel('analytics_sdk/proximity');
Stream<int> proximityEvents() {
if (!Platform.isAndroid && !Platform.isIOS) {
return const Stream<int>.empty();
}
return _proximityChannel.receiveBroadcastStream().map((value) {
if (value is bool) return value ? 1 : 0;
if (value is num) return value == 0 ? 0 : 1;
final text = value?.toString().trim().toLowerCase() ?? '';
return text == 'true' || text == '1' ? 1 : 0;
});
}
@@ -0,0 +1 @@
Stream<int> proximityEvents() => const Stream<int>.empty();
@@ -0,0 +1,467 @@
import 'dart:async';
import 'dart:convert';
import 'dart:math';
import 'package:analytics_sdk/manager/session_sensor_proximity.dart';
import 'package:analytics_sdk/utils/logger.dart';
import 'package:crypto/crypto.dart';
import 'package:flutter/foundation.dart';
import 'package:sensors_plus/sensors_plus.dart';
enum SensorSampleType {
accelerometer,
gyroscope,
magnetometer,
proximity,
}
class SensorMagnitudeSample {
final SensorSampleType type;
final double magnitude;
final DateTime sampledAt;
const SensorMagnitudeSample({
required this.type,
required this.magnitude,
required this.sampledAt,
});
}
class SessionSensorSnapshot {
final String sensorSignature;
final int sensorCountActive;
const SessionSensorSnapshot({
required this.sensorSignature,
required this.sensorCountActive,
});
const SessionSensorSnapshot.unsupported()
: sensorSignature = '',
sensorCountActive = -1;
}
typedef SensorStreamFactory = Stream<SensorMagnitudeSample> Function(
SensorSampleType type,
Duration samplingPeriod,
);
typedef PlatformSupportResolver = bool Function();
/// Android/iOS session-level sensor sampler.
///
/// It only keeps rolling aggregate statistics for the current sid. Raw sensor
/// sequences are never retained or exposed.
class SessionSensorSampler {
static final SessionSensorSampler instance = SessionSensorSampler._internal();
static const Duration _defaultWindowInterval = Duration(seconds: 60);
static const Duration _defaultWindowDuration = Duration(seconds: 5);
static const Duration _defaultSamplingPeriod = Duration(milliseconds: 200);
static const int _defaultMaxWindowCount = 6;
final PlatformSupportResolver _isSupportedPlatform;
final SensorStreamFactory _sensorStreams;
final Duration _windowInterval;
final Duration _windowDuration;
final Duration _samplingPeriod;
final int _maxWindowCount;
String _sid = '';
bool _isForeground = true;
bool _hasForegroundPageView = false;
bool _samplingStarted = false;
bool _samplingUnavailable = false;
bool _windowActive = false;
int _windowsStarted = 0;
Timer? _nextWindowTimer;
Timer? _windowStopTimer;
final List<StreamSubscription<SensorMagnitudeSample>> _subscriptions =
<StreamSubscription<SensorMagnitudeSample>>[];
final Map<SensorSampleType, _SensorStats> _stats =
<SensorSampleType, _SensorStats>{};
factory SessionSensorSampler() => instance;
SessionSensorSampler._internal()
: _isSupportedPlatform = _defaultPlatformSupported,
_sensorStreams = _defaultSensorStreams,
_windowInterval = _defaultWindowInterval,
_windowDuration = _defaultWindowDuration,
_samplingPeriod = _defaultSamplingPeriod,
_maxWindowCount = _defaultMaxWindowCount;
@visibleForTesting
SessionSensorSampler.forTest({
required PlatformSupportResolver isSupportedPlatform,
required SensorStreamFactory sensorStreams,
Duration windowInterval = _defaultWindowInterval,
Duration windowDuration = _defaultWindowDuration,
Duration samplingPeriod = _defaultSamplingPeriod,
int maxWindowCount = _defaultMaxWindowCount,
}) : _isSupportedPlatform = isSupportedPlatform,
_sensorStreams = sensorStreams,
_windowInterval = windowInterval,
_windowDuration = windowDuration,
_samplingPeriod = samplingPeriod,
_maxWindowCount = maxWindowCount;
void recordAcceptedEvent(Map<String, dynamic> event) {
final eventName = event['event']?.toString() ?? '';
if (eventName.isEmpty || eventName == 'app_session_summary') return;
final sid = event['sid']?.toString() ?? '';
if (sid != _sid) {
reset(sid: sid);
}
if (eventName == 'app_page_view') {
_hasForegroundPageView = _isForeground;
_ensureSamplingTask();
return;
}
if (eventName != 'app_lifecycle') return;
final key = _lifecycleEventKey(event);
if (key == 'background') {
markBackground();
} else if (key == 'foreground') {
markForeground();
}
}
void markForeground() {
_isForeground = true;
_hasForegroundPageView = false;
}
String _lifecycleEventKey(Map<String, dynamic> event) {
final payload = event['payload'];
if (payload is Map) {
final value = payload['lifecycle_event_key']?.toString().trim() ?? '';
if (value.isNotEmpty) return value;
}
return event['lifecycle_event_key']?.toString().trim() ?? '';
}
void markBackground() {
_isForeground = false;
_hasForegroundPageView = false;
_pauseSamplingTask();
}
void reset({String sid = ''}) {
_pauseSamplingTask();
_sid = sid;
_isForeground = true;
_hasForegroundPageView = false;
_samplingStarted = false;
_samplingUnavailable = false;
_windowsStarted = 0;
_stats.clear();
}
SessionSensorSnapshot snapshot({int screenBrightness = -1}) {
if (_samplingUnavailable || !_samplingStarted) {
return const SessionSensorSnapshot.unsupported();
}
final activeCount = _stats.entries
.where((entry) => entry.value.isActive(_activeThreshold(entry.key)))
.length;
final totalSamples =
_stats.values.fold<int>(0, (sum, stats) => sum + stats.count);
if (totalSamples <= 0) {
return const SessionSensorSnapshot(
sensorSignature: '',
sensorCountActive: 0,
);
}
if (activeCount <= 0) {
return const SessionSensorSnapshot(
sensorSignature: '',
sensorCountActive: 0,
);
}
final signatureInput = _signatureInput(screenBrightness: screenBrightness);
return SessionSensorSnapshot(
sensorSignature: sha256.convert(utf8.encode(signatureInput)).toString(),
sensorCountActive: activeCount,
);
}
Future<void> dispose() async {
_pauseSamplingTask();
_stats.clear();
}
@visibleForTesting
void debugMarkSamplingStartedForTest() {
_samplingStarted = true;
_samplingUnavailable = false;
}
@visibleForTesting
void debugRecordSampleForTest(
SensorSampleType type, {
required double magnitude,
required DateTime sampledAt,
}) {
_recordSample(SensorMagnitudeSample(
type: type,
magnitude: magnitude,
sampledAt: sampledAt,
));
}
void _ensureSamplingTask() {
if (!_isForeground || !_hasForegroundPageView) return;
if (_windowsStarted >= _maxWindowCount) return;
if (_windowActive || _nextWindowTimer != null) return;
if (!_isSupportedPlatform()) {
_samplingUnavailable = true;
return;
}
_startWindow();
}
void _startWindow() {
if (!_isForeground || !_hasForegroundPageView) return;
if (_windowsStarted >= _maxWindowCount) return;
if (!_isSupportedPlatform()) {
_samplingUnavailable = true;
return;
}
final subscriptions = <StreamSubscription<SensorMagnitudeSample>>[];
for (final type in SensorSampleType.values) {
try {
final subscription = _sensorStreams(type, _samplingPeriod).listen(
_recordSample,
onError: (Object error) {
Logger.analyticsSdk('传感器采样异常($type): $error', level: LogLevel.warn);
},
cancelOnError: false,
);
subscriptions.add(subscription);
} catch (e) {
Logger.analyticsSdk('传感器监听启动失败($type): $e', level: LogLevel.warn);
}
}
if (subscriptions.isEmpty) {
_samplingUnavailable = true;
return;
}
_subscriptions.addAll(subscriptions);
_samplingStarted = true;
_samplingUnavailable = false;
_windowActive = true;
_windowsStarted += 1;
_windowStopTimer?.cancel();
_windowStopTimer = Timer(_windowDuration, _finishWindow);
}
void _finishWindow() {
_stopCurrentWindow();
if (!_isForeground || !_hasForegroundPageView) return;
if (_windowsStarted >= _maxWindowCount) return;
_nextWindowTimer?.cancel();
_nextWindowTimer = Timer(_windowInterval, () {
_nextWindowTimer = null;
_startWindow();
});
}
void _pauseSamplingTask() {
_nextWindowTimer?.cancel();
_nextWindowTimer = null;
_windowStopTimer?.cancel();
_windowStopTimer = null;
_stopCurrentWindow();
}
void _stopCurrentWindow() {
_windowActive = false;
for (final subscription in _subscriptions) {
unawaited(subscription.cancel());
}
_subscriptions.clear();
}
void _recordSample(SensorMagnitudeSample sample) {
if (!sample.magnitude.isFinite) return;
final stats = _stats.putIfAbsent(sample.type, _SensorStats.new);
if (!stats.canRecord(sample.sampledAt, _samplingPeriod)) return;
stats.record(sample.magnitude, sample.sampledAt);
}
String _signatureInput({required int screenBrightness}) {
final tokens = <String>['sid=$_sid', 'windows=$_windowsStarted'];
if (screenBrightness >= 0 && screenBrightness <= 100) {
tokens.add('screen_brightness=$screenBrightness');
}
for (final type in SensorSampleType.values) {
final stats = _stats[type];
if (stats == null || stats.count == 0) continue;
tokens.add('${_typeCode(type)}:${stats.signatureToken}');
}
return tokens.join('|');
}
static bool _defaultPlatformSupported() {
return isSupportedPlatformForTest(
isWeb: kIsWeb,
platform: defaultTargetPlatform,
);
}
@visibleForTesting
static bool isSupportedPlatformForTest({
required bool isWeb,
required TargetPlatform platform,
}) {
return !isWeb &&
(platform == TargetPlatform.android || platform == TargetPlatform.iOS);
}
static Stream<SensorMagnitudeSample> _defaultSensorStreams(
SensorSampleType type,
Duration samplingPeriod,
) {
switch (type) {
case SensorSampleType.accelerometer:
return accelerometerEvents.map(
(event) => SensorMagnitudeSample(
type: type,
magnitude: _magnitude(event.x, event.y, event.z),
sampledAt: DateTime.now(),
),
);
case SensorSampleType.gyroscope:
return gyroscopeEvents.map(
(event) => SensorMagnitudeSample(
type: type,
magnitude: _magnitude(event.x, event.y, event.z),
sampledAt: DateTime.now(),
),
);
case SensorSampleType.magnetometer:
return magnetometerEvents.map(
(event) => SensorMagnitudeSample(
type: type,
magnitude: _magnitude(event.x, event.y, event.z),
sampledAt: DateTime.now(),
),
);
case SensorSampleType.proximity:
return proximityEvents().map(
(value) => SensorMagnitudeSample(
type: type,
magnitude: value == 0 ? 0 : 1,
sampledAt: DateTime.now(),
),
);
}
}
static double _magnitude(double x, double y, double z) {
return sqrt(x * x + y * y + z * z);
}
static String _typeCode(SensorSampleType type) {
switch (type) {
case SensorSampleType.accelerometer:
return 'acc';
case SensorSampleType.gyroscope:
return 'gyro';
case SensorSampleType.magnetometer:
return 'mag';
case SensorSampleType.proximity:
return 'prox';
}
}
static double _activeThreshold(SensorSampleType type) {
switch (type) {
case SensorSampleType.accelerometer:
return 0.8;
case SensorSampleType.gyroscope:
return 0.05;
case SensorSampleType.magnetometer:
return 3.0;
case SensorSampleType.proximity:
return 1.0;
}
}
}
class _SensorStats {
int count = 0;
double mean = 0;
double _m2 = 0;
double minValue = double.infinity;
double maxValue = double.negativeInfinity;
DateTime? _lastSampleAt;
int _intervalCount = 0;
int _totalIntervalMs = 0;
bool canRecord(DateTime sampledAt, Duration minInterval) {
final last = _lastSampleAt;
if (last == null) return true;
return sampledAt.difference(last) >= minInterval;
}
void record(double value, DateTime sampledAt) {
final last = _lastSampleAt;
if (last != null) {
final intervalMs = sampledAt.difference(last).inMilliseconds;
if (intervalMs > 0) {
_intervalCount += 1;
_totalIntervalMs += intervalMs;
}
}
_lastSampleAt = sampledAt;
count += 1;
final delta = value - mean;
mean += delta / count;
final delta2 = value - mean;
_m2 += delta * delta2;
if (value < minValue) minValue = value;
if (value > maxValue) maxValue = value;
}
double get variance => count > 1 ? _m2 / (count - 1) : 0;
double get range {
if (count <= 0) return 0;
return maxValue - minValue;
}
int get averageIntervalMs {
if (_intervalCount <= 0) return 0;
return (_totalIntervalMs / _intervalCount).round();
}
bool isActive(double threshold) {
return count > 0 && range >= threshold;
}
String get signatureToken {
return [
'n=$count',
'mean=${_fixed(mean)}',
'var=${_fixed(variance)}',
'min=${_fixed(minValue)}',
'max=${_fixed(maxValue)}',
'range=${_fixed(range)}',
'avg_i=$averageIntervalMs',
].join(',');
}
static String _fixed(double value) {
if (!value.isFinite) return '0.000';
return value.toStringAsFixed(3);
}
}
@@ -0,0 +1,35 @@
import 'package:analytics_sdk/enum/user_type_enum.dart';
/// 全局用户管理器
/// 负责管理用户类型信息,支持用户类型更新和登出操作
class UserManager {
static final UserManager instance = UserManager._internal();
factory UserManager() => instance;
UserManager._internal();
/// 当前用户类型:normal、vip 等
String _userType = UserTypeEnum.normal.label;
String get userType => _userType;
static String normalizeUserType(String? value) {
return value?.trim().toLowerCase() == UserTypeEnum.vip.label
? UserTypeEnum.vip.label
: UserTypeEnum.normal.label;
}
/// 更新用户类型(登录、升级会员、切换账号时调用)
void updateUserType(String newType) {
final normalized = normalizeUserType(newType);
if (_userType != normalized) {
_userType = normalized;
}
}
/// 登出
void logout() {
_userType = UserTypeEnum.normal.label; // 或 'guest',根据业务决定
}
}
@@ -0,0 +1,160 @@
import 'dart:async';
import 'package:analytics_sdk/entity/app_lifecycle_event.dart';
import 'package:analytics_sdk/manager/session_manager.dart';
import 'package:analytics_sdk/observer/page_lifecycle_observer.dart';
import 'package:analytics_sdk/utils/environment_info_util.dart';
import 'package:analytics_sdk/utils/logger.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
/// 应用生命周期观察者:监听应用前后台切换,用于会话管理与生命周期埋点
class AppLifecycleObserver with WidgetsBindingObserver {
static final AppLifecycleObserver instance = AppLifecycleObserver._internal();
factory AppLifecycleObserver() => instance;
AppLifecycleObserver._internal();
bool _isInitialized = false;
void Function(dynamic event)? _track;
DateTime Function() _now = DateTime.now;
DateTime? _foregroundStartedAt;
DateTime? _backgroundStartedAt;
int _foregroundCountInSid = 0;
String _foregroundCountSid = '';
/// 初始化观察者
void initialize({
void Function(dynamic event)? track,
DateTime Function()? now,
}) {
if (track != null) _track = track;
if (now != null) _now = now;
_foregroundStartedAt ??= _now();
if (!_isInitialized) {
WidgetsBinding.instance.addObserver(this);
_isInitialized = true;
Logger.analyticsSdk('应用生命周期观察者已初始化');
}
}
/// 销毁观察者
void dispose() {
if (_isInitialized) {
WidgetsBinding.instance.removeObserver(this);
_isInitialized = false;
Logger.analyticsSdk('应用生命周期观察者已销毁');
}
_track = null;
_foregroundStartedAt = null;
_backgroundStartedAt = null;
_foregroundCountInSid = 0;
_foregroundCountSid = '';
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
super.didChangeAppLifecycleState(state);
try {
switch (state) {
case AppLifecycleState.resumed:
if (!kIsWeb) {
final now = _now();
final backgroundStartedAt = _backgroundStartedAt;
final backgroundDurationMs = backgroundStartedAt == null
? 0
: now.difference(backgroundStartedAt).inMilliseconds;
// 应用从后台恢复前台
SessionManager.instance.onAppForeground();
if (backgroundStartedAt != null) {
_emit(AppLifecycleEvent.warmStart(
backgroundDurationMs: backgroundDurationMs,
resumeReason: 'unknown',
));
_emit(AppLifecycleEvent.foreground(
foregroundCountInSid: _nextForegroundCountInSid(),
));
}
_backgroundStartedAt = null;
_foregroundStartedAt = now;
}
unawaited(
EnvironmentInfoUtil.refreshBatteryLevelIfNeeded(force: true),
);
unawaited(EnvironmentInfoUtil.refreshRuntimeObservationFields());
Logger.analyticsSdk('应用恢复前台');
break;
case AppLifecycleState.paused:
if (!kIsWeb) {
final now = _now();
final foregroundDurationMs = _foregroundStartedAt == null
? 0
: now.difference(_foregroundStartedAt!).inMilliseconds;
_emit(AppLifecycleEvent.background(
foregroundDurationMs: foregroundDurationMs,
lastActivePageKey: PageLifecycleObserver.currentPageKey,
));
// Android 和 iOS 进入后台均走 paused(iOS 先经过 hidden 再到 paused)
// 统一在此记录后台开始时间,避免在 hidden 重复记录
SessionManager.instance.onAppBackground();
_backgroundStartedAt = now;
}
Logger.analyticsSdk('应用进入后台');
break;
case AppLifecycleState.inactive:
// inactive 是过渡状态(进后台和回前台都会经过),不记录后台时间
Logger.analyticsSdk('应用进入 inactive 状态');
break;
case AppLifecycleState.detached:
if (!kIsWeb) {
final now = _now();
_emit(AppLifecycleEvent.terminate(
lastActivePageKey: PageLifecycleObserver.currentPageKey,
terminateType: 'unknown',
sessionDurationMs: _sessionDurationMs(now),
terminateTs: now.millisecondsSinceEpoch ~/ 1000,
));
}
Logger.analyticsSdk('应用即将终止');
break;
default:
// Flutter 3.0 没有 hidden;较新版本的 hidden 是过渡状态,
// 不应影响会话开始或后台时长。
Logger.analyticsSdk('应用生命周期过渡状态: $state');
break;
}
} catch (e) {
Logger.analyticsSdk('应用生命周期状态变化处理异常: $e');
_emit(AppLifecycleEvent.unknown(unknownReason: 'collect_error'));
}
}
void _emit(AppLifecycleEvent event) {
try {
final track = _track;
if (track == null) return;
track(event);
} catch (e) {
Logger.analyticsSdk('app_lifecycle 上报异常,已安全处理: $e');
}
}
int _nextForegroundCountInSid() {
final sid = SessionManager.instance.currentSessionId ?? '';
if (sid != _foregroundCountSid) {
_foregroundCountSid = sid;
_foregroundCountInSid = 0;
}
_foregroundCountInSid += 1;
return _foregroundCountInSid;
}
int _sessionDurationMs(DateTime now) {
final start = SessionManager.instance.sessionStartTime;
if (start == null) return 0;
final duration = now.difference(start).inMilliseconds;
return duration < 0 ? 0 : duration;
}
}

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