Initial commit: 91dj android client dev_1.0.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,105 @@
|
||||
import 'package:analytics_sdk/config/sdk_config.dart';
|
||||
import 'package:analytics_sdk/utils/logger.dart';
|
||||
|
||||
/// 广告展示去重管理器
|
||||
///
|
||||
/// 以页面为粒度做去重:同一页面访问内不重复上报同一广告,
|
||||
/// 页面退出后去重状态随之清除,下次进入同一页面广告可重新上报。
|
||||
/// 新 session 时调用 [clear] 清除所有页面记录。
|
||||
class AdImpressionManager {
|
||||
static final AdImpressionManager instance = AdImpressionManager._internal();
|
||||
|
||||
factory AdImpressionManager() => instance;
|
||||
|
||||
AdImpressionManager._internal();
|
||||
|
||||
/// 页面级广告去重表:pageKey → 已上报 adId 集合
|
||||
final Map<String, Set<String>> _reportedAdIdsByPage = {};
|
||||
|
||||
/// 广告 SDK 曝光已上报的 trace_id;同一 session 内全局去重。
|
||||
final Set<String> _reportedSdkTraceIds = {};
|
||||
|
||||
/// 当前已存储的广告ID总数(跨所有页面)
|
||||
int get reportedCount =>
|
||||
_reportedAdIdsByPage.values.fold(0, (sum, s) => sum + s.length);
|
||||
|
||||
/// 批量检查多个广告ID,返回在指定页面内尚未上报的列表(不修改状态)
|
||||
List<String> getUnreportedAdIds(String adIds, {required String pageKey}) {
|
||||
if (adIds.isEmpty) return [];
|
||||
|
||||
final adIdList = adIds
|
||||
.split(',')
|
||||
.map((id) => id.trim())
|
||||
.where((id) => id.isNotEmpty)
|
||||
.toList();
|
||||
|
||||
if (adIdList.isEmpty) return [];
|
||||
|
||||
final pageSet = _reportedAdIdsByPage[pageKey];
|
||||
if (pageSet == null || pageSet.isEmpty) return adIdList;
|
||||
|
||||
return adIdList.where((id) => !pageSet.contains(id)).toList();
|
||||
}
|
||||
|
||||
/// 在指定页面内批量标记广告ID为已上报
|
||||
void markAsReportedBatch(String adIds, {required String pageKey}) {
|
||||
if (adIds.isEmpty) return;
|
||||
|
||||
final adIdList =
|
||||
adIds.split(',').map((id) => id.trim()).where((id) => id.isNotEmpty);
|
||||
|
||||
final pageSet = _reportedAdIdsByPage.putIfAbsent(pageKey, () => {});
|
||||
for (final id in adIdList) {
|
||||
if (reportedCount >= SdkConfig.maxAdImpressionCapacity) {
|
||||
Logger.adImpressionManager(
|
||||
'广告去重表已达上限(${SdkConfig.maxAdImpressionCapacity}),停止写入,跳过 id: $id',
|
||||
level: LogLevel.warn);
|
||||
break;
|
||||
}
|
||||
pageSet.add(id);
|
||||
}
|
||||
}
|
||||
|
||||
/// 指定 trace_id 是否已作为广告 SDK 曝光上报。
|
||||
bool isSdkTraceReported(String traceId) {
|
||||
return _reportedSdkTraceIds.contains(traceId.trim());
|
||||
}
|
||||
|
||||
/// 在广告 SDK 曝光成功入队后标记其 trace_id。
|
||||
void markSdkTraceReported(String traceId) {
|
||||
final normalized = traceId.trim();
|
||||
if (normalized.isEmpty || isSdkTraceReported(normalized)) return;
|
||||
if (_reportedSdkTraceIds.length >= SdkConfig.maxAdImpressionCapacity) {
|
||||
Logger.adImpressionManager(
|
||||
'广告 SDK 曝光 trace_id 去重表已达上限(${SdkConfig.maxAdImpressionCapacity}),停止写入: $normalized',
|
||||
level: LogLevel.warn,
|
||||
);
|
||||
return;
|
||||
}
|
||||
_reportedSdkTraceIds.add(normalized);
|
||||
}
|
||||
|
||||
/// 清除指定页面的广告去重记录(页面退出时调用,下次进入可重新上报)
|
||||
void clearPage(String pageKey) {
|
||||
if (_reportedAdIdsByPage.remove(pageKey) != null) {
|
||||
Logger.adImpressionManager('页面 "$pageKey" 广告去重记录已清除');
|
||||
}
|
||||
}
|
||||
|
||||
/// 清除所有页面的广告去重记录(新 session 开始时调用)
|
||||
void clear() {
|
||||
_reportedAdIdsByPage.clear();
|
||||
_reportedSdkTraceIds.clear();
|
||||
}
|
||||
|
||||
// ── 以下方法供测试使用 ──────────────────────────────
|
||||
|
||||
/// 检查广告ID在指定页面内是否已上报(不修改状态)
|
||||
bool isReported(String adId, {required String pageKey}) {
|
||||
return _reportedAdIdsByPage[pageKey]?.contains(adId.trim()) ?? false;
|
||||
}
|
||||
|
||||
/// [isReported] 的别名,用于断言
|
||||
bool contains(String adId, {required String pageKey}) =>
|
||||
isReported(adId, pageKey: pageKey);
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'package:analytics_sdk/config/sdk_config.dart';
|
||||
import 'package:analytics_sdk/utils/logger.dart';
|
||||
import 'package:analytics_sdk/utils/platform_storage.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
/// 域名管理器:对业务方传入的域名列表进行并发测速,选择最快的上报域名
|
||||
class DomainManager {
|
||||
static final DomainManager instance = DomainManager._internal();
|
||||
|
||||
factory DomainManager() => instance;
|
||||
|
||||
DomainManager._internal();
|
||||
|
||||
List<String> _reportDomains = [];
|
||||
String? _fastestDomain;
|
||||
|
||||
/// 每次 initWithDomains / reset 时递增,用于丢弃过期测速结果
|
||||
int _generation = 0;
|
||||
|
||||
http.Client? _httpClient;
|
||||
http.Client get _client => _httpClient ??= http.Client();
|
||||
|
||||
/// 域名更新回调,当最快域名选出后调用
|
||||
Function(String? fastestDomain)? onFastestDomainChanged;
|
||||
|
||||
/// 获取最快的上报域名
|
||||
String? get fastestDomain => _fastestDomain;
|
||||
|
||||
/// 获取所有上报域名
|
||||
List<String> get reportDomains => List.unmodifiable(_reportDomains);
|
||||
|
||||
/// 传入已解密的域名列表,执行并发测速并选出最快域名
|
||||
///
|
||||
/// [domains] 每项为带协议的完整域名,如 "https://api.example.com"
|
||||
///
|
||||
/// 注意:此方法设计为 fire-and-forget,调用方无需 await,
|
||||
/// 测速完成后通过 [onFastestDomainChanged] 回调通知结果。
|
||||
Future<String?> initWithDomains(List<String> domains) async {
|
||||
// 记录本次调用的代次,测速完成后用于判断结果是否已过期
|
||||
final gen = ++_generation;
|
||||
try {
|
||||
if (domains.isEmpty) {
|
||||
Logger.domainManager('域名列表为空,跳过测速', level: LogLevel.warn);
|
||||
return null;
|
||||
}
|
||||
|
||||
_reportDomains = domains;
|
||||
Logger.domainManager('开始域名测速,共 ${domains.length} 个: $domains');
|
||||
|
||||
String? fastest;
|
||||
if (domains.length == 1) {
|
||||
fastest = domains.first;
|
||||
Logger.domainManager('只有一个域名,跳过测速: $fastest');
|
||||
} else {
|
||||
fastest = await _speedTestAndSelect(domains);
|
||||
Logger.domainManager('测速完成,最快域名: $fastest');
|
||||
}
|
||||
|
||||
// 若 reset() 或新一轮 initWithDomains 已被调用,丢弃本次过期结果
|
||||
if (gen != _generation) {
|
||||
Logger.domainManager('测速结果已过期(generation 不匹配),忽略本次结果');
|
||||
return null;
|
||||
}
|
||||
|
||||
_fastestDomain = fastest;
|
||||
// 测速完成后持久化域名列表(供下次 init 时解密失败时作为降级)
|
||||
_saveDomainCache();
|
||||
if (onFastestDomainChanged != null && _fastestDomain != null) {
|
||||
try {
|
||||
onFastestDomainChanged!(_fastestDomain);
|
||||
} catch (e) {
|
||||
Logger.domainManager('域名更新回调异常: $e');
|
||||
}
|
||||
}
|
||||
return _fastestDomain;
|
||||
} catch (e) {
|
||||
Logger.domainManager('域名测速失败: $e', level: LogLevel.warn);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// 重置域名管理器状态,供 re-init 时使用
|
||||
///
|
||||
/// 清除内存中的域名列表和最快域名,不关闭 HttpClient(复用连接池)。
|
||||
/// 不删除磁盘缓存(缓存代表最近已知可用状态,跨 init 保留)。
|
||||
void reset() {
|
||||
_generation++; // 使所有进行中的测速结果失效
|
||||
_fastestDomain = null;
|
||||
_reportDomains = [];
|
||||
onFastestDomainChanged = null; // 防止旧回调在新一轮测速前残留
|
||||
Logger.domainManager('域名管理器状态已重置');
|
||||
}
|
||||
|
||||
/// 释放 HttpClient 资源,通常在 SDK dispose 时调用
|
||||
void dispose() {
|
||||
_httpClient?.close();
|
||||
_httpClient = null;
|
||||
Logger.domainManager('域名管理器已销毁');
|
||||
}
|
||||
|
||||
/// 从磁盘加载上次缓存的域名列表
|
||||
///
|
||||
/// 返回非空列表时可直接传入 [initWithDomains]。
|
||||
/// 无缓存文件或读取失败时返回空列表(不抛出异常)。
|
||||
Future<List<String>> loadCachedDomains() async {
|
||||
try {
|
||||
final content =
|
||||
await PlatformStorage.read(SdkConfig.domainCacheFileName);
|
||||
if (content == null) return [];
|
||||
final decoded = jsonDecode(content);
|
||||
if (decoded is List) {
|
||||
return (decoded as List)
|
||||
.map<String>((item) => item.toString().trim())
|
||||
.where((d) => d.isNotEmpty)
|
||||
.toList();
|
||||
}
|
||||
return [];
|
||||
} catch (e) {
|
||||
Logger.domainManager('加载域名缓存失败: $e');
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 私有方法 ====================
|
||||
|
||||
/// 将当前 _reportDomains 写入磁盘缓存(fire-and-forget)
|
||||
void _saveDomainCache() {
|
||||
if (_reportDomains.isEmpty) return;
|
||||
final domainsSnapshot = List<String>.from(_reportDomains);
|
||||
Future(() async {
|
||||
try {
|
||||
await PlatformStorage.write(
|
||||
SdkConfig.domainCacheFileName,
|
||||
jsonEncode(domainsSnapshot),
|
||||
);
|
||||
Logger.domainManager('域名缓存已写入: $domainsSnapshot');
|
||||
} catch (e) {
|
||||
Logger.domainManager('写入域名缓存失败: $e');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// 对所有域名进行并发测速,返回最快的域名
|
||||
Future<String?> _speedTestAndSelect(List<String> domains) async {
|
||||
if (domains.isEmpty) return null;
|
||||
|
||||
final speedResults = <String, Duration>{};
|
||||
|
||||
final futures = domains.map((domain) async {
|
||||
try {
|
||||
final speed = await _speedTest(domain);
|
||||
if (speed != null) {
|
||||
speedResults[domain] = speed;
|
||||
Logger.domainManager('域名 $domain 测速: ${speed.inMilliseconds}ms');
|
||||
}
|
||||
} catch (e) {
|
||||
Logger.domainManager('域名 $domain 测速失败: $e');
|
||||
}
|
||||
});
|
||||
|
||||
await Future.wait(futures);
|
||||
|
||||
if (speedResults.isEmpty) {
|
||||
Logger.domainManager('所有域名测速失败,回退到使用第一个域名作为上报域名',
|
||||
level: LogLevel.warn);
|
||||
return domains.first;
|
||||
}
|
||||
|
||||
final fastest =
|
||||
speedResults.entries.reduce((a, b) => a.value < b.value ? a : b);
|
||||
|
||||
Logger.domainManager(
|
||||
'最快域名: ${fastest.key} (${fastest.value.inMilliseconds}ms)');
|
||||
return fastest.key;
|
||||
}
|
||||
|
||||
/// 测速单个域名,返回连接耗时;连接超时或网络异常返回 null
|
||||
///
|
||||
/// 仅 HTTP 200 视为可达。
|
||||
/// 非 200(3xx 重定向、4xx 客户端错误、5xx 服务端错误)/ 连接失败 / 超时 视为不可达,返回 null。
|
||||
Future<Duration?> _speedTest(String domain) async {
|
||||
try {
|
||||
final uri = Uri.parse(domain);
|
||||
final startTime = DateTime.now();
|
||||
final response =
|
||||
await _client.get(uri).timeout(SdkConfig.speedTestTimeout);
|
||||
final elapsed = DateTime.now().difference(startTime);
|
||||
if (response.statusCode != 200) {
|
||||
Logger.domainManager(
|
||||
'域名 $domain 返回 ${response.statusCode},视为不可达',
|
||||
level: LogLevel.warn);
|
||||
return null;
|
||||
}
|
||||
Logger.domainManager(
|
||||
'域名 $domain 可达,状态码: ${response.statusCode}');
|
||||
return elapsed;
|
||||
} catch (e) {
|
||||
Logger.domainManager('域名 $domain 测速失败: $e', level: LogLevel.warn);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export 'event_persistence_native.dart'
|
||||
// ignore: uri_does_not_exist
|
||||
if (dart.library.html) 'event_persistence_web.dart';
|
||||
|
||||
// EventPersistenceImpl 由条件导入文件提供
|
||||
// Native:lib/manager/event_persistence_native.dart
|
||||
// Web :lib/manager/event_persistence_web.dart
|
||||
@@ -0,0 +1,398 @@
|
||||
import 'dart:async';
|
||||
import 'dart:collection';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:analytics_sdk/config/sdk_config.dart';
|
||||
import 'package:analytics_sdk/utils/logger.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:synchronized/synchronized.dart';
|
||||
|
||||
/// Native 平台事件持久化:基于本地文件 + tombstone 机制。
|
||||
class EventPersistenceImpl {
|
||||
String? _cacheFilePath;
|
||||
String? _debugReportEventLogFilePath;
|
||||
String? _tombstoneFilePath;
|
||||
String? _inFlightFilePath;
|
||||
final Lock _cacheLock = Lock();
|
||||
final List<Map<String, dynamic>> _cacheBuffer = [];
|
||||
static int get _cacheBufferThreshold => SdkConfig.cacheBufferThreshold;
|
||||
static Duration get _cacheFlushInterval => SdkConfig.cacheFlushInterval;
|
||||
Timer? _cacheFlushTimer;
|
||||
|
||||
Future<void> init() async {
|
||||
try {
|
||||
final dir = await getApplicationDocumentsDirectory();
|
||||
_cacheFilePath = '${dir.path}/${SdkConfig.cacheFileName}';
|
||||
_debugReportEventLogFilePath =
|
||||
'${dir.path}/${SdkConfig.debugReportEventLogFileName}';
|
||||
_tombstoneFilePath = '${dir.path}/${SdkConfig.tombstoneFileName}';
|
||||
_inFlightFilePath = '${dir.path}/data_plus_events_inflight.txt';
|
||||
} catch (e) {
|
||||
Logger.analyticsSdk('持久化初始化失败: $e');
|
||||
_cacheFilePath = null;
|
||||
_debugReportEventLogFilePath = null;
|
||||
_tombstoneFilePath = null;
|
||||
}
|
||||
}
|
||||
|
||||
String? get debugReportEventLogFilePath =>
|
||||
SdkConfig.enableDebugReportLog ? _debugReportEventLogFilePath : null;
|
||||
|
||||
void appendDebugReportEvent(Map<String, dynamic> event) {
|
||||
if (!SdkConfig.enableDebugReportLog ||
|
||||
_debugReportEventLogFilePath == null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
File(_debugReportEventLogFilePath!).writeAsStringSync(
|
||||
'${jsonEncode(event)}\n',
|
||||
mode: FileMode.append,
|
||||
flush: true,
|
||||
);
|
||||
} catch (e) {
|
||||
Logger.analyticsSdk('debug 完整事件 JSONL 写入失败: $e', level: LogLevel.warn);
|
||||
}
|
||||
}
|
||||
|
||||
void bufferEvent(Map<String, dynamic> event) {
|
||||
if (_cacheFilePath == null) return;
|
||||
_cacheBuffer.add(event);
|
||||
if (_cacheBuffer.length >= _cacheBufferThreshold) {
|
||||
flushBuffer();
|
||||
} else {
|
||||
_cacheFlushTimer ??= Timer(_cacheFlushInterval, () {
|
||||
_cacheFlushTimer = null;
|
||||
flushBuffer();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// 同步写入关键事件,供应用终止阶段的会话汇总使用。
|
||||
///
|
||||
/// 此时 Dart isolate 可能在下一个 await 前被系统终止,不能依赖延迟
|
||||
/// buffer 或异步文件写入。事件已包含 event_id,后续仍通过 tombstone
|
||||
/// 和服务端 event_id 去重保证幂等。
|
||||
void persistCriticalEvent(Map<String, dynamic> event) {
|
||||
if (_cacheFilePath == null) return;
|
||||
try {
|
||||
final line = jsonEncode(event);
|
||||
File(_cacheFilePath!).writeAsStringSync(
|
||||
'$line\n',
|
||||
mode: FileMode.append,
|
||||
flush: true,
|
||||
);
|
||||
} catch (e) {
|
||||
Logger.analyticsSdk('关键事件同步缓存写入失败: $e', level: LogLevel.warn);
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<Map<String, dynamic>>> loadEvents({
|
||||
required Queue<Map<String, dynamic>> queue,
|
||||
required int maxQueueSize,
|
||||
required int maxCacheLines,
|
||||
required bool Function(String) isEventTypeEnabled,
|
||||
}) async {
|
||||
if (_cacheFilePath == null) return const [];
|
||||
final file = File(_cacheFilePath!);
|
||||
if (!await file.exists()) return const [];
|
||||
|
||||
final recovered = <Map<String, dynamic>>[];
|
||||
try {
|
||||
await _cacheLock.synchronized(() async {
|
||||
if (await file.length() > SdkConfig.maxCacheBytes) {
|
||||
await _trimCacheFileIfNeeded(file);
|
||||
}
|
||||
|
||||
final tombstonedIds = await _loadTombstoneIds();
|
||||
final inFlightIds = await _loadInFlightIds();
|
||||
// 请求开始不代表服务端已经确认。进程在请求途中被终止时,in-flight
|
||||
// 事件必须随缓存一起恢复,交由服务端按 event_id 去重,不能在客户端
|
||||
// 为了避免重复而丢失会话汇总等终止事件。
|
||||
if (inFlightIds.isNotEmpty) {
|
||||
Logger.analyticsSdk(
|
||||
'检测到 ${inFlightIds.length} 条未确认 in-flight 事件,将恢复重试');
|
||||
}
|
||||
final lines = await file.readAsLines();
|
||||
final linesToProcess = lines.length > maxCacheLines
|
||||
? lines.sublist(lines.length - maxCacheLines)
|
||||
: lines;
|
||||
|
||||
for (final line in linesToProcess) {
|
||||
if (line.trim().isEmpty) continue;
|
||||
try {
|
||||
final decoded = jsonDecode(line);
|
||||
if (decoded is Map<String, dynamic>) {
|
||||
final eventId = decoded['event_id'];
|
||||
if (eventId != null && tombstonedIds.contains(eventId)) {
|
||||
continue;
|
||||
}
|
||||
final eventType = decoded['event'] as String?;
|
||||
if (eventType != null && !isEventTypeEnabled(eventType)) {
|
||||
Logger.analyticsSdk('缓存恢复:事件类型 $eventType 未启用,跳过');
|
||||
continue;
|
||||
}
|
||||
recovered.add(decoded);
|
||||
}
|
||||
} catch (_) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// 缓存和 tombstone 都不能在“恢复到内存队列”时删除。恢复后的
|
||||
// 事件尚未得到本次请求的 200 确认,进程再次终止时仍需能从磁盘
|
||||
// 重试;只有 removeEvents() 在 200 后写入 tombstone。
|
||||
if (_inFlightFilePath != null) {
|
||||
final inFlight = File(_inFlightFilePath!);
|
||||
if (await inFlight.exists()) await inFlight.delete();
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
Logger.analyticsSdk('缓存加载失败: $e');
|
||||
try {
|
||||
await file.delete();
|
||||
} catch (_) {}
|
||||
}
|
||||
return recovered;
|
||||
}
|
||||
|
||||
Future<void> removeEvents(List<Map<String, dynamic>> reportedEvents) async {
|
||||
if (_tombstoneFilePath == null || reportedEvents.isEmpty) return;
|
||||
|
||||
final ids = reportedEvents
|
||||
.map((e) => e['event_id'])
|
||||
.whereType<String>()
|
||||
.where((id) => id.isNotEmpty && !id.contains('\n'))
|
||||
.toList();
|
||||
|
||||
if (ids.isEmpty) return;
|
||||
|
||||
try {
|
||||
await _cacheLock.synchronized(() async {
|
||||
try {
|
||||
final tombstone = File(_tombstoneFilePath!);
|
||||
await tombstone.writeAsString(
|
||||
'${ids.join('\n')}\n',
|
||||
mode: FileMode.append,
|
||||
);
|
||||
Logger.analyticsSdk('tombstone 追加 ${ids.length} 条 event_id');
|
||||
|
||||
final tombstoneContent = await tombstone.readAsString();
|
||||
final tombstoneCount =
|
||||
tombstoneContent.split('\n').where((l) => l.isNotEmpty).length;
|
||||
if (tombstoneCount >= SdkConfig.tombstoneCompactionThreshold) {
|
||||
await _compactCache(tombstoneContent);
|
||||
}
|
||||
} catch (e) {
|
||||
Logger.analyticsSdk('tombstone 写入失败: $e');
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
Logger.analyticsSdk('缓存移除操作异常: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> clearCache() async {
|
||||
if (_cacheFilePath == null) return;
|
||||
try {
|
||||
await _cacheLock.synchronized(() async {
|
||||
try {
|
||||
final file = File(_cacheFilePath!);
|
||||
if (await file.exists()) await file.delete();
|
||||
if (_tombstoneFilePath != null) {
|
||||
final tombstone = File(_tombstoneFilePath!);
|
||||
if (await tombstone.exists()) await tombstone.delete();
|
||||
}
|
||||
} catch (e) {
|
||||
Logger.analyticsSdk('缓存清除失败: $e');
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
Logger.analyticsSdk('缓存清除操作异常: $e');
|
||||
}
|
||||
}
|
||||
|
||||
void flushBuffer() {
|
||||
if (_cacheFilePath == null || _cacheBuffer.isEmpty) return;
|
||||
_cacheFlushTimer?.cancel();
|
||||
_cacheFlushTimer = null;
|
||||
final batch = List<Map<String, dynamic>>.from(_cacheBuffer);
|
||||
_cacheBuffer.clear();
|
||||
Future.microtask(() async {
|
||||
await _saveBatchToCache(batch);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> flushBufferAsync() async {
|
||||
if (_cacheFilePath == null || _cacheBuffer.isEmpty) return;
|
||||
_cacheFlushTimer?.cancel();
|
||||
_cacheFlushTimer = null;
|
||||
final batch = List<Map<String, dynamic>>.from(_cacheBuffer);
|
||||
_cacheBuffer.clear();
|
||||
await _saveBatchToCache(batch);
|
||||
}
|
||||
|
||||
/// 发送前记录 batch 中的 event_id,便于诊断未确认请求。
|
||||
///
|
||||
/// 崩溃重启后这些事件会与缓存一起恢复并重试,采用 at-least-once
|
||||
/// 语义;服务端应按 event_id 去重。
|
||||
Future<void> markInFlight(List<Map<String, dynamic>> batch) async {
|
||||
if (_inFlightFilePath == null) return;
|
||||
final ids = batch
|
||||
.map((e) => e['event_id'])
|
||||
.whereType<String>()
|
||||
.where((id) => id.isNotEmpty && !id.contains('\n'))
|
||||
.toList();
|
||||
if (ids.isEmpty) return;
|
||||
try {
|
||||
await File(_inFlightFilePath!).writeAsString('${ids.join('\n')}\n');
|
||||
} catch (e) {
|
||||
Logger.analyticsSdk('in-flight 标记写入失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// 清除 in-flight 文件(上报完成或明确失败后调用,幂等)。
|
||||
Future<void> clearInFlight() async {
|
||||
if (_inFlightFilePath == null) return;
|
||||
try {
|
||||
final file = File(_inFlightFilePath!);
|
||||
if (await file.exists()) await file.delete();
|
||||
} catch (e) {
|
||||
Logger.analyticsSdk('in-flight 文件清除失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 私有方法 ====================
|
||||
|
||||
Future<void> _trimCacheFileIfNeeded(File file) async {
|
||||
try {
|
||||
final len = await file.length();
|
||||
if (len <= SdkConfig.maxCacheBytes) return;
|
||||
final lines = await file.readAsLines();
|
||||
int total = 0;
|
||||
int startIndex = 0;
|
||||
for (int i = lines.length - 1; i >= 0; i--) {
|
||||
total += utf8.encode(lines[i]).length + 1;
|
||||
if (total > SdkConfig.maxCacheBytes) {
|
||||
startIndex = i + 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (startIndex <= 0) return;
|
||||
final kept = lines.sublist(startIndex);
|
||||
await file.writeAsString(kept.isEmpty ? '' : '${kept.join('\n')}\n');
|
||||
Logger.analyticsSdk(
|
||||
'缓存文件超过 ${SdkConfig.maxCacheBytes} 字节,已裁剪掉前 $startIndex 条');
|
||||
} catch (e) {
|
||||
Logger.analyticsSdk('缓存文件裁剪失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _saveBatchToCache(List<Map<String, dynamic>> batch) async {
|
||||
if (_cacheFilePath == null || batch.isEmpty) return;
|
||||
try {
|
||||
await _cacheLock.synchronized(() async {
|
||||
try {
|
||||
final file = File(_cacheFilePath!);
|
||||
final lines = batch
|
||||
.map((item) {
|
||||
try {
|
||||
return jsonEncode(item);
|
||||
} catch (e) {
|
||||
Logger.analyticsSdk('单个事件编码失败,跳过: $e');
|
||||
return null;
|
||||
}
|
||||
})
|
||||
.where((line) => line != null)
|
||||
.join('\n');
|
||||
if (lines.isNotEmpty) {
|
||||
await file.writeAsString('$lines\n', mode: FileMode.append);
|
||||
await _trimCacheFileIfNeeded(file);
|
||||
}
|
||||
} catch (e) {
|
||||
Logger.analyticsSdk('批量缓存写入失败: $e');
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
Logger.analyticsSdk('缓存保存异常: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<Set<String>> _loadTombstoneIds() async {
|
||||
if (_tombstoneFilePath == null) return {};
|
||||
try {
|
||||
final tombstone = File(_tombstoneFilePath!);
|
||||
if (!await tombstone.exists()) return {};
|
||||
final content = await tombstone.readAsString();
|
||||
return content.split('\n').where((l) => l.isNotEmpty).toSet();
|
||||
} catch (e) {
|
||||
Logger.analyticsSdk('tombstone 读取失败: $e');
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
Future<Set<String>> _loadInFlightIds() async {
|
||||
if (_inFlightFilePath == null) return {};
|
||||
try {
|
||||
final file = File(_inFlightFilePath!);
|
||||
if (!await file.exists()) return {};
|
||||
final content = await file.readAsString();
|
||||
return content.split('\n').where((l) => l.isNotEmpty).toSet();
|
||||
} catch (e) {
|
||||
Logger.analyticsSdk('in-flight 文件读取失败: $e');
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _compactCache(String tombstoneContent) async {
|
||||
if (_cacheFilePath == null || _tombstoneFilePath == null) return;
|
||||
try {
|
||||
final tombstonedIds =
|
||||
tombstoneContent.split('\n').where((l) => l.isNotEmpty).toSet();
|
||||
|
||||
final cacheFile = File(_cacheFilePath!);
|
||||
if (!await cacheFile.exists()) {
|
||||
await File(_tombstoneFilePath!).delete();
|
||||
return;
|
||||
}
|
||||
|
||||
final lines = await cacheFile.readAsLines();
|
||||
final kept = <String>[];
|
||||
int removed = 0;
|
||||
|
||||
for (final line in lines) {
|
||||
if (line.trim().isEmpty) continue;
|
||||
try {
|
||||
final decoded = jsonDecode(line);
|
||||
if (decoded is Map<String, dynamic>) {
|
||||
final id = decoded['event_id'];
|
||||
if (id != null && tombstonedIds.contains(id)) {
|
||||
removed++;
|
||||
} else {
|
||||
kept.add(line);
|
||||
}
|
||||
} else {
|
||||
kept.add(line);
|
||||
}
|
||||
} catch (_) {
|
||||
kept.add(line);
|
||||
}
|
||||
}
|
||||
|
||||
if (kept.isEmpty) {
|
||||
await cacheFile.delete();
|
||||
} else {
|
||||
await cacheFile.writeAsString('${kept.join('\n')}\n');
|
||||
}
|
||||
try {
|
||||
await File(_tombstoneFilePath!).delete();
|
||||
} catch (e) {
|
||||
Logger.analyticsSdk('tombstone 删除失败(可忽略,下次压缩会重试): $e');
|
||||
}
|
||||
|
||||
Logger.analyticsSdk('缓存压缩完成:删除 $removed 条,保留 ${kept.length} 条');
|
||||
} catch (e) {
|
||||
Logger.analyticsSdk('缓存压缩失败: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import 'dart:collection';
|
||||
|
||||
/// Web 平台事件持久化:内存 only,所有持久化操作为空实现。
|
||||
/// Web 会话通常短暂,不需要跨页面持久化事件。
|
||||
class EventPersistenceImpl {
|
||||
Future<void> init() async {}
|
||||
|
||||
String? get debugReportEventLogFilePath => null;
|
||||
|
||||
void appendDebugReportEvent(Map<String, dynamic> event) {}
|
||||
|
||||
void bufferEvent(Map<String, dynamic> event) {}
|
||||
|
||||
void persistCriticalEvent(Map<String, dynamic> event) {}
|
||||
|
||||
Future<List<Map<String, dynamic>>> loadEvents({
|
||||
required Queue<Map<String, dynamic>> queue,
|
||||
required int maxQueueSize,
|
||||
required int maxCacheLines,
|
||||
required bool Function(String) isEventTypeEnabled,
|
||||
}) async =>
|
||||
const [];
|
||||
|
||||
Future<void> removeEvents(List<Map<String, dynamic>> events) async {}
|
||||
|
||||
Future<void> markInFlight(List<Map<String, dynamic>> events) async {}
|
||||
|
||||
Future<void> clearInFlight() async {}
|
||||
|
||||
Future<void> clearCache() async {}
|
||||
|
||||
void flushBuffer() {}
|
||||
|
||||
Future<void> flushBufferAsync() async {}
|
||||
}
|
||||
@@ -0,0 +1,552 @@
|
||||
import 'dart:async';
|
||||
import 'dart:collection';
|
||||
import 'dart:convert';
|
||||
import 'package:analytics_sdk/manager/event_persistence.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:analytics_sdk/config/sdk_config.dart';
|
||||
import 'package:analytics_sdk/manager/event_type_config_manager.dart';
|
||||
import 'package:analytics_sdk/manager/session_behavior_manager.dart';
|
||||
import 'package:analytics_sdk/manager/session_manager.dart';
|
||||
import 'package:analytics_sdk/manager/session_metrics_manager.dart';
|
||||
import 'package:analytics_sdk/manager/session_sensor_sampler.dart';
|
||||
import 'package:analytics_sdk/manager/sdk_context.dart';
|
||||
import 'package:analytics_sdk/utils/logger.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:synchronized/synchronized.dart';
|
||||
|
||||
/// 事件处理与上报管理器
|
||||
///
|
||||
/// 负责:
|
||||
/// - 维护事件队列
|
||||
/// - 本地持久化(缓存文件)
|
||||
/// - 批量上报 & 指数退避重试
|
||||
/// - 定时上报定时器
|
||||
///
|
||||
/// 对外由 `AnalyticsSdk` 持有并调用,不直接暴露给业务方。
|
||||
class EventProcessor {
|
||||
String? _reportUrl;
|
||||
final Queue<Map<String, dynamic>> _eventQueue = Queue<Map<String, dynamic>>();
|
||||
|
||||
Timer? _uploadTimer;
|
||||
|
||||
final EventPersistenceImpl _persistence = EventPersistenceImpl();
|
||||
|
||||
int _backoffLevel = 0;
|
||||
Timer? _retryTimer;
|
||||
|
||||
/// JSON 过大时折半缩小批次;成功后重置为 null(恢复用 maxBatchSize)
|
||||
int? _overrideBatchSize;
|
||||
|
||||
http.Client? _httpClient;
|
||||
|
||||
http.Client get _client => _httpClient ??= http.Client();
|
||||
|
||||
/// 上报锁,防止并发上报
|
||||
bool _isUploading = false;
|
||||
final Lock _uploadLock = Lock();
|
||||
|
||||
/// event_id 防抖:记录最近见到该 id 的时间,窗口内重复则丢弃
|
||||
final Map<String, DateTime> _recentEventIds = {};
|
||||
|
||||
/// 防抖窗口:1000ms 秒级时间戳粒度 + 100ms 时钟抖动容差。
|
||||
/// event_id 的哈希因子包含秒级 client_ts,同秒内同事件会产生同一 id;
|
||||
/// 额外 100ms 用于覆盖调用链路上的微小时间偏移,避免临界点误判。
|
||||
static const _dedupWindow = Duration(milliseconds: 1100);
|
||||
|
||||
/// 测试包用:最近一次上报结果
|
||||
bool? lastUploadSuccess;
|
||||
String? lastUploadError;
|
||||
DateTime? lastUploadTime;
|
||||
|
||||
/// 是否已配置可用的上报 URL
|
||||
bool get hasValidReportUrl =>
|
||||
_reportUrl != null && _reportUrl!.isNotEmpty == true;
|
||||
|
||||
/// 测试包用:队列中待上报事件数
|
||||
int get queueLength => _eventQueue.length;
|
||||
|
||||
/// 测试包用:当前上报地址(脱敏)
|
||||
String? get debugReportUrl => _reportUrl;
|
||||
|
||||
/// 测试包用:完整事件 JSONL 调试文件路径(仅 Native debug 包)
|
||||
String? get debugReportEventLogPath =>
|
||||
kDebugMode && SdkConfig.enableDebugReportLog
|
||||
? _persistence.debugReportEventLogFilePath
|
||||
: null;
|
||||
|
||||
/// 更新上报 URL(域名管理器回调时调用)
|
||||
void updateReportUrl(String? reportUrl) {
|
||||
_reportUrl = reportUrl;
|
||||
}
|
||||
|
||||
/// 启动定时上报定时器
|
||||
void startAutoUploadTimer() {
|
||||
_uploadTimer?.cancel();
|
||||
_uploadTimer = Timer.periodic(SdkConfig.uploadInterval, (_) {
|
||||
try {
|
||||
uploadBatch().catchError((e) {
|
||||
Logger.analyticsSdk('定时上报 Future 异常: $e');
|
||||
});
|
||||
} catch (e) {
|
||||
Logger.analyticsSdk('定时上报异常: $e');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// 停止定时上报定时器
|
||||
void stopAutoUploadTimer() {
|
||||
_uploadTimer?.cancel();
|
||||
_uploadTimer = null;
|
||||
}
|
||||
|
||||
/// 重置退避和上报状态(SDK 重新初始化时调用)
|
||||
void resetState() {
|
||||
_backoffLevel = 0;
|
||||
_overrideBatchSize = null;
|
||||
_retryTimer?.cancel();
|
||||
_retryTimer = null;
|
||||
lastUploadSuccess = null;
|
||||
lastUploadError = null;
|
||||
lastUploadTime = null;
|
||||
_isUploading = false;
|
||||
_recentEventIds.clear();
|
||||
}
|
||||
|
||||
/// 事件入队:
|
||||
/// - 负责队列容量检查
|
||||
/// - 触发必要的上报
|
||||
/// - 写入本地缓存
|
||||
///
|
||||
/// 返回值:
|
||||
/// - `true`:事件成功进入队列
|
||||
/// - `false`:因队列已满等原因被丢弃
|
||||
bool enqueueEvent(Map<String, dynamic> json) {
|
||||
// 队列容量检查,防止内存无限增长
|
||||
if (_eventQueue.length >= SdkConfig.maxQueueSize) {
|
||||
Logger.analyticsSdk('警告:事件队列已满(${_eventQueue.length}),尝试立即上报');
|
||||
// 尝试立即上报以释放队列空间(不 await)
|
||||
try {
|
||||
uploadBatch().catchError((e) {
|
||||
Logger.analyticsSdk('立即上报 Future 异常: $e');
|
||||
});
|
||||
} catch (e) {
|
||||
Logger.analyticsSdk('立即上报异常: $e');
|
||||
}
|
||||
// 队列已满时直接丢弃新事件
|
||||
return false;
|
||||
}
|
||||
|
||||
// event_id 防抖:1100ms 窗口内同一 id 只保留第一条
|
||||
final eventId = json['event_id'] as String?;
|
||||
if (eventId != null) {
|
||||
final now = DateTime.now();
|
||||
_recentEventIds.removeWhere((_, t) => now.difference(t) > _dedupWindow);
|
||||
final lastSeen = _recentEventIds[eventId];
|
||||
if (lastSeen != null && now.difference(lastSeen) <= _dedupWindow) {
|
||||
Logger.analyticsSdk('event_id 防抖,已丢弃: $eventId');
|
||||
return false;
|
||||
}
|
||||
_recentEventIds[eventId] = now;
|
||||
}
|
||||
|
||||
_eventQueue.add(json);
|
||||
SessionManager.instance.recordActivityForEvent(json);
|
||||
SessionMetricsManager.instance.recordAcceptedEvent(json);
|
||||
SessionBehaviorManager.instance.recordAcceptedEvent(json);
|
||||
SessionSensorSampler.instance.recordAcceptedEvent(json);
|
||||
if (json['event'] == 'app_session_summary') {
|
||||
_persistence.persistCriticalEvent(json);
|
||||
Logger.analyticsSdk(
|
||||
'app_session_summary 已同步写入本地缓存: ${json['event_id'] ?? ''}');
|
||||
} else {
|
||||
_persistence.bufferEvent(json);
|
||||
}
|
||||
_writeDebugReportEvent(json);
|
||||
_logAcceptedEvent(json);
|
||||
_flushIfNeeded();
|
||||
return true;
|
||||
}
|
||||
|
||||
/// 当队列长度达到阈值时触发上报
|
||||
void _flushIfNeeded() {
|
||||
if (_eventQueue.length >= SdkConfig.autoUploadThreshold) {
|
||||
uploadBatch().catchError((e) {
|
||||
Logger.analyticsSdk('阈值触发上报 Future 异常: $e');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// 强制立即上报
|
||||
/// 该方法永远不会抛出异常
|
||||
Future<void> flush() async {
|
||||
try {
|
||||
await uploadBatch();
|
||||
} catch (e) {
|
||||
Logger.analyticsSdk('flush() 发生异常,已安全处理: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// 批量上报队列中的事件
|
||||
Future<void> uploadBatch() async {
|
||||
if (_eventQueue.isEmpty || _reportUrl == null) return;
|
||||
|
||||
return _uploadLock.synchronized(() async {
|
||||
if (_eventQueue.isEmpty || _reportUrl == null || _isUploading) return;
|
||||
|
||||
_isUploading = true;
|
||||
// 限制单次上报的批次大小,避免请求过大导致超时或内存问题
|
||||
// _overrideBatchSize 在 JSON 过大时折半缩小,上报成功后重置
|
||||
final maxBatch = _overrideBatchSize ?? SdkConfig.maxBatchSize;
|
||||
final batchSize =
|
||||
_eventQueue.length > maxBatch ? maxBatch : _eventQueue.length;
|
||||
|
||||
// 安全检查:确保批次大小有效
|
||||
if (batchSize <= 0) {
|
||||
Logger.analyticsSdk('批次大小无效: $batchSize');
|
||||
_isUploading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// 取出批次但不从队列移除,上报成功后再移除(at-least-once 语义)
|
||||
final batch = List<Map<String, dynamic>>.from(
|
||||
_eventQueue.take(batchSize),
|
||||
);
|
||||
|
||||
try {
|
||||
// 安全检查:确保批次不为空
|
||||
if (batch.isEmpty) {
|
||||
Logger.analyticsSdk('批次为空,跳过上报');
|
||||
return;
|
||||
}
|
||||
|
||||
final requestBatch = batch
|
||||
.map(_ensureEventSignatureBeforeUpload)
|
||||
.toList(growable: false);
|
||||
|
||||
// 在后台线程执行 JSON 编码,避免阻塞主线程
|
||||
final batchJson = await _encodeBatchAsync(requestBatch);
|
||||
|
||||
// 检查编码结果
|
||||
if (batchJson == '[]' || batchJson.isEmpty) {
|
||||
Logger.analyticsSdk('JSON 编码失败或结果为空');
|
||||
_scheduleRetry();
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查 JSON 大小,避免请求过大
|
||||
final jsonSize = utf8.encode(batchJson).length;
|
||||
if (jsonSize > SdkConfig.maxJsonSize) {
|
||||
Logger.analyticsSdk(
|
||||
'警告:JSON 大小过大 (${(jsonSize / 1024 / 1024).toStringAsFixed(2)}MB),缩小批次重试');
|
||||
if (batchSize > 1) {
|
||||
// 折半批次后重试,避免同一批次反复触发 JSON 过大
|
||||
_overrideBatchSize = (batchSize / 2).ceil();
|
||||
_isUploading = false;
|
||||
Future.microtask(() {
|
||||
uploadBatch().catchError((e) {
|
||||
Logger.analyticsSdk('缩小批次后重试上报 Future 异常: $e');
|
||||
});
|
||||
});
|
||||
} else {
|
||||
// 单条事件过大,移除该事件避免阻塞队列
|
||||
if (_eventQueue.isNotEmpty) {
|
||||
_eventQueue.removeFirst();
|
||||
}
|
||||
Logger.analyticsSdk(
|
||||
'警告:单条事件过大 (${(jsonSize / 1024).toStringAsFixed(2)}KB),已丢弃');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// 解析 URL,添加错误处理
|
||||
Uri uri;
|
||||
try {
|
||||
uri = Uri.parse(_reportUrl!);
|
||||
if (!uri.hasScheme || (!uri.scheme.startsWith('http'))) {
|
||||
throw const FormatException('无效的 URL scheme');
|
||||
}
|
||||
} catch (e) {
|
||||
Logger.analyticsSdk('URL 解析失败: $_reportUrl, 错误: $e');
|
||||
_scheduleRetry();
|
||||
return;
|
||||
}
|
||||
|
||||
// 终止窗口内也要保证批次已落盘:否则请求已开始但进程被杀时,
|
||||
// in-flight 文件虽存在,主缓存却没有这批事件可供重试。
|
||||
// 服务端通过 event_id 去重,客户端采用 at-least-once 投递。
|
||||
await _persistence.flushBufferAsync();
|
||||
await _persistence.markInFlight(requestBatch);
|
||||
|
||||
Logger.analyticsSdk(
|
||||
'上报中:${requestBatch.length} 条事件,JSON大小: ${(jsonSize / 1024).toStringAsFixed(2)}KB(已落盘)');
|
||||
|
||||
// 复用 http.Client,利用连接池
|
||||
final response = await _client
|
||||
.post(
|
||||
uri,
|
||||
headers: {
|
||||
'Content-Type': 'application/json; charset=utf-8',
|
||||
},
|
||||
body: batchJson,
|
||||
)
|
||||
.timeout(SdkConfig.totalRequestTimeout);
|
||||
final statusCode = response.statusCode;
|
||||
if (statusCode == 200) {
|
||||
// 上报成功,按 event_id 从队列中移除已上报的事件
|
||||
final reportedIds =
|
||||
batch.map((e) => e['event_id']).whereType<String>().toSet();
|
||||
if (reportedIds.isNotEmpty) {
|
||||
_eventQueue
|
||||
.removeWhere((e) => reportedIds.contains(e['event_id']));
|
||||
} else {
|
||||
// event_id 全缺失时回退到按数量移除(兜底)
|
||||
for (int i = 0; i < batchSize && _eventQueue.isNotEmpty; i++) {
|
||||
_eventQueue.removeFirst();
|
||||
}
|
||||
}
|
||||
lastUploadSuccess = true;
|
||||
lastUploadError = null;
|
||||
lastUploadTime = DateTime.now();
|
||||
_overrideBatchSize = null; // 上报成功,恢复正常批次大小
|
||||
_resetBackoff();
|
||||
await _persistence.removeEvents(requestBatch);
|
||||
final confirmedEvents = requestBatch
|
||||
.map((event) =>
|
||||
'${event['event'] ?? 'unknown'}(${event['event_id'] ?? 'missing_event_id'})')
|
||||
.join(', ');
|
||||
Logger.analyticsSdk(
|
||||
'上报成功: ${requestBatch.length} 条事件,HTTP $statusCode 批次确认,'
|
||||
'已写入 tombstone;确认事件: [$confirmedEvents]');
|
||||
} else {
|
||||
lastUploadSuccess = false;
|
||||
lastUploadError = 'HTTP $statusCode';
|
||||
lastUploadTime = DateTime.now();
|
||||
_scheduleRetry();
|
||||
if (statusCode >= 400 && statusCode < 500) {
|
||||
Logger.analyticsSdk('上报失败: 客户端错误,状态码: $statusCode(可能是数据格式问题)');
|
||||
} else if (statusCode >= 500) {
|
||||
Logger.analyticsSdk('上报失败: 服务器错误,状态码: $statusCode(服务器问题,将重试)');
|
||||
} else {
|
||||
Logger.analyticsSdk('上报失败: 未知状态码 $statusCode');
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
lastUploadSuccess = false;
|
||||
lastUploadError = e is TimeoutException
|
||||
? '请求超时'
|
||||
: e is http.ClientException
|
||||
? '网络连接失败'
|
||||
: e.toString().length > 30
|
||||
? '${e.toString().substring(0, 30)}...'
|
||||
: e.toString();
|
||||
lastUploadTime = DateTime.now();
|
||||
_scheduleRetry();
|
||||
if (e is TimeoutException) {
|
||||
Logger.analyticsSdk('上报异常: 请求超时 - ${e.message ?? "未知超时"}',
|
||||
level: LogLevel.error);
|
||||
} else if (e is http.ClientException) {
|
||||
Logger.analyticsSdk('上报异常: 网络连接失败 - ${e.message}',
|
||||
level: LogLevel.error);
|
||||
} else {
|
||||
final errorMessage = e.toString();
|
||||
Logger.analyticsSdk('上报异常: $errorMessage', level: LogLevel.error);
|
||||
}
|
||||
} finally {
|
||||
// 无论成功、失败还是异常,确保 in-flight 文件被清除(幂等)
|
||||
try {
|
||||
await _persistence.clearInFlight();
|
||||
} catch (_) {}
|
||||
}
|
||||
} finally {
|
||||
_isUploading = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Map<String, dynamic> _ensureEventSignatureBeforeUpload(
|
||||
Map<String, dynamic> event,
|
||||
) {
|
||||
final eventSig = event['event_sig'];
|
||||
if (eventSig is String && eventSig.trim().isNotEmpty) {
|
||||
return event;
|
||||
}
|
||||
|
||||
final signed = SdkContext.attachDeviceEnvIfNeeded(event);
|
||||
final signedEventSig = signed['event_sig'];
|
||||
if (signedEventSig is String && signedEventSig.trim().isNotEmpty) {
|
||||
return signed;
|
||||
}
|
||||
return event;
|
||||
}
|
||||
|
||||
void _logAcceptedEvent(Map<String, dynamic> json) {
|
||||
if (!SdkConfig.enableDebugReportLog) return;
|
||||
try {
|
||||
Logger.analyticsSdkDeveloperLog(
|
||||
'accepted event json: ${jsonEncode(json)}',
|
||||
);
|
||||
} catch (e) {
|
||||
Logger.analyticsSdk('事件日志打印失败: $e', level: LogLevel.warn);
|
||||
}
|
||||
}
|
||||
|
||||
void _writeDebugReportEvent(Map<String, dynamic> json) {
|
||||
if (!kDebugMode || !SdkConfig.enableDebugReportLog) return;
|
||||
try {
|
||||
_persistence.appendDebugReportEvent(json);
|
||||
} catch (e) {
|
||||
Logger.analyticsSdk('debug 完整事件 JSONL 记录失败: $e', level: LogLevel.warn);
|
||||
}
|
||||
}
|
||||
|
||||
/// JSON 编码的静态方法,用于 compute isolate
|
||||
static String _encodeBatchJson(List<Map<String, dynamic>> batch) {
|
||||
return jsonEncode(batch);
|
||||
}
|
||||
|
||||
/// 在后台 isolate 中异步执行 JSON 编码,避免阻塞主线程
|
||||
Future<String> _encodeBatchAsync(List<Map<String, dynamic>> batch) async {
|
||||
try {
|
||||
if (batch.isEmpty) {
|
||||
return '[]';
|
||||
}
|
||||
if (batch.length <= SdkConfig.isolateEncodingThreshold) {
|
||||
return jsonEncode(batch);
|
||||
}
|
||||
try {
|
||||
return await compute(_encodeBatchJson, batch);
|
||||
} catch (e) {
|
||||
Logger.analyticsSdk('compute 失败,回退到主线程编码: $e');
|
||||
return jsonEncode(batch);
|
||||
}
|
||||
} catch (e) {
|
||||
Logger.analyticsSdk('JSON 编码失败: $e');
|
||||
return '[]';
|
||||
}
|
||||
}
|
||||
|
||||
/// 安排指数退避重试(事件仍在队列中,无需重新入队)
|
||||
void _scheduleRetry() {
|
||||
_backoffLevel = (_backoffLevel + 1).clamp(0, SdkConfig.maxBackoffLevel);
|
||||
_retryTimer?.cancel();
|
||||
final delay = _nextDelay();
|
||||
_retryTimer = Timer(delay, () {
|
||||
try {
|
||||
Logger.analyticsSdk('指数退避重试 (延迟 ${delay.inSeconds}s)');
|
||||
uploadBatch();
|
||||
} catch (e) {
|
||||
Logger.analyticsSdk('重试上报异常: $e');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// 计算下次延迟时间(指数退避 + 上限)
|
||||
Duration _nextDelay() {
|
||||
final exponential = SdkConfig.baseRetryDelay * pow(2, _backoffLevel);
|
||||
return exponential > SdkConfig.maxRetryDelay
|
||||
? SdkConfig.maxRetryDelay
|
||||
: exponential;
|
||||
}
|
||||
|
||||
/// 上报成功时重置退避状态
|
||||
void _resetBackoff() {
|
||||
if (_backoffLevel > 0) {
|
||||
_backoffLevel = 0;
|
||||
_retryTimer?.cancel();
|
||||
_retryTimer = null;
|
||||
Logger.analyticsSdk('上报成功,退避机制已重置');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> initPersistence() async {
|
||||
try {
|
||||
await _persistence.init();
|
||||
final debugPath = debugReportEventLogPath;
|
||||
if (debugPath != null && debugPath.isNotEmpty) {
|
||||
Logger.analyticsSdk('debug 完整事件 JSONL 文件: $debugPath');
|
||||
}
|
||||
} catch (e) {
|
||||
Logger.analyticsSdk('持久化初始化失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> loadCachedEvents() async {
|
||||
try {
|
||||
final recovered = await _persistence.loadEvents(
|
||||
queue: _eventQueue,
|
||||
maxQueueSize: SdkConfig.maxQueueSize,
|
||||
maxCacheLines: SdkConfig.maxCacheLines,
|
||||
isEventTypeEnabled: EventTypeConfigManager.instance.isEventTypeEnabled,
|
||||
);
|
||||
|
||||
if (recovered.isNotEmpty) {
|
||||
final availableSpace = SdkConfig.maxQueueSize - _eventQueue.length;
|
||||
if (availableSpace > 0) {
|
||||
final eventsToRecover = recovered.length > availableSpace
|
||||
? recovered.sublist(0, availableSpace)
|
||||
: recovered;
|
||||
_eventQueue.addAll(eventsToRecover);
|
||||
Logger.analyticsSdk('从缓存恢复 ${eventsToRecover.length} 条事件');
|
||||
} else {
|
||||
Logger.analyticsSdk('警告:队列已满,无法恢复缓存事件');
|
||||
}
|
||||
}
|
||||
|
||||
_flushIfNeeded();
|
||||
} catch (e) {
|
||||
Logger.analyticsSdk('缓存加载失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> clearCache() async {
|
||||
await _persistence.clearCache();
|
||||
}
|
||||
|
||||
/// 释放资源(定时器、HttpClient、缓存 buffer 等)
|
||||
void dispose() {
|
||||
try {
|
||||
stopAutoUploadTimer();
|
||||
_retryTimer?.cancel();
|
||||
_retryTimer = null;
|
||||
_isUploading = false;
|
||||
_persistence.flushBuffer();
|
||||
_httpClient?.close();
|
||||
_httpClient = null;
|
||||
} catch (e) {
|
||||
Logger.analyticsSdk('EventProcessor.dispose 异常: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// 测试专用入口:触发 tombstone 写入以供单元测试验证缓存移除行为
|
||||
@visibleForTesting
|
||||
Future<void> removeEventsFromCacheForTest(
|
||||
List<Map<String, dynamic>> events) =>
|
||||
_persistence.removeEvents(events);
|
||||
|
||||
/// 测试专用入口:注入 HTTP client 以验证最终请求体。
|
||||
@visibleForTesting
|
||||
void setHttpClientForTest(http.Client client) {
|
||||
_httpClient?.close();
|
||||
_httpClient = client;
|
||||
}
|
||||
|
||||
/// 异步释放资源(先 await 缓存刷写,再释放其他资源)
|
||||
///
|
||||
/// 推荐在 SDK dispose 时使用此方法替代 [dispose],确保缓存 buffer 写入磁盘。
|
||||
Future<void> disposeAsync() async {
|
||||
try {
|
||||
stopAutoUploadTimer();
|
||||
_retryTimer?.cancel();
|
||||
_retryTimer = null;
|
||||
_isUploading = false;
|
||||
await _persistence.flushBufferAsync();
|
||||
_httpClient?.close();
|
||||
_httpClient = null;
|
||||
} catch (e) {
|
||||
Logger.analyticsSdk('EventProcessor.disposeAsync 异常: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
import 'dart:convert';
|
||||
import 'package:analytics_sdk/config/sdk_config.dart';
|
||||
import 'package:analytics_sdk/utils/logger.dart';
|
||||
import 'package:analytics_sdk/utils/platform_storage.dart';
|
||||
|
||||
/// 事件类型配置管理器:解析并缓存业务方传入的事件类型配置
|
||||
class EventTypeConfigManager {
|
||||
static final EventTypeConfigManager instance =
|
||||
EventTypeConfigManager._internal();
|
||||
|
||||
factory EventTypeConfigManager() => instance;
|
||||
|
||||
EventTypeConfigManager._internal();
|
||||
|
||||
Set<String> _enabledEventTypes = {};
|
||||
|
||||
/// 获取已启用的事件类型集合
|
||||
Set<String> get enabledEventTypes => Set.unmodifiable(_enabledEventTypes);
|
||||
|
||||
/// 检查某个事件类型是否被启用
|
||||
/// 如果配置为空,默认允许所有事件上报(向后兼容)
|
||||
bool isEventTypeEnabled(String eventType) {
|
||||
if (_enabledEventTypes.isEmpty) return true;
|
||||
return _enabledEventTypes.contains(eventType);
|
||||
}
|
||||
|
||||
/// 传入已解密的事件类型配置 JSON 字符串,解析后写入本地缓存
|
||||
///
|
||||
/// 支持格式:
|
||||
/// - 字符串数组:`["app_page_view", "ad_impression", ...]`
|
||||
/// - JSON 对象:`{"enabled_event_types": [...]}`
|
||||
///
|
||||
/// 返回解析到的事件类型数量
|
||||
Future<int> initWithConfig(String decryptedJson) async {
|
||||
try {
|
||||
final eventTypes = _parseEventTypes(decryptedJson);
|
||||
if (eventTypes.isNotEmpty) {
|
||||
_enabledEventTypes = eventTypes;
|
||||
await _saveConfigToCache(eventTypes);
|
||||
Logger.eventTypeConfigManager(
|
||||
'事件类型配置已更新,共 ${eventTypes.length} 个: ${eventTypes.join(", ")}');
|
||||
} else {
|
||||
Logger.eventTypeConfigManager('解析结果为空,保持原有配置');
|
||||
}
|
||||
return eventTypes.length;
|
||||
} catch (e) {
|
||||
Logger.eventTypeConfigManager('initWithConfig 异常: $e');
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/// 从本地缓存加载配置(若无缓存则保持 allow-all 状态)
|
||||
Future<void> loadCachedConfig() async {
|
||||
try {
|
||||
await _loadCachedConfig();
|
||||
} catch (e) {
|
||||
Logger.eventTypeConfigManager('加载缓存配置异常: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// 清理资源
|
||||
void dispose() {
|
||||
_enabledEventTypes.clear();
|
||||
}
|
||||
|
||||
/// 解析 JSON 字符串,提取事件类型列表(供外部验证用,如 validateEncryptedConfig)
|
||||
///
|
||||
/// 返回 List<String>,解析失败或结果为空则返回空列表,不抛出异常。
|
||||
static List<String> parseForValidation(String json) {
|
||||
try {
|
||||
dynamic decoded = jsonDecode(json);
|
||||
if (decoded is String && decoded.trim().isNotEmpty) {
|
||||
try {
|
||||
decoded = jsonDecode(decoded);
|
||||
} catch (_) {}
|
||||
}
|
||||
if (decoded is List) {
|
||||
return (decoded as List)
|
||||
.map<String>((e) => e.toString().trim())
|
||||
.where((e) => e.isNotEmpty)
|
||||
.toList();
|
||||
} else if (decoded is Map<String, dynamic>) {
|
||||
final list = decoded['enabled_event_types'] as List? ??
|
||||
decoded['event_types'] as List? ??
|
||||
decoded['data'] as List?;
|
||||
if (list != null) {
|
||||
return list
|
||||
.map<String>((e) => e.toString().trim())
|
||||
.where((e) => e.isNotEmpty)
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
return [];
|
||||
}
|
||||
|
||||
// ==================== 私有方法 ====================
|
||||
|
||||
/// 解析 JSON 字符串,提取事件类型列表
|
||||
Set<String> _parseEventTypes(String json) {
|
||||
try {
|
||||
dynamic decoded = jsonDecode(json);
|
||||
// 兼容外层为字符串的格式:"[\"event_type\", ...]"
|
||||
if (decoded is String && decoded.trim().isNotEmpty) {
|
||||
try {
|
||||
decoded = jsonDecode(decoded);
|
||||
} catch (_) {}
|
||||
}
|
||||
if (decoded is List) {
|
||||
return (decoded as List)
|
||||
.map<String>((e) => e.toString().trim())
|
||||
.where((e) => e.isNotEmpty)
|
||||
.toSet();
|
||||
} else if (decoded is Map<String, dynamic>) {
|
||||
final list = decoded['enabled_event_types'] as List? ??
|
||||
decoded['event_types'] as List? ??
|
||||
decoded['data'] as List?;
|
||||
if (list != null) {
|
||||
return list
|
||||
.map<String>((e) => e.toString().trim())
|
||||
.where((e) => e.isNotEmpty)
|
||||
.toSet();
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
Logger.eventTypeConfigManager('事件类型 JSON 解析失败: $e');
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
Future<void> _saveConfigToCache(Set<String> eventTypes) async {
|
||||
try {
|
||||
await PlatformStorage.write(
|
||||
SdkConfig.eventTypeConfigCacheFileName,
|
||||
jsonEncode({
|
||||
'enabled_event_types': eventTypes.toList(),
|
||||
'updated_at': DateTime.now().toIso8601String(),
|
||||
}),
|
||||
);
|
||||
Logger.eventTypeConfigManager('配置已保存到缓存');
|
||||
} catch (e) {
|
||||
Logger.eventTypeConfigManager('保存缓存失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadCachedConfig() async {
|
||||
try {
|
||||
final content =
|
||||
await PlatformStorage.read(SdkConfig.eventTypeConfigCacheFileName);
|
||||
if (content == null) {
|
||||
Logger.eventTypeConfigManager('缓存文件不存在');
|
||||
return;
|
||||
}
|
||||
final json = jsonDecode(content) as Map<String, dynamic>;
|
||||
final list = json['enabled_event_types'];
|
||||
if (list is List) {
|
||||
_enabledEventTypes = (list as List)
|
||||
.map<String>((e) => e.toString().trim())
|
||||
.where((e) => e.isNotEmpty)
|
||||
.toSet();
|
||||
Logger.eventTypeConfigManager(
|
||||
'从缓存加载 ${_enabledEventTypes.length} 个事件类型');
|
||||
}
|
||||
} catch (e) {
|
||||
Logger.eventTypeConfigManager('加载缓存失败: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
Future<Map<String, dynamic>> collectNetworkStateFields() async {
|
||||
return const {};
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
// ignore: avoid_web_libraries_in_flutter
|
||||
import 'dart:html' as html;
|
||||
|
||||
import 'package:analytics_sdk/utils/js_interop_compat.dart';
|
||||
|
||||
Future<Map<String, dynamic>> collectNetworkStateFields() async {
|
||||
final connection = _connection(html.window.navigator);
|
||||
if (connection == null) return const {};
|
||||
final fields = <String, dynamic>{};
|
||||
fields['connection_type'] = _string(connection, 'type', 'unknown');
|
||||
fields['effective_type'] = _effectiveType(connection);
|
||||
fields['downlink'] = _downlinkKbps(connection);
|
||||
fields['rtt'] = _number(connection, 'rtt', -1).round();
|
||||
fields.removeWhere((key, value) {
|
||||
if (value is String) return value.trim().isEmpty;
|
||||
if (value is num) return value < 0;
|
||||
return value == null;
|
||||
});
|
||||
return fields;
|
||||
}
|
||||
|
||||
Object? _connection(html.Navigator navigator) {
|
||||
for (final key in ['connection', 'mozConnection', 'webkitConnection']) {
|
||||
try {
|
||||
if (hasJsProperty(navigator, key)) {
|
||||
return getJsProperty(navigator, key);
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
String _string(Object? object, String key, String fallback) {
|
||||
if (object == null) return fallback;
|
||||
try {
|
||||
final value = getJsProperty(object, key);
|
||||
final string = value?.toString().trim() ?? '';
|
||||
return string.isEmpty ? fallback : string;
|
||||
} catch (_) {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
num _number(Object? object, String key, num fallback) {
|
||||
if (object == null) return fallback;
|
||||
try {
|
||||
final value = getJsProperty(object, key);
|
||||
if (value is num && value.isFinite) return value;
|
||||
return num.tryParse(value?.toString() ?? '') ?? fallback;
|
||||
} catch (_) {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
String _effectiveType(Object? connection) {
|
||||
final value = _string(connection, 'effectiveType', 'unknown').toLowerCase();
|
||||
if (value == 'slow-2g') return '2g';
|
||||
const allowed = {'2g', '3g', '4g', '5g'};
|
||||
return allowed.contains(value) ? value : 'unknown';
|
||||
}
|
||||
|
||||
int _downlinkKbps(Object? connection) {
|
||||
final downlink = _number(connection, 'downlink', -1);
|
||||
return downlink >= 0 ? (downlink * 1000).round() : -1;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export 'page_click_auto_fields_stub.dart'
|
||||
if (dart.library.html) 'page_click_auto_fields_web.dart';
|
||||
@@ -0,0 +1,33 @@
|
||||
class ClickDocumentMetrics {
|
||||
final int clickDocumentX;
|
||||
final int clickDocumentY;
|
||||
final int pageWidth;
|
||||
final int pageHeight;
|
||||
final int scrollTop;
|
||||
final int scrollLeft;
|
||||
|
||||
const ClickDocumentMetrics({
|
||||
required this.clickDocumentX,
|
||||
required this.clickDocumentY,
|
||||
required this.pageWidth,
|
||||
required this.pageHeight,
|
||||
required this.scrollTop,
|
||||
required this.scrollLeft,
|
||||
});
|
||||
|
||||
static const unknown = ClickDocumentMetrics(
|
||||
clickDocumentX: -1,
|
||||
clickDocumentY: -1,
|
||||
pageWidth: -1,
|
||||
pageHeight: -1,
|
||||
scrollTop: -1,
|
||||
scrollLeft: -1,
|
||||
);
|
||||
}
|
||||
|
||||
ClickDocumentMetrics currentClickDocumentMetrics({
|
||||
required int clickViewportX,
|
||||
required int clickViewportY,
|
||||
}) {
|
||||
return ClickDocumentMetrics.unknown;
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
// ignore: avoid_web_libraries_in_flutter
|
||||
import 'dart:html' as html;
|
||||
|
||||
class ClickDocumentMetrics {
|
||||
final int clickDocumentX;
|
||||
final int clickDocumentY;
|
||||
final int pageWidth;
|
||||
final int pageHeight;
|
||||
final int scrollTop;
|
||||
final int scrollLeft;
|
||||
|
||||
const ClickDocumentMetrics({
|
||||
required this.clickDocumentX,
|
||||
required this.clickDocumentY,
|
||||
required this.pageWidth,
|
||||
required this.pageHeight,
|
||||
required this.scrollTop,
|
||||
required this.scrollLeft,
|
||||
});
|
||||
|
||||
static const unknown = ClickDocumentMetrics(
|
||||
clickDocumentX: -1,
|
||||
clickDocumentY: -1,
|
||||
pageWidth: -1,
|
||||
pageHeight: -1,
|
||||
scrollTop: -1,
|
||||
scrollLeft: -1,
|
||||
);
|
||||
}
|
||||
|
||||
ClickDocumentMetrics currentClickDocumentMetrics({
|
||||
required int clickViewportX,
|
||||
required int clickViewportY,
|
||||
}) {
|
||||
try {
|
||||
final documentElement = html.document.documentElement;
|
||||
final body = html.document.body;
|
||||
final pageWidth = _maxPositive([
|
||||
documentElement?.scrollWidth,
|
||||
body?.scrollWidth,
|
||||
documentElement?.clientWidth,
|
||||
body?.clientWidth,
|
||||
]);
|
||||
final pageHeight = _maxPositive([
|
||||
documentElement?.scrollHeight,
|
||||
body?.scrollHeight,
|
||||
documentElement?.clientHeight,
|
||||
body?.clientHeight,
|
||||
]);
|
||||
if (pageWidth <= 0 || pageHeight <= 0) {
|
||||
return ClickDocumentMetrics.unknown;
|
||||
}
|
||||
|
||||
final scrollLeft = _nonNegative(html.window.pageXOffset.round());
|
||||
final scrollTop = _nonNegative(html.window.pageYOffset.round());
|
||||
return ClickDocumentMetrics(
|
||||
clickDocumentX: _nonNegative(clickViewportX + scrollLeft),
|
||||
clickDocumentY: _nonNegative(clickViewportY + scrollTop),
|
||||
pageWidth: pageWidth,
|
||||
pageHeight: pageHeight,
|
||||
scrollTop: scrollTop,
|
||||
scrollLeft: scrollLeft,
|
||||
);
|
||||
} catch (_) {
|
||||
return ClickDocumentMetrics.unknown;
|
||||
}
|
||||
}
|
||||
|
||||
int _maxPositive(List<int?> values) {
|
||||
var max = -1;
|
||||
for (final value in values) {
|
||||
if (value != null && value > max) max = value;
|
||||
}
|
||||
return max > 0 ? max : -1;
|
||||
}
|
||||
|
||||
int _nonNegative(int value) => value < 0 ? 0 : value;
|
||||
@@ -0,0 +1,177 @@
|
||||
// lib/utils/page_name_mapper.dart
|
||||
|
||||
import 'package:analytics_sdk/utils/logger.dart';
|
||||
|
||||
/// 统一的页面 pageKey → pageName 映射管理器
|
||||
/// 支持静态默认映射 + 动态添加/覆盖(业务方可在初始化时自定义)
|
||||
///
|
||||
/// ## 两种注册格式
|
||||
///
|
||||
/// **老格式(精确匹配)**:值为字符串,pageKey 与路由名称完全一致时命中
|
||||
/// ```dart
|
||||
/// PageNameMapper.addMappings({'home': '首页'});
|
||||
/// ```
|
||||
///
|
||||
/// **新格式(支持 `:param` 动态路由)**:值为 `{'key': ..., 'name': ...}`
|
||||
/// - `key`:上报时使用的稳定 pageKey(替换掉含真实 ID 的原始路由名)
|
||||
/// - `name`:页面展示名称
|
||||
/// ```dart
|
||||
/// PageNameMapper.addMappings({
|
||||
/// '/video/:id': {'key': 'video_detail', 'name': '视频详情'},
|
||||
/// });
|
||||
/// ```
|
||||
class PageNameMapper {
|
||||
// 默认映射表(SDK 内置,精确匹配)
|
||||
static const Map<String, String> _defaultMap = {};
|
||||
|
||||
// 老格式:精确匹配映射表(路由名 → pageName)
|
||||
static final Map<String, String> _customMap = {};
|
||||
|
||||
// 新格式::param 模式映射表(pattern → ResolvedPageInfo)
|
||||
static final Map<String, ResolvedPageInfo> _patternMap = {};
|
||||
|
||||
// 新格式 resolvedKey 缓存(resolvedKey → pageName),与 _customMap 隔离
|
||||
// 供 getPageName 在 currentPageKey 已被解析为稳定 key 时反查 pageName
|
||||
static final Map<String, String> _resolvedKeyMap = {};
|
||||
|
||||
static const String _tag = 'PageNameMapper';
|
||||
|
||||
/// 去除 pageKey 前导斜杠,结果为空时保留原值(如纯 "/" 保持为 "/")
|
||||
static String normalizeKey(String pageKey) {
|
||||
if (!pageKey.startsWith('/')) return pageKey;
|
||||
final stripped = pageKey.substring(1);
|
||||
return stripped.isEmpty ? pageKey : stripped;
|
||||
}
|
||||
|
||||
/// 获取页面名称(老格式精确匹配,行为与之前完全一致)
|
||||
///
|
||||
/// 同时查 _resolvedKeyMap,确保新格式解析后的稳定 key 也能反查到 pageName
|
||||
static String getPageName(String pageKey) {
|
||||
final key = normalizeKey(pageKey);
|
||||
return _customMap[key] ?? _resolvedKeyMap[key] ?? _defaultMap[key] ?? key;
|
||||
}
|
||||
|
||||
/// 解析路由名称,返回上报用的稳定 [ResolvedPageInfo]。
|
||||
///
|
||||
/// 查找优先级:
|
||||
/// 1. 老格式精确匹配(_customMap / _defaultMap)
|
||||
/// 2. 新格式精确匹配(_patternMap)
|
||||
/// 3. 新格式 `:param` 通配匹配(_patternMap)
|
||||
/// 4. Fallback:key = name = 归一化后的路由名
|
||||
static ResolvedPageInfo resolvePageInfo(String routeName) {
|
||||
final normalized = normalizeKey(routeName);
|
||||
|
||||
// 1. 老格式精确匹配(只查 _customMap / _defaultMap,不查 _resolvedKeyMap)
|
||||
final directName = _customMap[normalized] ?? _defaultMap[normalized];
|
||||
if (directName != null) return ResolvedPageInfo(normalized, directName);
|
||||
|
||||
// 2. 新格式精确匹配
|
||||
final exactPattern = _patternMap[normalized];
|
||||
if (exactPattern != null) return exactPattern;
|
||||
|
||||
// 3. 新格式 :param 通配匹配
|
||||
for (final entry in _patternMap.entries) {
|
||||
if (_matchesPattern(entry.key, normalized)) {
|
||||
return entry.value;
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Fallback
|
||||
return ResolvedPageInfo(normalized, normalized);
|
||||
}
|
||||
|
||||
/// 动态添加或覆盖单个映射
|
||||
///
|
||||
/// [value] 可为字符串(老格式)或 `{'key': String, 'name': String}`(新格式)
|
||||
static void addMapping(String routePattern, dynamic value) {
|
||||
final normalized = normalizeKey(routePattern);
|
||||
if (value is String) {
|
||||
_customMap[normalized] = value;
|
||||
} else if (value is Map) {
|
||||
// ── 风险 1 修复:严格校验 key / name 字段,避免运行时 crash ──
|
||||
final resolvedKey = value['key'];
|
||||
final name = value['name'];
|
||||
if (resolvedKey is! String || resolvedKey.isEmpty) {
|
||||
Logger.warn(_tag, 'addMapping("$routePattern") 的 key 字段无效或为空,已忽略');
|
||||
return;
|
||||
}
|
||||
if (name is! String || name.isEmpty) {
|
||||
Logger.warn(_tag, 'addMapping("$routePattern") 的 name 字段无效或为空,已忽略');
|
||||
return;
|
||||
}
|
||||
|
||||
// ── 风险 3 修复:检测与已注册 pattern 的歧义冲突 ──
|
||||
if (_hasConflictingPattern(normalized)) {
|
||||
Logger.warn(
|
||||
_tag,
|
||||
'addMapping("$routePattern") 与已注册的某个 pattern 存在歧义,'
|
||||
'将按注册顺序匹配,请检查是否重复注册',
|
||||
);
|
||||
}
|
||||
|
||||
_patternMap[normalized] = ResolvedPageInfo(resolvedKey, name);
|
||||
// ── 风险 2 修复:写入独立的 _resolvedKeyMap,不污染 _customMap ──
|
||||
_resolvedKeyMap[resolvedKey] = name;
|
||||
}
|
||||
}
|
||||
|
||||
/// 批量动态添加或覆盖映射,兼容老格式与新格式混传
|
||||
static void addMappings(Map<String, dynamic> mappings) {
|
||||
for (final entry in mappings.entries) {
|
||||
addMapping(entry.key, entry.value);
|
||||
}
|
||||
}
|
||||
|
||||
/// 清空自定义映射(测试或重置用)
|
||||
static void clearCustom() {
|
||||
_customMap.clear();
|
||||
_patternMap.clear();
|
||||
_resolvedKeyMap.clear();
|
||||
}
|
||||
|
||||
/// 判断路由名称是否匹配某个 :param 模式
|
||||
static bool _matchesPattern(String pattern, String routeName) {
|
||||
final patternParts = pattern.split('/');
|
||||
final routeParts = routeName.split('/');
|
||||
if (patternParts.length != routeParts.length) return false;
|
||||
for (int i = 0; i < patternParts.length; i++) {
|
||||
if (patternParts[i].startsWith(':')) continue; // 通配符,跳过
|
||||
if (patternParts[i] != routeParts[i]) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/// 检测新 pattern 是否与已注册的 pattern 存在歧义(可能命中同一路由)
|
||||
static bool _hasConflictingPattern(String newPattern) {
|
||||
for (final existing in _patternMap.keys) {
|
||||
if (existing == newPattern) continue; // 相同 pattern,属于覆盖,不是冲突
|
||||
if (_patternsConflict(existing, newPattern)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// 判断两个 pattern 是否可能匹配同一条路由
|
||||
static bool _patternsConflict(String p1, String p2) {
|
||||
final parts1 = p1.split('/');
|
||||
final parts2 = p2.split('/');
|
||||
if (parts1.length != parts2.length) return false;
|
||||
for (int i = 0; i < parts1.length; i++) {
|
||||
final isParam1 = parts1[i].startsWith(':');
|
||||
final isParam2 = parts2[i].startsWith(':');
|
||||
// 两段都是固定字面量且不相等 → 不可能同时匹配
|
||||
if (!isParam1 && !isParam2 && parts1[i] != parts2[i]) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/// [PageNameMapper.resolvePageInfo] 的返回值:路由解析后的稳定标识
|
||||
class ResolvedPageInfo {
|
||||
/// 上报用的稳定 pageKey(新格式下为业务方自定义 key,老格式下为归一化路由名)
|
||||
final String key;
|
||||
|
||||
/// 页面展示名称
|
||||
final String name;
|
||||
|
||||
const ResolvedPageInfo(this.key, this.name);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import 'package:analytics_sdk/utils/logger.dart';
|
||||
|
||||
/// 校验页面生命周期 Observer 的接入状态,并避免重复刷屏。
|
||||
class PageObserverRegistrationGuard {
|
||||
bool _missingWarningLogged = false;
|
||||
bool _duplicateWarningLogged = false;
|
||||
|
||||
void reset() {
|
||||
_missingWarningLogged = false;
|
||||
_duplicateWarningLogged = false;
|
||||
}
|
||||
|
||||
void check({
|
||||
required bool pageObserverAttached,
|
||||
required bool sessionObserverAttached,
|
||||
}) {
|
||||
if (!pageObserverAttached && !sessionObserverAttached) {
|
||||
if (_missingWarningLogged) return;
|
||||
_missingWarningLogged = true;
|
||||
Logger.pageLifecycleObserver(
|
||||
'未检测到页面 Observer:请在 MaterialApp.navigatorObservers 中注册 '
|
||||
'AnalyticsSdk.instance.pageObserver 或 '
|
||||
'AnalyticsSdk.instance.sessionObserver,二选一。否则 '
|
||||
'app_session_summary 的 page_stay_list 可能不完整。',
|
||||
level: LogLevel.warn,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (pageObserverAttached && sessionObserverAttached) {
|
||||
if (_duplicateWarningLogged) return;
|
||||
_duplicateWarningLogged = true;
|
||||
Logger.pageLifecycleObserver(
|
||||
'检测到 pageObserver 与 sessionObserver 同时注册:两者必须二选一。'
|
||||
'SDK 已对页面停留和自动 app_page_view 做幂等保护,'
|
||||
'但请尽快修正接入配置。',
|
||||
level: LogLevel.warn,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export 'page_view_auto_fields_stub.dart'
|
||||
if (dart.library.html) 'page_view_auto_fields_web.dart';
|
||||
@@ -0,0 +1 @@
|
||||
String currentPageUrl() => '';
|
||||
@@ -0,0 +1,27 @@
|
||||
// ignore: avoid_web_libraries_in_flutter
|
||||
import 'dart:html' as html;
|
||||
|
||||
String currentPageUrl() {
|
||||
try {
|
||||
final uri = Uri.tryParse(html.window.location.href);
|
||||
if (uri == null) return '';
|
||||
final buffer = StringBuffer();
|
||||
if (uri.scheme.isNotEmpty) {
|
||||
buffer
|
||||
..write(uri.scheme)
|
||||
..write('://');
|
||||
}
|
||||
if (uri.host.isNotEmpty) {
|
||||
buffer.write(uri.host);
|
||||
}
|
||||
if (uri.hasPort) {
|
||||
buffer.write(':${uri.port}');
|
||||
}
|
||||
if (uri.path.isNotEmpty) {
|
||||
buffer.write(uri.path);
|
||||
}
|
||||
return buffer.toString();
|
||||
} catch (_) {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import 'package:analytics_sdk/utils/platform_storage.dart';
|
||||
|
||||
class SdkActivationStore {
|
||||
SdkActivationStore._();
|
||||
|
||||
static const String _storageKey = 'analytics_sdk_activation_time.txt';
|
||||
|
||||
static Future<int> getOrCreateActivationTime() async {
|
||||
try {
|
||||
final cached = await PlatformStorage.read(_storageKey);
|
||||
final cachedTs = int.tryParse(cached?.trim() ?? '');
|
||||
if (cachedTs != null && cachedTs > 0) return cachedTs;
|
||||
|
||||
final now = DateTime.now().millisecondsSinceEpoch ~/ 1000;
|
||||
await PlatformStorage.write(_storageKey, now.toString());
|
||||
return now;
|
||||
} catch (_) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,439 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:analytics_sdk/utils/device_fingerprint_util.dart';
|
||||
import 'package:analytics_sdk/utils/logger.dart';
|
||||
import 'package:crypto/crypto.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
/// SDK 全局上下文:持有所有公共字段、提供 event_id 计算。
|
||||
///
|
||||
/// 作为 SDK 内部的单一状态来源(SSOT),所有事件构造时从此处读取公共字段。
|
||||
/// 不对外暴露,接入方无需直接使用。
|
||||
class SdkContext {
|
||||
/// SDK 版本号(写死,与 pubspec.yaml 保持一致)
|
||||
static const String kSdkVersion = '1.4.6';
|
||||
|
||||
/// appVersion 公共字段默认值。
|
||||
/// 接入方未传 `appVersion`、或 release 模式下传入非法值时均使用该值,
|
||||
/// 保证事件 payload 与 `AnalyticsSdk.getParams()` 返回值保持一致。
|
||||
static const String kDefaultAppVersion = '1.0.0';
|
||||
|
||||
/// 客户端事件签名规则版本。
|
||||
static const String kEventSignatureVersion = '2.0.0';
|
||||
|
||||
static String _eventSignatureHmacKey = '';
|
||||
static String _eventSignatureVersion = kEventSignatureVersion;
|
||||
|
||||
static String? _appId;
|
||||
static String? _channel;
|
||||
static String? _uid;
|
||||
static String? _appVersion;
|
||||
static String? _device;
|
||||
static String? _deviceId;
|
||||
static String? _deviceBrand;
|
||||
static String? _deviceModel;
|
||||
static String? _userAgent;
|
||||
static String? _systemName;
|
||||
static String? _systemVersion;
|
||||
static String? _deviceFingerprint;
|
||||
static String _fingerprintVersion = DeviceFingerprintUtil.kVersion;
|
||||
static Map<String, dynamic> _environmentFields = _defaultEnvironmentFields();
|
||||
|
||||
/// SID 提供者(由 AnalyticsSdk.init() 注入)。
|
||||
/// 调用时同时返回当前 SID 并记录用户行为,避免 entity 层直接依赖 SessionManager。
|
||||
static String Function()? sidProvider;
|
||||
|
||||
/// 配置公共字段
|
||||
///
|
||||
/// 所有参数均为可选,非 null 值才会覆盖现有值。
|
||||
/// 通常在 [AnalyticsSdk.init] 中调用,也可在 init 之后按需更新。
|
||||
/// deviceFingerprint / fingerprintVersion 不再支持从外部传入。
|
||||
static void configure({
|
||||
String? appId,
|
||||
String? channel,
|
||||
String? uid,
|
||||
String? appVersion,
|
||||
String? device,
|
||||
String? deviceId,
|
||||
String? deviceBrand,
|
||||
String? deviceModel,
|
||||
String? userAgent,
|
||||
String? systemName,
|
||||
String? systemVersion,
|
||||
String? deviceFingerprint,
|
||||
String? fingerprintVersion,
|
||||
Map<String, dynamic>? environmentFields,
|
||||
}) {
|
||||
if (appId != null) _appId = appId;
|
||||
if (channel != null) _channel = channel;
|
||||
if (uid != null) _uid = uid;
|
||||
if (appVersion != null) {
|
||||
if (RegExp(r'^\d+\.\d+\.\d+$').hasMatch(appVersion)) {
|
||||
_appVersion = appVersion;
|
||||
} else if (kDebugMode) {
|
||||
Logger.analyticsSdk('appVersion 格式不正确,应为 x.y.z,当前值:$appVersion,已忽略',
|
||||
level: LogLevel.error);
|
||||
} else {
|
||||
_appVersion = kDefaultAppVersion;
|
||||
}
|
||||
}
|
||||
if (device != null) _device = device;
|
||||
if (deviceId != null) _deviceId = deviceId;
|
||||
if (deviceBrand != null) _deviceBrand = deviceBrand;
|
||||
if (deviceModel != null) _deviceModel = deviceModel;
|
||||
if (userAgent != null) _userAgent = userAgent;
|
||||
if (systemName != null) _systemName = systemName;
|
||||
if (systemVersion != null) _systemVersion = systemVersion;
|
||||
if (environmentFields != null) {
|
||||
_environmentFields = {
|
||||
..._defaultEnvironmentFields(),
|
||||
..._normalizeEnvironmentFields(environmentFields),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// SDK 内部调用:重置自动生成的设备指纹状态。
|
||||
static void resetGeneratedDeviceFingerprint() {
|
||||
_deviceFingerprint = null;
|
||||
_fingerprintVersion = DeviceFingerprintUtil.kVersion;
|
||||
}
|
||||
|
||||
/// SDK 内部调用:写入已生成的设备指纹。
|
||||
static void configureGeneratedDeviceFingerprint(String deviceFingerprint) {
|
||||
final normalized = deviceFingerprint.trim();
|
||||
_deviceFingerprint = normalized.isEmpty ? null : normalized;
|
||||
_fingerprintVersion = DeviceFingerprintUtil.kVersion;
|
||||
}
|
||||
|
||||
/// 更新用户 ID(登录或切换账号时调用)
|
||||
static void setUid(String newUid) {
|
||||
_uid = newUid;
|
||||
}
|
||||
|
||||
/// 更新渠道(渠道变更时调用)
|
||||
static void setChannel(String channel) {
|
||||
_channel = channel;
|
||||
}
|
||||
|
||||
/// 增量更新系统、网络、硬件和浏览器环境快照字段。
|
||||
///
|
||||
/// 用于网络、电量、充电状态等运行期可能变化的字段刷新。
|
||||
static void updateEnvironmentFields(Map<String, dynamic> fields) {
|
||||
if (fields.isEmpty) return;
|
||||
_environmentFields = {
|
||||
..._environmentFields,
|
||||
..._normalizeEnvironmentFields(fields),
|
||||
};
|
||||
}
|
||||
|
||||
/// 配置客户端事件签名密钥。
|
||||
///
|
||||
/// 密钥来自加密配置解密后的 `signKey`,不写入事件、payload、日志或缓存。
|
||||
static void configureEventSignature({
|
||||
String? signKey,
|
||||
String? version,
|
||||
}) {
|
||||
if (signKey != null) {
|
||||
_eventSignatureHmacKey = signKey.trim();
|
||||
}
|
||||
final normalizedVersion = version?.trim();
|
||||
if (normalizedVersion != null && normalizedVersion.isNotEmpty) {
|
||||
_eventSignatureVersion = normalizedVersion;
|
||||
}
|
||||
}
|
||||
|
||||
/// 重置所有公共字段为 null(SDK 重新初始化时使用)
|
||||
///
|
||||
/// 与 [configure] 不同,此方法会将所有字段清为 null,
|
||||
/// 而 configure 中传 null 表示"不修改"。
|
||||
static void reset() {
|
||||
_appId = null;
|
||||
_channel = null;
|
||||
_uid = null;
|
||||
_appVersion = null;
|
||||
_device = null;
|
||||
_deviceId = null;
|
||||
_deviceBrand = null;
|
||||
_deviceModel = null;
|
||||
_userAgent = null;
|
||||
_systemName = null;
|
||||
_systemVersion = null;
|
||||
_deviceFingerprint = null;
|
||||
_fingerprintVersion = DeviceFingerprintUtil.kVersion;
|
||||
_environmentFields = _defaultEnvironmentFields();
|
||||
_eventSignatureHmacKey = '';
|
||||
_eventSignatureVersion = kEventSignatureVersion;
|
||||
sidProvider = null;
|
||||
}
|
||||
|
||||
// ──────── Getters(调试 / 外部复用) ────────────────────────────────────
|
||||
|
||||
static String? get appId => _appId;
|
||||
static String? get channel => _channel;
|
||||
static String? get uid => _uid;
|
||||
static String? get appVersion => _appVersion;
|
||||
static String? get deviceId => _deviceId;
|
||||
static String? get deviceBrand => _deviceBrand;
|
||||
static String? get deviceModel => _deviceModel;
|
||||
static String get deviceFingerprint => _deviceFingerprint ?? '';
|
||||
static String get fingerprintVersion => _fingerprintVersion;
|
||||
static Map<String, dynamic> get environmentFields =>
|
||||
Map<String, dynamic>.from(_environmentFields);
|
||||
|
||||
/// 获取设备公共字段(供产品方在服务端上报事件时复用)
|
||||
///
|
||||
/// 返回的 Map 包含:sid、device、device_id、device_brand、device_model、
|
||||
/// user_agent、system_name、system_version、device_fingerprint、fp_version。
|
||||
/// 未配置的字段返回空字符串。
|
||||
static Map<String, String> getDeviceCommonFields() {
|
||||
String sid = '';
|
||||
try {
|
||||
sid = sidProvider?.call() ?? '';
|
||||
} catch (_) {
|
||||
// 未初始化或 SID 获取异常时保持为空,确保该读取 API 不抛异常。
|
||||
}
|
||||
|
||||
return {
|
||||
'sid': sid,
|
||||
'device': _device ?? '',
|
||||
'device_id': _deviceId ?? '',
|
||||
'device_brand': _deviceBrand ?? '',
|
||||
'device_model': _deviceModel ?? '',
|
||||
'user_agent': _userAgent ?? '',
|
||||
'system_name': _systemName ?? '',
|
||||
'system_version': _systemVersion ?? '',
|
||||
'device_fingerprint': _deviceFingerprint ?? '',
|
||||
'fp_version': _fingerprintVersion,
|
||||
};
|
||||
}
|
||||
|
||||
/// 获取系统、网络、硬件和浏览器环境快照字段。
|
||||
///
|
||||
/// 仅供 SDK 内部构建 `sdk_init` 启动快照和少量汇总事件使用,普通事件公共字段
|
||||
/// 不再重复携带这些环境快照。
|
||||
static Map<String, dynamic> getEnvironmentSnapshotFields() {
|
||||
return Map<String, dynamic>.from(_environmentFields);
|
||||
}
|
||||
|
||||
/// 给 SDK 客户端事件补充外层签名字段。
|
||||
///
|
||||
/// 历史版本曾把轻量环境签名写入 payload;这里会清理这些保留字段,
|
||||
/// 避免同一事件同时出现新旧两套签名。
|
||||
static Map<String, dynamic> attachDeviceEnvIfNeeded(
|
||||
Map<String, dynamic> event,
|
||||
) {
|
||||
final eventName = event['event']?.toString() ?? '';
|
||||
final isServerOnlyEvent = _serverOnlyEvents.contains(eventName);
|
||||
final result = Map<String, dynamic>.from(event);
|
||||
final existingPayload = result['payload'];
|
||||
final existingDeviceEnv = result.remove('device_env');
|
||||
|
||||
if (existingPayload is Map || existingPayload == null) {
|
||||
final payload = <String, dynamic>{
|
||||
if (existingPayload is Map)
|
||||
...Map<String, dynamic>.from(existingPayload),
|
||||
if (existingDeviceEnv is Map)
|
||||
...Map<String, dynamic>.from(existingDeviceEnv),
|
||||
};
|
||||
payload.removeWhere(
|
||||
(key, _) => _reservedPayloadSignatureFields.contains(key));
|
||||
result['payload'] = payload;
|
||||
}
|
||||
|
||||
if (!isServerOnlyEvent && _eventSignatureHmacKey.isNotEmpty) {
|
||||
final eventForSignature = Map<String, dynamic>.from(result);
|
||||
result['event_sig_version'] = _eventSignatureVersion;
|
||||
result['event_sig'] = _eventSignature(eventForSignature);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/// 生成公共字段 Map
|
||||
///
|
||||
/// 包含所有客户端事件上报所需的公共字段:
|
||||
/// event、channel、app_id、uid、sid、client_ts、
|
||||
/// device、device_id、device_brand、device_model、
|
||||
/// user_agent、system_name、system_version、
|
||||
/// device_fingerprint、fp_version、sdk_version、app_version。
|
||||
///
|
||||
/// 本方法永远不会抛出异常。
|
||||
static Map<String, dynamic> generateCommonFields(String event) {
|
||||
final now = DateTime.now();
|
||||
final clientTs = now.millisecondsSinceEpoch ~/ 1000;
|
||||
|
||||
String sid;
|
||||
try {
|
||||
sid = sidProvider?.call() ?? 'fallback_${now.millisecondsSinceEpoch}';
|
||||
} catch (_) {
|
||||
sid = 'fallback_${now.millisecondsSinceEpoch}';
|
||||
}
|
||||
|
||||
return {
|
||||
'event': event,
|
||||
'channel': _channel ?? '',
|
||||
'app_id': _appId ?? '',
|
||||
'uid': _uid ?? '',
|
||||
'sid': sid,
|
||||
'client_ts': clientTs,
|
||||
'device': _device ?? '',
|
||||
'device_id': _deviceId ?? '',
|
||||
'device_brand': _deviceBrand ?? '',
|
||||
'device_model': _deviceModel ?? '',
|
||||
'user_agent': _userAgent ?? '',
|
||||
'system_name': _systemName ?? '',
|
||||
'system_version': _systemVersion ?? '',
|
||||
'device_fingerprint': _deviceFingerprint ?? '',
|
||||
'fp_version': _fingerprintVersion,
|
||||
'sdk_version': kSdkVersion,
|
||||
'app_version': _appVersion ?? kDefaultAppVersion,
|
||||
};
|
||||
}
|
||||
|
||||
/// 根据字段列表生成 event_id
|
||||
///
|
||||
/// 计算规则:
|
||||
/// 1. 对每个字段值单独做 MD5
|
||||
/// 2. 将所有 MD5 用 `|` 拼接
|
||||
/// 3. 对拼接结果再做一次 MD5,作为最终 event_id
|
||||
///
|
||||
/// **注意**:`device_fingerprint` 不参与 event_id 计算。
|
||||
/// 本方法永远不会抛出异常,失败时返回基于时间戳的备选 ID。
|
||||
static String generateEventId(List<String> fields) {
|
||||
try {
|
||||
final fieldDigests = <String>[];
|
||||
for (final value in fields) {
|
||||
final v = value;
|
||||
final bytes = utf8.encode(v);
|
||||
final digest = md5.convert(bytes);
|
||||
fieldDigests.add(digest.toString());
|
||||
}
|
||||
|
||||
final joined = fieldDigests.join('|');
|
||||
final finalDigest = md5.convert(utf8.encode(joined));
|
||||
return finalDigest.toString();
|
||||
} catch (e) {
|
||||
try {
|
||||
final content = fields.join('|');
|
||||
return 'h${(content.hashCode & 0x7FFFFFFF).toRadixString(16)}';
|
||||
} catch (_) {
|
||||
return 't${DateTime.now().millisecondsSinceEpoch}';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static Map<String, dynamic> _defaultEnvironmentFields() => {
|
||||
'language': '',
|
||||
'timezone': '',
|
||||
'package_name': '',
|
||||
'screen_width': -1,
|
||||
'screen_height': -1,
|
||||
'screen_dpi': -1,
|
||||
'device_pixel_ratio': -1,
|
||||
'orientation': '',
|
||||
'screen_brightness': -1,
|
||||
'volume': -1,
|
||||
'is_screen_on': -1,
|
||||
'network_type': 'unknown',
|
||||
'battery_level': -1,
|
||||
'is_charging': -1,
|
||||
'battery_state': 'unknown',
|
||||
'cpu_cores': -1,
|
||||
'device_memory_gb': -1,
|
||||
'browser_name': '',
|
||||
'browser_version': '',
|
||||
'cookie_enabled': -1,
|
||||
'local_storage_enabled': -1,
|
||||
'touch_support': -1,
|
||||
'max_touch_points': -1,
|
||||
'color_depth': -1,
|
||||
'is_pwa': -1,
|
||||
};
|
||||
|
||||
static Map<String, dynamic> _normalizeEnvironmentFields(
|
||||
Map<String, dynamic> fields,
|
||||
) {
|
||||
final normalized = Map<String, dynamic>.from(fields);
|
||||
if (normalized.containsKey('device_pixel_ratio')) {
|
||||
normalized['device_pixel_ratio'] =
|
||||
_normalizeDevicePixelRatio(normalized['device_pixel_ratio']);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
static int _normalizeDevicePixelRatio(dynamic value) {
|
||||
if (value == null) return -1;
|
||||
final ratio =
|
||||
value is num ? value.toDouble() : double.tryParse(value.toString());
|
||||
if (ratio == null || !ratio.isFinite) return -1;
|
||||
if (ratio > 10) return ratio.round();
|
||||
return (ratio * 100).round();
|
||||
}
|
||||
|
||||
static const Set<String> _serverOnlyEvents = {
|
||||
'user_register',
|
||||
'user_login',
|
||||
'realtime_online',
|
||||
'order_created',
|
||||
'order_paid',
|
||||
'coin_consume',
|
||||
'video_status_change',
|
||||
};
|
||||
|
||||
static const Set<String> _reservedPayloadSignatureFields = {
|
||||
'sdk_type',
|
||||
'client_type',
|
||||
'id_type',
|
||||
'event_sig',
|
||||
'event_sig_version',
|
||||
'security_key_version',
|
||||
'sign_algorithm',
|
||||
'sign_ts',
|
||||
};
|
||||
|
||||
static String _eventSignature(
|
||||
Map<String, dynamic> event,
|
||||
) {
|
||||
try {
|
||||
if (_eventSignatureHmacKey.isEmpty) return '';
|
||||
final factors = [
|
||||
event['event']?.toString() ?? '',
|
||||
event['event_id']?.toString() ?? '',
|
||||
event['app_id']?.toString() ?? '',
|
||||
event['client_ts']?.toString() ?? '',
|
||||
event['device_fingerprint']?.toString() ?? '',
|
||||
];
|
||||
final signatureInput = factors.join('|');
|
||||
final signature = Hmac(sha256, utf8.encode(_eventSignatureHmacKey))
|
||||
.convert(utf8.encode(signatureInput))
|
||||
.toString();
|
||||
Logger.analyticsSdk(
|
||||
'event_sig input="$signatureInput" '
|
||||
'event_sig=$signature '
|
||||
'event_sig_version=$_eventSignatureVersion',
|
||||
);
|
||||
return signature;
|
||||
} catch (_) {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/// 从公共字段 Map 和业务特有字段列表生成 event_id。
|
||||
///
|
||||
/// 本方法永远不会抛出异常。
|
||||
static String generateEventIdFromCommonFields(
|
||||
Map<String, dynamic> commonFields, List<String> extraFields) {
|
||||
return generateEventId([
|
||||
commonFields['event'] as String? ?? '',
|
||||
commonFields['app_id'] as String? ?? '',
|
||||
commonFields['channel'] as String? ?? '',
|
||||
commonFields['uid'] as String? ?? '',
|
||||
commonFields['device'] as String? ?? '',
|
||||
commonFields['device_id'] as String? ?? '',
|
||||
commonFields['device_brand'] as String? ?? '',
|
||||
commonFields['device_model'] as String? ?? '',
|
||||
(commonFields['client_ts'] ?? '').toString(),
|
||||
commonFields['sid'] as String? ?? '',
|
||||
...extraFields,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
/// Builds the sdk_init payload from the SDK common fields plus platform-only
|
||||
/// fields. Web and app clients intentionally carry different field families.
|
||||
class SdkInitPayloadBuilder {
|
||||
SdkInitPayloadBuilder._();
|
||||
|
||||
static Map<String, dynamic> build({
|
||||
required Map<String, dynamic> commonFields,
|
||||
required bool isWeb,
|
||||
Map<String, dynamic> platformFields = const {},
|
||||
}) {
|
||||
final fields = {
|
||||
...commonFields,
|
||||
...platformFields,
|
||||
};
|
||||
|
||||
final osFamily = _osFamily(_string(fields['system_name'], 'unknown'));
|
||||
final payload = <String, dynamic>{
|
||||
'platform': isWeb ? 'web' : 'flutter',
|
||||
'os_family': osFamily,
|
||||
'device_type': _deviceType(_string(fields['device'], '')),
|
||||
'language': _string(fields['language'], 'unknown'),
|
||||
'timezone': _string(fields['timezone'], 'unknown'),
|
||||
'network_type': _string(fields['network_type'], 'unknown'),
|
||||
'screen_width': _int(fields['screen_width'], -1),
|
||||
'screen_height': _int(fields['screen_height'], -1),
|
||||
'screen_dpi': _int(fields['screen_dpi'], -1),
|
||||
'device_pixel_ratio': _devicePixelRatio(fields['device_pixel_ratio']),
|
||||
'orientation': _string(fields['orientation'], 'unknown'),
|
||||
'cpu_cores': _int(fields['cpu_cores'], -1),
|
||||
'device_memory_gb': _int(fields['device_memory_gb'], -1),
|
||||
'is_debug': kDebugMode ? 1 : 0,
|
||||
'flutter_build_mode': _string(fields['flutter_build_mode'], _buildMode()),
|
||||
'touch_support': _int(fields['touch_support'], -1),
|
||||
'max_touch_points': _int(fields['max_touch_points'], -1),
|
||||
};
|
||||
|
||||
if (isWeb) {
|
||||
payload.addAll(_webPayload(fields));
|
||||
} else if (osFamily == 'android') {
|
||||
payload.addAll(_androidPayload(fields));
|
||||
} else if (osFamily == 'ios') {
|
||||
payload.addAll(_iosPayload(fields));
|
||||
} else {
|
||||
payload.addAll(_flutterPayload());
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
static Map<String, dynamic> _nativeSharedPayload(
|
||||
Map<String, dynamic> fields,
|
||||
) {
|
||||
return {
|
||||
'carrier': _string(fields['carrier'], ''),
|
||||
'mcc': _string(fields['mcc'], 'unknown'),
|
||||
'mnc': _string(fields['mnc'], 'unknown'),
|
||||
'has_sim': _int(fields['has_sim'], -1),
|
||||
'storage': _int(fields['storage'], -1),
|
||||
'available_storage': _int(fields['available_storage'], -1),
|
||||
'battery_level': _int(fields['battery_level'], -1),
|
||||
'is_charging': _int(fields['is_charging'], -1),
|
||||
'vpn_enabled': _int(fields['vpn_enabled'], -1),
|
||||
'proxy_enabled': _int(fields['proxy_enabled'], -1),
|
||||
};
|
||||
}
|
||||
|
||||
static Map<String, dynamic> _androidPayload(Map<String, dynamic> fields) {
|
||||
return {
|
||||
..._nativeSharedPayload(fields),
|
||||
'activation_time': _int(fields['activation_time'], 0),
|
||||
'install_source': _string(fields['install_source'], ''),
|
||||
'android_id': _string(fields['android_id'], ''),
|
||||
'android_id_o': _string(fields['android_id_o'], ''),
|
||||
'android_id_status': _string(fields['android_id_status'], 'unknown'),
|
||||
'oaid': _string(fields['oaid'], ''),
|
||||
'oaid_o': _string(fields['oaid_o'], ''),
|
||||
'aaid': _string(fields['aaid'], ''),
|
||||
'aaid_o': _string(fields['aaid_o'], ''),
|
||||
'guid': _string(fields['guid'], ''),
|
||||
'guid_o': _string(fields['guid_o'], ''),
|
||||
'brand':
|
||||
_string(fields['brand'], _string(fields['device_brand'], 'unknown')),
|
||||
'manufacturer': _string(fields['manufacturer'], 'unknown'),
|
||||
'model':
|
||||
_string(fields['model'], _string(fields['device_model'], 'unknown')),
|
||||
'package_name': _string(fields['package_name'], 'unknown'),
|
||||
'abi': _string(fields['abi'], 'unknown'),
|
||||
'is_rooted': _int(fields['is_rooted'], -1),
|
||||
'usb_debug_enabled': _int(fields['usb_debug_enabled'], -1),
|
||||
'developer_options_enabled':
|
||||
_int(fields['developer_options_enabled'], -1),
|
||||
'adb_enabled': _int(fields['adb_enabled'], -1),
|
||||
'has_baseband': _int(fields['has_baseband'], -1),
|
||||
'sensor_count_supported': _int(fields['sensor_count_supported'], -1),
|
||||
'has_accelerometer': _int(fields['has_accelerometer'], -1),
|
||||
'has_gyroscope': _int(fields['has_gyroscope'], -1),
|
||||
'has_magnetometer': _int(fields['has_magnetometer'], -1),
|
||||
'has_light_sensor': _int(fields['has_light_sensor'], -1),
|
||||
'has_proximity_sensor': _int(fields['has_proximity_sensor'], -1),
|
||||
'build_hardware': _string(fields['build_hardware'], 'unknown'),
|
||||
'build_product': _string(fields['build_product'], 'unknown'),
|
||||
'build_fingerprint': _string(fields['build_fingerprint'], 'unknown'),
|
||||
};
|
||||
}
|
||||
|
||||
static Map<String, dynamic> _iosPayload(Map<String, dynamic> fields) {
|
||||
return {
|
||||
..._nativeSharedPayload(fields),
|
||||
'bundle_id': _string(fields['bundle_id'], ''),
|
||||
'ios_device_model_raw': _string(fields['ios_device_model_raw'], ''),
|
||||
'idfv': _string(fields['idfv'], ''),
|
||||
'idfa': _string(fields['idfa'], ''),
|
||||
'att_status': _string(fields['att_status'], 'unknown'),
|
||||
'lat': _int(fields['lat'], -1),
|
||||
'is_jailbroken': _int(fields['is_jailbroken'], -1),
|
||||
};
|
||||
}
|
||||
|
||||
static Map<String, dynamic> _flutterPayload() {
|
||||
return const {};
|
||||
}
|
||||
|
||||
static Map<String, dynamic> _webPayload(Map<String, dynamic> fields) {
|
||||
return {
|
||||
'current_url': _string(fields['current_url'], ''),
|
||||
'current_domain': _string(fields['current_domain'], ''),
|
||||
'current_path': _string(fields['current_path'], ''),
|
||||
'current_query_hash': _string(fields['current_query_hash'], ''),
|
||||
'referer': _string(fields['referer'], ''),
|
||||
'referer_domain': _string(fields['referer_domain'], ''),
|
||||
'referer_path': _string(fields['referer_path'], ''),
|
||||
'browser_name': _string(fields['browser_name'], 'unknown'),
|
||||
'browser_version': _string(fields['browser_version'], 'unknown'),
|
||||
'browser_kernel': _string(fields['browser_kernel'], 'unknown'),
|
||||
'display_mode': _string(fields['display_mode'], 'unknown'),
|
||||
'visibility_state': _string(fields['visibility_state'], 'unknown'),
|
||||
'cookie_enabled': _int(fields['cookie_enabled'], -1),
|
||||
'local_storage_enabled': _int(fields['local_storage_enabled'], -1),
|
||||
'session_storage_enabled': _int(fields['session_storage_enabled'], -1),
|
||||
'indexeddb_enabled': _int(fields['indexeddb_enabled'], -1),
|
||||
'service_worker_enabled': _int(fields['service_worker_enabled'], -1),
|
||||
'connection_type': _string(fields['connection_type'], 'unknown'),
|
||||
'effective_type': _string(fields['effective_type'], 'unknown'),
|
||||
'downlink': _int(fields['downlink'], -1),
|
||||
'rtt': _int(fields['rtt'], -1),
|
||||
'canvas_fp': _string(fields['canvas_fp'], ''),
|
||||
'webgl_fp': _string(fields['webgl_fp'], ''),
|
||||
'webgl_vendor': _string(fields['webgl_vendor'], 'unknown'),
|
||||
'webgl_renderer': _string(fields['webgl_renderer'], 'unknown'),
|
||||
'webgl_status': _string(fields['webgl_status'], 'unknown'),
|
||||
'webgl_extensions_hash': _string(fields['webgl_extensions_hash'], ''),
|
||||
'webgl_params_hash': _string(fields['webgl_params_hash'], ''),
|
||||
'audio_sample_rate': _int(fields['audio_sample_rate'], -1),
|
||||
'fonts': _string(fields['fonts'], ''),
|
||||
'fonts_hash': _string(fields['fonts_hash'], ''),
|
||||
'font_count': _int(fields['font_count'], -1),
|
||||
'plugins': _string(fields['plugins'], ''),
|
||||
'plugins_hash': _string(fields['plugins_hash'], ''),
|
||||
'plugin_count': _int(fields['plugin_count'], -1),
|
||||
'mimes': _string(fields['mimes'], ''),
|
||||
'mimes_hash': _string(fields['mimes_hash'], ''),
|
||||
'mime_count': _int(fields['mime_count'], -1),
|
||||
'has_webdriver': _int(fields['has_webdriver'], 0),
|
||||
'hardware_concurrency': _int(fields['hardware_concurrency'], -1),
|
||||
'browser_platform': _string(fields['browser_platform'], 'unknown'),
|
||||
'color_depth': _int(fields['color_depth'], -1),
|
||||
'audio_fp': _string(fields['audio_fp'], ''),
|
||||
'viewport_width': _int(fields['viewport_width'], -1),
|
||||
'viewport_height': _int(fields['viewport_height'], -1),
|
||||
'is_headless': _int(fields['is_headless'], -1),
|
||||
'headless_reason': _string(fields['headless_reason'], ''),
|
||||
};
|
||||
}
|
||||
|
||||
static String _string(dynamic value, String fallback) {
|
||||
if (value == null) return fallback;
|
||||
final string = value.toString().trim();
|
||||
return string.isEmpty ? fallback : string;
|
||||
}
|
||||
|
||||
static int _int(dynamic value, int fallback) {
|
||||
if (value is int) return value;
|
||||
if (value is double && value.isFinite) return value.round();
|
||||
if (value is String) {
|
||||
final asInt = int.tryParse(value.trim());
|
||||
if (asInt != null) return asInt;
|
||||
final asDouble = double.tryParse(value.trim());
|
||||
if (asDouble != null && asDouble.isFinite) return asDouble.round();
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
static int _devicePixelRatio(dynamic value) {
|
||||
if (value == null) return -1;
|
||||
final ratio =
|
||||
value is num ? value.toDouble() : double.tryParse(value.toString());
|
||||
if (ratio == null || !ratio.isFinite) return -1;
|
||||
if (ratio > 10) return ratio.round();
|
||||
return (ratio * 100).round();
|
||||
}
|
||||
|
||||
static String _deviceType(String device) {
|
||||
switch (device.toLowerCase()) {
|
||||
case 'android':
|
||||
case 'ios':
|
||||
return 'phone';
|
||||
case 'pc':
|
||||
return 'desktop';
|
||||
default:
|
||||
return 'unknown';
|
||||
}
|
||||
}
|
||||
|
||||
static String _osFamily(String systemName) {
|
||||
switch (systemName.toLowerCase()) {
|
||||
case 'android':
|
||||
return 'android';
|
||||
case 'ios':
|
||||
return 'ios';
|
||||
case 'macos':
|
||||
return 'macos';
|
||||
case 'windows':
|
||||
return 'windows';
|
||||
case 'linux':
|
||||
return 'linux';
|
||||
default:
|
||||
return 'unknown';
|
||||
}
|
||||
}
|
||||
|
||||
static String _buildMode() {
|
||||
if (kReleaseMode) return 'release';
|
||||
if (kProfileMode) return 'profile';
|
||||
if (kDebugMode) return 'debug';
|
||||
return 'unknown';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export 'sdk_init_platform_fields_native.dart'
|
||||
// ignore: uri_does_not_exist
|
||||
if (dart.library.html) 'sdk_init_platform_fields_web.dart';
|
||||
@@ -0,0 +1,61 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:device_info_plus/device_info_plus.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:package_info_plus/package_info_plus.dart';
|
||||
|
||||
const MethodChannel _nativeChannel = MethodChannel('analytics_sdk');
|
||||
|
||||
Future<Map<String, dynamic>> collectSdkInitPlatformFields() async {
|
||||
final fields = <String, dynamic>{};
|
||||
|
||||
try {
|
||||
final info = await PackageInfo.fromPlatform();
|
||||
fields['package_name'] = info.packageName;
|
||||
fields['bundle_id'] = info.packageName;
|
||||
} catch (_) {}
|
||||
|
||||
try {
|
||||
final plugin = DeviceInfoPlugin();
|
||||
if (Platform.isAndroid) {
|
||||
final info = await plugin.androidInfo;
|
||||
fields.addAll({
|
||||
'brand': info.brand,
|
||||
'manufacturer': info.manufacturer,
|
||||
'model': info.model,
|
||||
'abi': info.supportedAbis.isNotEmpty
|
||||
? info.supportedAbis.first
|
||||
: 'unknown',
|
||||
'is_rooted': -1,
|
||||
'build_hardware': info.hardware,
|
||||
'build_product': info.product,
|
||||
'build_fingerprint': info.fingerprint,
|
||||
});
|
||||
} else if (Platform.isIOS) {
|
||||
final info = await plugin.iosInfo;
|
||||
fields.addAll({
|
||||
'bundle_id': fields['bundle_id'] ?? '',
|
||||
'ios_device_model_raw': info.utsname.machine,
|
||||
'is_jailbroken': -1,
|
||||
});
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
fields.addAll(await _collectNativeChannelFields());
|
||||
|
||||
return fields;
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> _collectNativeChannelFields() async {
|
||||
try {
|
||||
final result = await _nativeChannel
|
||||
.invokeMapMethod<String, dynamic>('getSdkInitPlatformFields');
|
||||
if (result == null) return const {};
|
||||
return Map<String, dynamic>.from(result)
|
||||
..removeWhere((key, value) => value == null);
|
||||
} on MissingPluginException {
|
||||
return const {};
|
||||
} catch (_) {
|
||||
return const {};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,540 @@
|
||||
// ignore: avoid_web_libraries_in_flutter
|
||||
import 'dart:convert';
|
||||
import 'dart:html' as html;
|
||||
|
||||
import 'package:crypto/crypto.dart';
|
||||
import 'package:analytics_sdk/utils/js_interop_compat.dart';
|
||||
|
||||
Future<Map<String, dynamic>> collectSdkInitPlatformFields() async {
|
||||
final navigator = html.window.navigator;
|
||||
final screen = html.window.screen;
|
||||
final uri = Uri.tryParse(html.window.location.href);
|
||||
final refererUri = Uri.tryParse(html.document.referrer);
|
||||
final connection = _connection(navigator);
|
||||
final browser = _parseBrowser(navigator.userAgent);
|
||||
final canvasFp = _canvasFingerprint();
|
||||
final webgl = _webglInfo();
|
||||
final audio = _audioInfo();
|
||||
final fonts = _fonts();
|
||||
final plugins = _plugins(navigator);
|
||||
final mimes = _mimes(navigator);
|
||||
final headless = _headlessReasons(navigator, plugins);
|
||||
headless.addAll(webgl.headlessReasons);
|
||||
if (fonts != null && fonts.isNotEmpty && fonts.length < 3) {
|
||||
headless.add('fonts_low');
|
||||
}
|
||||
|
||||
return {
|
||||
'current_url': _urlWithoutQueryAndFragment(uri),
|
||||
'current_domain': uri?.host ?? '',
|
||||
'current_path': uri?.path ?? '',
|
||||
'current_query_hash': _hash(uri?.query ?? ''),
|
||||
'referer': _urlWithoutQueryAndFragment(refererUri),
|
||||
'referer_domain': refererUri?.host ?? '',
|
||||
'referer_path': refererUri?.path ?? '',
|
||||
'browser_name': browser.name,
|
||||
'browser_version': browser.version,
|
||||
'browser_kernel': _browserKernel(navigator.userAgent),
|
||||
'display_mode': _displayMode(),
|
||||
'visibility_state': _nonEmpty(html.document.visibilityState, 'unknown'),
|
||||
'cookie_enabled': navigator.cookieEnabled == true ? 1 : 0,
|
||||
'local_storage_enabled': _storageEnabled(html.window.localStorage),
|
||||
'session_storage_enabled': _storageEnabled(html.window.sessionStorage),
|
||||
'indexeddb_enabled': _hasProperty(html.window, 'indexedDB'),
|
||||
'service_worker_enabled': _hasProperty(navigator, 'serviceWorker'),
|
||||
'connection_type': _string(connection, 'type', 'unknown'),
|
||||
'effective_type': _effectiveType(connection),
|
||||
'downlink': _downlinkKbps(connection),
|
||||
'rtt': _number(connection, 'rtt', -1).round(),
|
||||
'canvas_fp': canvasFp,
|
||||
'webgl_fp': webgl.fp,
|
||||
'webgl_vendor': webgl.vendor,
|
||||
'webgl_renderer': webgl.renderer,
|
||||
'webgl_status': webgl.status,
|
||||
'webgl_extensions_hash': webgl.extensionsHash,
|
||||
'webgl_params_hash': webgl.paramsHash,
|
||||
'audio_sample_rate': audio.sampleRate,
|
||||
'fonts': fonts?.join(',') ?? '',
|
||||
'fonts_hash': _hash(fonts?.join('|') ?? ''),
|
||||
'font_count': fonts?.length ?? -1,
|
||||
'plugins': plugins.join(','),
|
||||
'plugins_hash': _hash(plugins.join('|')),
|
||||
'plugin_count': plugins.length,
|
||||
'mimes': mimes.join(','),
|
||||
'mimes_hash': _hash(mimes.join('|')),
|
||||
'mime_count': mimes.length,
|
||||
'has_webdriver': navigator.webdriver == true ? 1 : 0,
|
||||
'hardware_concurrency': navigator.hardwareConcurrency ?? -1,
|
||||
'browser_platform': _nonEmpty(navigator.platform, 'unknown'),
|
||||
'color_depth': screen?.colorDepth ?? -1,
|
||||
'audio_fp': audio.fp,
|
||||
'viewport_width': html.window.innerWidth ?? -1,
|
||||
'viewport_height': html.window.innerHeight ?? -1,
|
||||
'touch_support': (navigator.maxTouchPoints ?? 0) > 0 ? 1 : 0,
|
||||
'max_touch_points': navigator.maxTouchPoints ?? -1,
|
||||
'is_headless': headless.isEmpty ? 0 : 1,
|
||||
'headless_reason': _unique(headless).join(','),
|
||||
};
|
||||
}
|
||||
|
||||
class _BrowserInfo {
|
||||
final String name;
|
||||
final String version;
|
||||
|
||||
const _BrowserInfo(this.name, this.version);
|
||||
}
|
||||
|
||||
class _WebglInfo {
|
||||
final String fp;
|
||||
final String vendor;
|
||||
final String renderer;
|
||||
final String status;
|
||||
final String extensionsHash;
|
||||
final String paramsHash;
|
||||
final List<String> headlessReasons;
|
||||
|
||||
const _WebglInfo({
|
||||
required this.fp,
|
||||
required this.vendor,
|
||||
required this.renderer,
|
||||
required this.status,
|
||||
required this.extensionsHash,
|
||||
required this.paramsHash,
|
||||
required this.headlessReasons,
|
||||
});
|
||||
}
|
||||
|
||||
class _AudioInfo {
|
||||
final int sampleRate;
|
||||
final String fp;
|
||||
|
||||
const _AudioInfo(this.sampleRate, this.fp);
|
||||
}
|
||||
|
||||
String _urlWithoutQueryAndFragment(Uri? uri) {
|
||||
if (uri == null) return '';
|
||||
final buffer = StringBuffer();
|
||||
if (uri.scheme.isNotEmpty) {
|
||||
buffer
|
||||
..write(uri.scheme)
|
||||
..write('://');
|
||||
}
|
||||
if (uri.host.isNotEmpty) {
|
||||
buffer.write(uri.host);
|
||||
}
|
||||
if (uri.hasPort) {
|
||||
buffer.write(':${uri.port}');
|
||||
}
|
||||
if (uri.path.isNotEmpty) {
|
||||
buffer.write(uri.path);
|
||||
}
|
||||
return buffer.toString();
|
||||
}
|
||||
|
||||
Object? _connection(html.Navigator navigator) {
|
||||
for (final key in ['connection', 'mozConnection', 'webkitConnection']) {
|
||||
try {
|
||||
if (hasJsProperty(navigator, key)) {
|
||||
return getJsProperty(navigator, key);
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
_BrowserInfo _parseBrowser(String ua) {
|
||||
final rules = <String, RegExp>{
|
||||
'Edge': RegExp(r'(?:Edg|EdgA|EdgiOS|Edge)/([\d.]+)'),
|
||||
'Opera': RegExp(r'(?:OPR|Opera)/([\d.]+)'),
|
||||
'Chrome': RegExp(r'(?:Chrome|CriOS)/([\d.]+)'),
|
||||
'Firefox': RegExp(r'(?:Firefox|FxiOS)/([\d.]+)'),
|
||||
'Safari': RegExp(r'Version/([\d.]+).*Safari'),
|
||||
};
|
||||
for (final entry in rules.entries) {
|
||||
final match = entry.value.firstMatch(ua);
|
||||
if (match != null) {
|
||||
return _BrowserInfo(entry.key, _nonEmpty(match.group(1), 'unknown'));
|
||||
}
|
||||
}
|
||||
return const _BrowserInfo('unknown', 'unknown');
|
||||
}
|
||||
|
||||
String _string(Object? object, String key, String fallback) {
|
||||
if (object == null) return fallback;
|
||||
try {
|
||||
final value = getJsProperty(object, key);
|
||||
final string = value?.toString().trim() ?? '';
|
||||
return string.isEmpty ? fallback : string;
|
||||
} catch (_) {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
num _number(Object? object, String key, num fallback) {
|
||||
if (object == null) return fallback;
|
||||
try {
|
||||
final value = getJsProperty(object, key);
|
||||
if (value is num && value.isFinite) return value;
|
||||
return num.tryParse(value?.toString() ?? '') ?? fallback;
|
||||
} catch (_) {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
String _effectiveType(Object? connection) {
|
||||
final value = _string(connection, 'effectiveType', 'unknown').toLowerCase();
|
||||
if (value == 'slow-2g') return '2g';
|
||||
const allowed = {'2g', '3g', '4g', '5g'};
|
||||
return allowed.contains(value) ? value : 'unknown';
|
||||
}
|
||||
|
||||
int _downlinkKbps(Object? connection) {
|
||||
final downlink = _number(connection, 'downlink', -1);
|
||||
return downlink >= 0 ? (downlink * 1000).round() : -1;
|
||||
}
|
||||
|
||||
int _hasProperty(Object object, String key) {
|
||||
try {
|
||||
return hasJsProperty(object, key) ? 1 : 0;
|
||||
} catch (_) {
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
int _storageEnabled(html.Storage storage) {
|
||||
try {
|
||||
const key = '__analytics_sdk_storage_test__';
|
||||
storage[key] = '1';
|
||||
final ok = storage[key] == '1';
|
||||
storage.remove(key);
|
||||
return ok ? 1 : 0;
|
||||
} catch (_) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
String _canvasFingerprint() {
|
||||
try {
|
||||
final canvas = html.CanvasElement(width: 240, height: 60);
|
||||
final context = canvas.context2D;
|
||||
context
|
||||
..textBaseline = 'top'
|
||||
..font = '14px Arial'
|
||||
..fillStyle = '#f60';
|
||||
context.fillRect(125, 1, 62, 20);
|
||||
context
|
||||
..fillStyle = '#069'
|
||||
..fillText('analytics_sdk_canvas_fp', 2, 15)
|
||||
..fillStyle = 'rgba(102, 204, 0, 0.7)'
|
||||
..fillText('analytics_sdk_canvas_fp', 4, 17);
|
||||
return _hash(canvas.toDataUrl());
|
||||
} catch (_) {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
_WebglInfo _webglInfo() {
|
||||
Object? gl;
|
||||
try {
|
||||
final canvas = html.CanvasElement(width: 1, height: 1);
|
||||
gl = canvas.getContext('webgl') ?? canvas.getContext('experimental-webgl');
|
||||
} catch (_) {
|
||||
return const _WebglInfo(
|
||||
fp: '',
|
||||
vendor: 'unknown',
|
||||
renderer: 'unknown',
|
||||
status: 'error',
|
||||
extensionsHash: '',
|
||||
paramsHash: '',
|
||||
headlessReasons: ['webgl_unavailable'],
|
||||
);
|
||||
}
|
||||
|
||||
if (gl == null) {
|
||||
return const _WebglInfo(
|
||||
fp: '',
|
||||
vendor: 'unknown',
|
||||
renderer: 'unknown',
|
||||
status: 'unsupported',
|
||||
extensionsHash: '',
|
||||
paramsHash: '',
|
||||
headlessReasons: ['webgl_unavailable'],
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
final debugInfo = callJsMethod(gl, 'getExtension', [
|
||||
'WEBGL_debug_renderer_info',
|
||||
]);
|
||||
final vendor =
|
||||
_webglParameter(gl, debugInfo, 'UNMASKED_VENDOR_WEBGL') ??
|
||||
_webglParameterByConstant(gl, 0x1F00) ??
|
||||
'unknown';
|
||||
final renderer =
|
||||
_webglParameter(gl, debugInfo, 'UNMASKED_RENDERER_WEBGL') ??
|
||||
_webglParameterByConstant(gl, 0x1F01) ??
|
||||
'unknown';
|
||||
final extensions = _jsStringArray(
|
||||
callJsMethod(gl, 'getSupportedExtensions'),
|
||||
)..sort();
|
||||
final params = _webglParams(gl);
|
||||
final extensionsHash = _hash(extensions.join('|'));
|
||||
final paramsHash = _hash(params.join('|'));
|
||||
final fp = _hash(
|
||||
[vendor, renderer, extensions.join('|'), params.join('|')].join('|'),
|
||||
);
|
||||
|
||||
return _WebglInfo(
|
||||
fp: fp,
|
||||
vendor: vendor,
|
||||
renderer: renderer,
|
||||
status: 'available',
|
||||
extensionsHash: extensionsHash,
|
||||
paramsHash: paramsHash,
|
||||
headlessReasons: const [],
|
||||
);
|
||||
} catch (_) {
|
||||
return const _WebglInfo(
|
||||
fp: '',
|
||||
vendor: 'unknown',
|
||||
renderer: 'unknown',
|
||||
status: 'error',
|
||||
extensionsHash: '',
|
||||
paramsHash: '',
|
||||
headlessReasons: ['webgl_unavailable'],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String? _webglParameter(Object gl, Object? debugInfo, String key) {
|
||||
if (debugInfo == null) return null;
|
||||
try {
|
||||
final parameter = getJsProperty(debugInfo, key);
|
||||
if (parameter == null) return null;
|
||||
final value = callJsMethod(gl, 'getParameter', [parameter]);
|
||||
final string = _jsValueToString(value).trim();
|
||||
return string.isEmpty ? null : string;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
String? _webglParameterByConstant(Object gl, int parameter) {
|
||||
try {
|
||||
final value = callJsMethod(gl, 'getParameter', [parameter]);
|
||||
final string = _jsValueToString(value).trim();
|
||||
return string.isEmpty ? null : string;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
List<String> _webglParams(Object gl) {
|
||||
const parameters = <int>[
|
||||
0x0D33, // MAX_TEXTURE_SIZE
|
||||
0x0D3A, // MAX_VIEWPORT_DIMS
|
||||
0x846D, // ALIASED_POINT_SIZE_RANGE
|
||||
0x846E, // ALIASED_LINE_WIDTH_RANGE
|
||||
0x84E8, // MAX_RENDERBUFFER_SIZE
|
||||
0x8869, // MAX_VERTEX_ATTRIBS
|
||||
0x8872, // MAX_TEXTURE_IMAGE_UNITS
|
||||
0x8B4C, // MAX_VERTEX_TEXTURE_IMAGE_UNITS
|
||||
0x8B8C, // SHADING_LANGUAGE_VERSION
|
||||
];
|
||||
final values = <String>[];
|
||||
for (final parameter in parameters) {
|
||||
final value = _webglParameterByConstant(gl, parameter);
|
||||
if (value != null) values.add('$parameter=$value');
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
_AudioInfo _audioInfo() {
|
||||
Object? context;
|
||||
try {
|
||||
final constructor =
|
||||
getJsProperty(html.window, 'AudioContext') ??
|
||||
getJsProperty(html.window, 'webkitAudioContext');
|
||||
if (constructor == null) return const _AudioInfo(-1, '');
|
||||
context = constructJsObject(constructor);
|
||||
final sampleRate = _number(context, 'sampleRate', -1).round();
|
||||
final fp = sampleRate > 0 ? _hash('sampleRate:$sampleRate') : '';
|
||||
return _AudioInfo(sampleRate, fp);
|
||||
} catch (_) {
|
||||
return const _AudioInfo(-1, '');
|
||||
} finally {
|
||||
if (context != null) {
|
||||
try {
|
||||
callJsMethod(context, 'close');
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
List<String>? _fonts() {
|
||||
try {
|
||||
final canvas = html.CanvasElement(width: 200, height: 50);
|
||||
final context = canvas.context2D;
|
||||
const text = 'mmmmmmmmmmlli';
|
||||
const baseFonts = ['monospace', 'serif', 'sans-serif'];
|
||||
final baseline = <String, num>{};
|
||||
for (final base in baseFonts) {
|
||||
context.font = '72px $base';
|
||||
baseline[base] = context.measureText(text).width ?? -1;
|
||||
}
|
||||
|
||||
const candidates = [
|
||||
'Arial',
|
||||
'Arial Black',
|
||||
'Calibri',
|
||||
'Cambria',
|
||||
'Comic Sans MS',
|
||||
'Courier New',
|
||||
'Georgia',
|
||||
'Helvetica',
|
||||
'Impact',
|
||||
'Menlo',
|
||||
'Microsoft YaHei',
|
||||
'Monaco',
|
||||
'Noto Sans',
|
||||
'PingFang SC',
|
||||
'Roboto',
|
||||
'San Francisco',
|
||||
'Segoe UI',
|
||||
'Tahoma',
|
||||
'Times New Roman',
|
||||
'Trebuchet MS',
|
||||
'Verdana',
|
||||
];
|
||||
|
||||
final detected = <String>[];
|
||||
for (final font in candidates) {
|
||||
var found = false;
|
||||
for (final base in baseFonts) {
|
||||
context.font = '72px "$font", $base';
|
||||
final width = context.measureText(text).width ?? -1;
|
||||
if (width != baseline[base]) {
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (found) detected.add(font);
|
||||
}
|
||||
return detected;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
List<String> _plugins(html.Navigator navigator) {
|
||||
try {
|
||||
return _indexedPropertyList(getJsProperty(navigator, 'plugins'), 'name');
|
||||
} catch (_) {
|
||||
return const [];
|
||||
}
|
||||
}
|
||||
|
||||
List<String> _mimes(html.Navigator navigator) {
|
||||
try {
|
||||
return _indexedPropertyList(getJsProperty(navigator, 'mimeTypes'), 'type');
|
||||
} catch (_) {
|
||||
return const [];
|
||||
}
|
||||
}
|
||||
|
||||
List<String> _indexedPropertyList(Object? collection, String property) {
|
||||
if (collection == null) return const [];
|
||||
final length = _number(collection, 'length', 0).round();
|
||||
if (length <= 0) return const [];
|
||||
final values = <String>[];
|
||||
for (var i = 0; i < length; i++) {
|
||||
try {
|
||||
final item = getJsProperty(collection, i);
|
||||
if (item == null) continue;
|
||||
final value = getJsProperty(item, property);
|
||||
final string = value?.toString().trim() ?? '';
|
||||
if (string.isNotEmpty) values.add(string);
|
||||
} catch (_) {}
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
List<String> _jsStringArray(Object? collection) {
|
||||
if (collection == null) return const [];
|
||||
final length = _number(collection, 'length', 0).round();
|
||||
if (length <= 0) return const [];
|
||||
final values = <String>[];
|
||||
for (var i = 0; i < length; i++) {
|
||||
try {
|
||||
final value = getJsProperty(collection, i);
|
||||
final string = value?.toString().trim() ?? '';
|
||||
if (string.isNotEmpty) values.add(string);
|
||||
} catch (_) {}
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
String _jsValueToString(Object? value) {
|
||||
if (value == null) return '';
|
||||
if (value is String || value is num || value is bool) return '$value';
|
||||
try {
|
||||
final values = _jsStringArray(value);
|
||||
if (values.isNotEmpty) return values.join(',');
|
||||
} catch (_) {}
|
||||
return value.toString();
|
||||
}
|
||||
|
||||
List<String> _headlessReasons(html.Navigator navigator, List<String> plugins) {
|
||||
final reasons = <String>[];
|
||||
try {
|
||||
if (navigator.webdriver == true) reasons.add('webdriver');
|
||||
} catch (_) {}
|
||||
if (plugins.isEmpty) reasons.add('plugins_empty');
|
||||
try {
|
||||
final width = html.window.innerWidth ?? 0;
|
||||
final height = html.window.innerHeight ?? 0;
|
||||
if (width <= 0 || height <= 0) reasons.add('viewport_zero');
|
||||
} catch (_) {}
|
||||
return reasons;
|
||||
}
|
||||
|
||||
String _displayMode() {
|
||||
const modes = ['standalone', 'minimal-ui', 'fullscreen', 'browser'];
|
||||
for (final mode in modes) {
|
||||
try {
|
||||
if (html.window.matchMedia('(display-mode: $mode)').matches) {
|
||||
return mode;
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
String _browserKernel(String ua) {
|
||||
if (ua.contains('Edge/')) return 'edgehtml';
|
||||
if (ua.contains('Edg/')) return 'chromium';
|
||||
if (ua.contains('OPR/') || ua.contains('Opera/')) return 'chromium';
|
||||
if (ua.contains('Chrome/') || ua.contains('Chromium/')) return 'chromium';
|
||||
if (ua.contains('Firefox/')) return 'gecko';
|
||||
if (ua.contains('Trident/')) return 'trident';
|
||||
if (ua.contains('Safari/')) return 'webkit';
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
String _nonEmpty(String? value, String fallback) {
|
||||
final string = value?.trim() ?? '';
|
||||
return string.isEmpty ? fallback : string;
|
||||
}
|
||||
|
||||
List<String> _unique(List<String> values) {
|
||||
final seen = <String>{};
|
||||
return [
|
||||
for (final value in values)
|
||||
if (seen.add(value)) value,
|
||||
];
|
||||
}
|
||||
|
||||
String _hash(String value) {
|
||||
if (value.isEmpty) return '';
|
||||
return md5.convert(utf8.encode(value)).toString();
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
class SessionBehaviorManager {
|
||||
static final SessionBehaviorManager instance =
|
||||
SessionBehaviorManager._internal();
|
||||
|
||||
factory SessionBehaviorManager() => instance;
|
||||
|
||||
SessionBehaviorManager._internal() : _nowProvider = DateTime.now;
|
||||
|
||||
SessionBehaviorManager.forTest({
|
||||
DateTime Function()? nowProvider,
|
||||
}) : _nowProvider = nowProvider ?? DateTime.now;
|
||||
|
||||
static const int _scrollThresholdPx = 8;
|
||||
static const int _bottomTolerancePx = 1;
|
||||
static const _scrollMinInterval = Duration(milliseconds: 200);
|
||||
static const _touchDedupWindow = Duration(milliseconds: 50);
|
||||
|
||||
final DateTime Function() _nowProvider;
|
||||
|
||||
String _sid = '';
|
||||
DateTime? _pageStartedAt;
|
||||
int? _lastScrollTop;
|
||||
DateTime? _lastScrollProcessedAt;
|
||||
DateTime? _lastTouchStartAt;
|
||||
DateTime? _lastSwipeAt;
|
||||
DateTime? _lastLongPressAt;
|
||||
DateTime? _lastPinchAt;
|
||||
int _lastScrollDirection = 0;
|
||||
|
||||
int _scrollTotalCount = 0;
|
||||
int _scrollTotalDistancePx = 0;
|
||||
int _scrollUpCount = 0;
|
||||
int _scrollDownCount = 0;
|
||||
int _scrollDirectionChangeCount = 0;
|
||||
int _firstScrollDelayMs = -1;
|
||||
int _maxScrollDepthPercent = -1;
|
||||
int _isReachBottom = -1;
|
||||
int _contentHeight = -1;
|
||||
int _touchTotalCount = 0;
|
||||
int _swipeTotalCount = 0;
|
||||
int _longPressTotalCount = 0;
|
||||
int _pinchTotalCount = 0;
|
||||
int _firstInteractionDelayMs = -1;
|
||||
|
||||
void reset({String sid = ''}) {
|
||||
_sid = sid;
|
||||
_pageStartedAt = _nowProvider();
|
||||
_lastScrollTop = null;
|
||||
_lastScrollProcessedAt = null;
|
||||
_lastTouchStartAt = null;
|
||||
_lastSwipeAt = null;
|
||||
_lastLongPressAt = null;
|
||||
_lastPinchAt = null;
|
||||
_lastScrollDirection = 0;
|
||||
|
||||
_scrollTotalCount = 0;
|
||||
_scrollTotalDistancePx = 0;
|
||||
_scrollUpCount = 0;
|
||||
_scrollDownCount = 0;
|
||||
_scrollDirectionChangeCount = 0;
|
||||
_firstScrollDelayMs = -1;
|
||||
_maxScrollDepthPercent = -1;
|
||||
_isReachBottom = -1;
|
||||
_contentHeight = -1;
|
||||
_touchTotalCount = 0;
|
||||
_swipeTotalCount = 0;
|
||||
_longPressTotalCount = 0;
|
||||
_pinchTotalCount = 0;
|
||||
_firstInteractionDelayMs = -1;
|
||||
}
|
||||
|
||||
void recordAcceptedEvent(Map<String, dynamic> event) {
|
||||
final eventName = event['event']?.toString() ?? '';
|
||||
if (eventName.isEmpty || eventName == 'app_session_summary') return;
|
||||
|
||||
final sid = event['sid']?.toString() ?? '';
|
||||
if (sid != _sid) {
|
||||
reset(sid: sid);
|
||||
}
|
||||
|
||||
if (eventName == 'app_page_view') {
|
||||
recordPageEnter(_pageEnterTime(event) ?? _nowProvider());
|
||||
}
|
||||
}
|
||||
|
||||
void recordPageEnter(DateTime enteredAt) {
|
||||
_pageStartedAt = enteredAt;
|
||||
_lastScrollTop = null;
|
||||
_lastScrollProcessedAt = null;
|
||||
_lastScrollDirection = 0;
|
||||
}
|
||||
|
||||
void recordScroll({
|
||||
required int scrollTop,
|
||||
required int viewportHeight,
|
||||
required int contentHeight,
|
||||
DateTime? at,
|
||||
}) {
|
||||
final observedAt = at ?? _nowProvider();
|
||||
final normalizedScrollTop = _nonNegative(scrollTop);
|
||||
final normalizedViewportHeight = _nonNegative(viewportHeight);
|
||||
final normalizedContentHeight = _nonNegative(contentHeight);
|
||||
|
||||
_updateScrollExtent(
|
||||
scrollTop: normalizedScrollTop,
|
||||
viewportHeight: normalizedViewportHeight,
|
||||
contentHeight: normalizedContentHeight,
|
||||
);
|
||||
|
||||
final previousScrollTop = _lastScrollTop;
|
||||
if (previousScrollTop == null) {
|
||||
_lastScrollTop = normalizedScrollTop;
|
||||
_lastScrollProcessedAt = observedAt;
|
||||
return;
|
||||
}
|
||||
|
||||
final previousProcessedAt = _lastScrollProcessedAt;
|
||||
if (previousProcessedAt != null &&
|
||||
observedAt.difference(previousProcessedAt) < _scrollMinInterval) {
|
||||
return;
|
||||
}
|
||||
|
||||
final delta = normalizedScrollTop - previousScrollTop;
|
||||
final distance = delta.abs();
|
||||
if (distance < _scrollThresholdPx) {
|
||||
_lastScrollTop = normalizedScrollTop;
|
||||
_lastScrollProcessedAt = observedAt;
|
||||
return;
|
||||
}
|
||||
|
||||
final direction = delta > 0 ? 1 : -1;
|
||||
_scrollTotalCount += 1;
|
||||
_scrollTotalDistancePx += distance;
|
||||
if (direction > 0) {
|
||||
_scrollDownCount += 1;
|
||||
} else {
|
||||
_scrollUpCount += 1;
|
||||
}
|
||||
if (_lastScrollDirection != 0 && _lastScrollDirection != direction) {
|
||||
_scrollDirectionChangeCount += 1;
|
||||
}
|
||||
_lastScrollDirection = direction;
|
||||
_lastScrollTop = normalizedScrollTop;
|
||||
_lastScrollProcessedAt = observedAt;
|
||||
_recordFirstScrollDelay(observedAt);
|
||||
}
|
||||
|
||||
void recordTouchStart({DateTime? at}) {
|
||||
final observedAt = at ?? _nowProvider();
|
||||
if (_isDuplicate(_lastTouchStartAt, observedAt)) return;
|
||||
_lastTouchStartAt = observedAt;
|
||||
_touchTotalCount += 1;
|
||||
_recordFirstInteractionDelay(observedAt);
|
||||
}
|
||||
|
||||
void recordSwipe({DateTime? at}) {
|
||||
final observedAt = at ?? _nowProvider();
|
||||
if (_isDuplicate(_lastSwipeAt, observedAt)) return;
|
||||
_lastSwipeAt = observedAt;
|
||||
_swipeTotalCount += 1;
|
||||
_recordFirstInteractionDelay(observedAt);
|
||||
}
|
||||
|
||||
void recordLongPress({DateTime? at}) {
|
||||
final observedAt = at ?? _nowProvider();
|
||||
if (_isDuplicate(_lastLongPressAt, observedAt)) return;
|
||||
_lastLongPressAt = observedAt;
|
||||
_longPressTotalCount += 1;
|
||||
_recordFirstInteractionDelay(observedAt);
|
||||
}
|
||||
|
||||
void recordPinch({DateTime? at}) {
|
||||
final observedAt = at ?? _nowProvider();
|
||||
if (_isDuplicate(_lastPinchAt, observedAt)) return;
|
||||
_lastPinchAt = observedAt;
|
||||
_pinchTotalCount += 1;
|
||||
_recordFirstInteractionDelay(observedAt);
|
||||
}
|
||||
|
||||
SessionBehaviorSnapshot snapshot() {
|
||||
return SessionBehaviorSnapshot(
|
||||
scrollTotalCount: _scrollTotalCount,
|
||||
scrollTotalDistancePx: _scrollTotalDistancePx,
|
||||
scrollUpCount: _scrollUpCount,
|
||||
scrollDownCount: _scrollDownCount,
|
||||
scrollDirectionChangeCount: _scrollDirectionChangeCount,
|
||||
firstScrollDelayMs: _firstScrollDelayMs,
|
||||
maxScrollDepthPercent: _maxScrollDepthPercent,
|
||||
isReachBottom: _isReachBottom,
|
||||
contentHeight: _contentHeight,
|
||||
touchTotalCount: _touchTotalCount,
|
||||
swipeTotalCount: _swipeTotalCount,
|
||||
longPressTotalCount: _longPressTotalCount,
|
||||
pinchTotalCount: _pinchTotalCount,
|
||||
firstInteractionDelayMs: _firstInteractionDelayMs,
|
||||
);
|
||||
}
|
||||
|
||||
void _updateScrollExtent({
|
||||
required int scrollTop,
|
||||
required int viewportHeight,
|
||||
required int contentHeight,
|
||||
}) {
|
||||
if (contentHeight <= 0 || viewportHeight <= 0) return;
|
||||
_contentHeight = contentHeight;
|
||||
final depth = ((scrollTop + viewportHeight) / contentHeight * 100)
|
||||
.round()
|
||||
.clamp(0, 100);
|
||||
if (_maxScrollDepthPercent < 0 || depth > _maxScrollDepthPercent) {
|
||||
_maxScrollDepthPercent = depth;
|
||||
}
|
||||
if (scrollTop + viewportHeight >= contentHeight - _bottomTolerancePx) {
|
||||
_isReachBottom = 1;
|
||||
} else if (_isReachBottom < 0) {
|
||||
_isReachBottom = 0;
|
||||
}
|
||||
}
|
||||
|
||||
void _recordFirstScrollDelay(DateTime at) {
|
||||
if (_firstScrollDelayMs >= 0) return;
|
||||
_firstScrollDelayMs = _delaySincePageStart(at);
|
||||
}
|
||||
|
||||
void _recordFirstInteractionDelay(DateTime at) {
|
||||
if (_firstInteractionDelayMs >= 0) return;
|
||||
_firstInteractionDelayMs = _delaySincePageStart(at);
|
||||
}
|
||||
|
||||
int _delaySincePageStart(DateTime at) {
|
||||
final pageStartedAt = _pageStartedAt;
|
||||
if (pageStartedAt == null) return -1;
|
||||
final delay = at.difference(pageStartedAt).inMilliseconds;
|
||||
return delay < 0 ? 0 : delay;
|
||||
}
|
||||
|
||||
DateTime? _pageEnterTime(Map<String, dynamic> event) {
|
||||
final payload = event['payload'];
|
||||
if (payload is! Map) return null;
|
||||
final value = payload['page_enter_ts'];
|
||||
if (value is int) {
|
||||
return DateTime.fromMillisecondsSinceEpoch(value * 1000);
|
||||
}
|
||||
if (value is num) {
|
||||
return DateTime.fromMillisecondsSinceEpoch(value.round() * 1000);
|
||||
}
|
||||
final parsed = int.tryParse(value?.toString() ?? '');
|
||||
if (parsed == null) return null;
|
||||
return DateTime.fromMillisecondsSinceEpoch(parsed * 1000);
|
||||
}
|
||||
|
||||
bool _isDuplicate(DateTime? previous, DateTime current) {
|
||||
if (previous == null) return false;
|
||||
final diffMs = current.difference(previous).inMilliseconds.abs();
|
||||
return diffMs < _touchDedupWindow.inMilliseconds;
|
||||
}
|
||||
|
||||
int _nonNegative(int value) => value < 0 ? 0 : value;
|
||||
}
|
||||
|
||||
class SessionBehaviorSnapshot {
|
||||
final int scrollTotalCount;
|
||||
final int scrollTotalDistancePx;
|
||||
final int scrollUpCount;
|
||||
final int scrollDownCount;
|
||||
final int scrollDirectionChangeCount;
|
||||
final int firstScrollDelayMs;
|
||||
final int maxScrollDepthPercent;
|
||||
final int isReachBottom;
|
||||
final int contentHeight;
|
||||
final int touchTotalCount;
|
||||
final int swipeTotalCount;
|
||||
final int longPressTotalCount;
|
||||
final int pinchTotalCount;
|
||||
final int firstInteractionDelayMs;
|
||||
|
||||
const SessionBehaviorSnapshot({
|
||||
required this.scrollTotalCount,
|
||||
required this.scrollTotalDistancePx,
|
||||
required this.scrollUpCount,
|
||||
required this.scrollDownCount,
|
||||
required this.scrollDirectionChangeCount,
|
||||
required this.firstScrollDelayMs,
|
||||
required this.maxScrollDepthPercent,
|
||||
required this.isReachBottom,
|
||||
required this.contentHeight,
|
||||
required this.touchTotalCount,
|
||||
required this.swipeTotalCount,
|
||||
required this.longPressTotalCount,
|
||||
required this.pinchTotalCount,
|
||||
required this.firstInteractionDelayMs,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export 'session_behavior_monitor_stub.dart'
|
||||
if (dart.library.html) 'session_behavior_monitor_web.dart';
|
||||
@@ -0,0 +1,10 @@
|
||||
class SessionBehaviorMonitor {
|
||||
static final SessionBehaviorMonitor instance =
|
||||
SessionBehaviorMonitor._internal();
|
||||
|
||||
SessionBehaviorMonitor._internal();
|
||||
|
||||
void start() {}
|
||||
|
||||
void dispose() {}
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
// ignore: avoid_web_libraries_in_flutter
|
||||
import 'dart:async';
|
||||
import 'dart:html' as html;
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:analytics_sdk/manager/session_behavior_manager.dart';
|
||||
|
||||
class SessionBehaviorMonitor {
|
||||
static final SessionBehaviorMonitor instance =
|
||||
SessionBehaviorMonitor._internal();
|
||||
|
||||
SessionBehaviorMonitor._internal();
|
||||
|
||||
static const int _swipeThresholdPx = 50;
|
||||
static const int _pinchThresholdPx = 20;
|
||||
static const int _longPressMoveTolerancePx = 10;
|
||||
static const _longPressDuration = Duration(milliseconds: 500);
|
||||
|
||||
final List<StreamSubscription<dynamic>> _subscriptions = [];
|
||||
final Map<int, _TouchStart> _touchStarts = <int, _TouchStart>{};
|
||||
final Map<int, math.Point<num>> _activeTouches = <int, math.Point<num>>{};
|
||||
|
||||
double? _pinchStartDistance;
|
||||
bool _pinchRecorded = false;
|
||||
bool _started = false;
|
||||
|
||||
void start() {
|
||||
if (_started) return;
|
||||
_started = true;
|
||||
_subscriptions
|
||||
..add(html.window.onScroll.listen(_handleScroll))
|
||||
..add(html.document.onTouchStart.listen(_handleTouchStart))
|
||||
..add(html.document.onTouchMove.listen(_handleTouchMove))
|
||||
..add(html.document.onTouchEnd.listen(_handleTouchEnd))
|
||||
..add(html.document.onTouchCancel.listen(_handleTouchCancel));
|
||||
_handleScroll(null);
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
for (final subscription in _subscriptions) {
|
||||
subscription.cancel();
|
||||
}
|
||||
_subscriptions.clear();
|
||||
_touchStarts.clear();
|
||||
_activeTouches.clear();
|
||||
_pinchStartDistance = null;
|
||||
_pinchRecorded = false;
|
||||
_started = false;
|
||||
}
|
||||
|
||||
void _handleScroll(Object? _) {
|
||||
try {
|
||||
final element = html.document.scrollingElement ??
|
||||
html.document.documentElement ??
|
||||
html.document.body;
|
||||
final viewportHeight = html.window.innerHeight ?? element?.clientHeight;
|
||||
final contentHeight = element?.scrollHeight;
|
||||
final scrollTop = element?.scrollTop ?? html.window.pageYOffset.round();
|
||||
if (viewportHeight == null || contentHeight == null) return;
|
||||
SessionBehaviorManager.instance.recordScroll(
|
||||
scrollTop: scrollTop,
|
||||
viewportHeight: viewportHeight,
|
||||
contentHeight: contentHeight,
|
||||
);
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
void _handleTouchStart(html.TouchEvent event) {
|
||||
try {
|
||||
final touches = event.touches;
|
||||
final changedTouches = event.changedTouches;
|
||||
if (touches == null || changedTouches == null) return;
|
||||
|
||||
final now = DateTime.now();
|
||||
if (touches.length == 1) {
|
||||
SessionBehaviorManager.instance.recordTouchStart(at: now);
|
||||
}
|
||||
|
||||
for (final touch in changedTouches) {
|
||||
final identifier = touch.identifier ?? 0;
|
||||
final point = touch.client;
|
||||
_touchStarts[identifier] = _TouchStart(point: point, startedAt: now);
|
||||
_activeTouches[identifier] = point;
|
||||
}
|
||||
_updatePinchBaseline();
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
void _handleTouchMove(html.TouchEvent event) {
|
||||
try {
|
||||
final touches = event.touches;
|
||||
if (touches == null) return;
|
||||
for (final touch in touches) {
|
||||
_activeTouches[touch.identifier ?? 0] = touch.client;
|
||||
}
|
||||
if (_activeTouches.length < 2) return;
|
||||
final distance = _firstTwoTouchDistance();
|
||||
if (distance == null) return;
|
||||
_pinchStartDistance ??= distance;
|
||||
if (!_pinchRecorded &&
|
||||
(distance - _pinchStartDistance!).abs() >= _pinchThresholdPx) {
|
||||
_pinchRecorded = true;
|
||||
SessionBehaviorManager.instance.recordPinch();
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
void _handleTouchEnd(html.TouchEvent event) {
|
||||
try {
|
||||
final changedTouches = event.changedTouches;
|
||||
if (changedTouches == null) return;
|
||||
final now = DateTime.now();
|
||||
final wasSingleTouch = _activeTouches.length == 1;
|
||||
for (final touch in changedTouches) {
|
||||
final identifier = touch.identifier ?? 0;
|
||||
final start = _touchStarts[identifier];
|
||||
if (start != null && wasSingleTouch) {
|
||||
_recordFinishedSingleTouch(
|
||||
start: start,
|
||||
endPoint: touch.client,
|
||||
endedAt: now,
|
||||
);
|
||||
}
|
||||
_touchStarts.remove(identifier);
|
||||
_activeTouches.remove(identifier);
|
||||
}
|
||||
_updatePinchBaseline();
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
void _handleTouchCancel(html.TouchEvent event) {
|
||||
try {
|
||||
final changedTouches = event.changedTouches;
|
||||
if (changedTouches == null) return;
|
||||
for (final touch in changedTouches) {
|
||||
final identifier = touch.identifier ?? 0;
|
||||
_touchStarts.remove(identifier);
|
||||
_activeTouches.remove(identifier);
|
||||
}
|
||||
_updatePinchBaseline();
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
void _recordFinishedSingleTouch({
|
||||
required _TouchStart start,
|
||||
required math.Point<num> endPoint,
|
||||
required DateTime endedAt,
|
||||
}) {
|
||||
final distance = _distance(start.point, endPoint);
|
||||
if (distance >= _swipeThresholdPx) {
|
||||
SessionBehaviorManager.instance.recordSwipe(at: endedAt);
|
||||
return;
|
||||
}
|
||||
if (endedAt.difference(start.startedAt) >= _longPressDuration &&
|
||||
distance <= _longPressMoveTolerancePx) {
|
||||
SessionBehaviorManager.instance.recordLongPress(at: endedAt);
|
||||
}
|
||||
}
|
||||
|
||||
void _updatePinchBaseline() {
|
||||
if (_activeTouches.length < 2) {
|
||||
_pinchStartDistance = null;
|
||||
_pinchRecorded = false;
|
||||
return;
|
||||
}
|
||||
_pinchStartDistance = _firstTwoTouchDistance();
|
||||
_pinchRecorded = false;
|
||||
}
|
||||
|
||||
double? _firstTwoTouchDistance() {
|
||||
if (_activeTouches.length < 2) return null;
|
||||
final iterator = _activeTouches.values.iterator;
|
||||
iterator.moveNext();
|
||||
final first = iterator.current;
|
||||
iterator.moveNext();
|
||||
final second = iterator.current;
|
||||
return _distance(first, second);
|
||||
}
|
||||
|
||||
double _distance(math.Point<num> a, math.Point<num> b) {
|
||||
final dx = a.x - b.x;
|
||||
final dy = a.y - b.y;
|
||||
return math.sqrt(dx * dx + dy * dy);
|
||||
}
|
||||
}
|
||||
|
||||
class _TouchStart {
|
||||
final math.Point<num> point;
|
||||
final DateTime startedAt;
|
||||
|
||||
const _TouchStart({
|
||||
required this.point,
|
||||
required this.startedAt,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:analytics_sdk/utils/logger.dart';
|
||||
import 'package:analytics_sdk/utils/uuid_util.dart';
|
||||
|
||||
/// 会话管理器:管理会话ID(sid)的生成和更新
|
||||
///
|
||||
/// 会话定义:用户在一次连续、自然的产品使用过程中产生的一组行为集合
|
||||
///
|
||||
/// **sid 说明**:sid 由客户端 SDK 自动生成,每个事件均会携带。
|
||||
/// 服务端上报事件时,需通过 [AnalyticsSdk.getParams] 获取当前 sid 并一同回传,
|
||||
/// 以便服务端事件与客户端事件归属同一会话。
|
||||
///
|
||||
/// 会话中断阈值:30 分钟无任何有效行为,生成新会话ID
|
||||
///
|
||||
/// APP产品定义方案,任意满足一条就重新生成会话ID:
|
||||
/// 1. App 冷启动(进程首次启动)
|
||||
/// 2. App 从后台切换前台,且后台停留 > 30 分钟
|
||||
/// 3. 上一次行为距离当前 > 30 分钟
|
||||
class SessionManager {
|
||||
static final SessionManager instance = SessionManager._internal();
|
||||
|
||||
factory SessionManager() => instance;
|
||||
|
||||
SessionManager._internal();
|
||||
|
||||
/// 当前会话ID
|
||||
String? _currentSessionId;
|
||||
|
||||
/// 会话创建时间
|
||||
DateTime? _sessionStartTime;
|
||||
|
||||
/// 最后一次行为时间
|
||||
DateTime? _lastActivityTime;
|
||||
|
||||
/// 应用进入后台的时间
|
||||
DateTime? _backgroundTime;
|
||||
|
||||
/// 当前正在通知结束的旧 session 逻辑结束时间。
|
||||
DateTime? _notifyingSessionEndTime;
|
||||
|
||||
/// 会话中断阈值(30分钟)
|
||||
static const Duration sessionTimeout = Duration(minutes: 30);
|
||||
|
||||
static const Set<String> _effectiveLifecycleKeys = {
|
||||
'cold_start',
|
||||
'web_load',
|
||||
'warm_start',
|
||||
'foreground',
|
||||
};
|
||||
|
||||
static const Set<String> _nonBusinessActivityEvents = {
|
||||
'sdk_init',
|
||||
'network_state_change',
|
||||
'app_session_summary',
|
||||
'navigation',
|
||||
'page_lifecycle',
|
||||
};
|
||||
|
||||
/// 是否已初始化(用于判断冷启动)
|
||||
bool _isInitialized = false;
|
||||
|
||||
/// 新 session 创建时的回调(由外部注入,避免直接依赖 AdImpressionManager)
|
||||
/// 通常由 AnalyticsSdk.init() 注入:`() => AdImpressionManager.instance.clear()`
|
||||
void Function()? onNewSession;
|
||||
|
||||
/// 旧 session 即将结束时的回调。
|
||||
///
|
||||
/// 在新 sid 生成前调用,使调用方仍能用旧 sid 补发 app_session_summary。
|
||||
void Function(String sessionEndReason)? onSessionEnding;
|
||||
|
||||
/// 获取当前会话ID
|
||||
/// 如果当前没有会话或会话已过期,会自动创建新会话
|
||||
String getSessionId() {
|
||||
final now = DateTime.now();
|
||||
|
||||
// 检查是否需要生成新会话
|
||||
// 注意:这里只检查和创建会话,不更新 _lastActivityTime
|
||||
// 调用方应通过 recordActivity() 显式记录行为时间,避免非行为调用(如 debug 面板)误延长会话
|
||||
if (_shouldCreateNewSession(now)) {
|
||||
_createNewSession(now);
|
||||
}
|
||||
|
||||
// 安全检查:确保会话ID不为null(理论上不会发生,但作为保护措施)
|
||||
if (_currentSessionId == null) {
|
||||
Logger.analyticsSdk('警告:会话ID为null,强制创建新会话', level: LogLevel.warn);
|
||||
_createNewSession(now);
|
||||
}
|
||||
|
||||
return _currentSessionId!;
|
||||
}
|
||||
|
||||
/// 检查是否需要创建新会话
|
||||
bool _shouldCreateNewSession(DateTime now) {
|
||||
// 1. 如果还没有会话ID,需要创建
|
||||
if (_currentSessionId == null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 2. 如果还未初始化,说明是冷启动,需要创建新会话
|
||||
if (!_isInitialized) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 3. 如果从后台恢复,且后台停留时间 > 30 分钟
|
||||
// 注意:这里只在超时时清空 _backgroundTime(并返回 true 触发新会话)。
|
||||
// 非超时分支不清空,保留给 onAppForeground() 统一处理,
|
||||
// 避免后台期间有代码调用 getSessionId()(如推送通知触发 track())时
|
||||
// 提前清空导致 onAppForeground() 无法正确判断后台时长。
|
||||
if (_backgroundTime != null) {
|
||||
final backgroundDuration = now.difference(_backgroundTime!);
|
||||
if (backgroundDuration > sessionTimeout) {
|
||||
Logger.analyticsSdk(
|
||||
'后台停留时间 ${backgroundDuration.inMinutes} 分钟,超过阈值,创建新会话');
|
||||
_backgroundTime = null;
|
||||
return true;
|
||||
}
|
||||
// 未超时:不清空 _backgroundTime,由 onAppForeground() 负责清空
|
||||
}
|
||||
|
||||
// 4. 如果上一次行为距离当前 > 30 分钟
|
||||
if (_lastActivityTime != null) {
|
||||
final timeSinceLastActivity = now.difference(_lastActivityTime!);
|
||||
if (timeSinceLastActivity > sessionTimeout) {
|
||||
Logger.analyticsSdk(
|
||||
'距离上次行为 ${timeSinceLastActivity.inMinutes} 分钟,超过阈值,创建新会话');
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// 创建新会话
|
||||
void _createNewSession(DateTime now) {
|
||||
try {
|
||||
_notifySessionEndingIfNeeded('idle');
|
||||
_currentSessionId = generateUuidV4().replaceAll("-", "");
|
||||
_sessionStartTime = now;
|
||||
_lastActivityTime = now;
|
||||
_isInitialized = true;
|
||||
// 新 session 开始时通知外部清理广告去重状态
|
||||
onNewSession?.call();
|
||||
Logger.analyticsSdk('创建新会话ID: $_currentSessionId');
|
||||
} catch (e) {
|
||||
// UUID 生成失败时使用时间戳作为备选方案
|
||||
final timestamp = now.millisecondsSinceEpoch;
|
||||
_currentSessionId = 'session_${timestamp}_${now.microsecondsSinceEpoch}';
|
||||
_sessionStartTime = now;
|
||||
_lastActivityTime = now;
|
||||
_isInitialized = true;
|
||||
onNewSession?.call();
|
||||
Logger.analyticsSdk('UUID 生成失败,使用时间戳创建会话ID: $_currentSessionId');
|
||||
}
|
||||
}
|
||||
|
||||
/// 初始化会话管理器
|
||||
/// 在 SDK 初始化时调用,标记为冷启动
|
||||
void initialize() {
|
||||
if (!_isInitialized) {
|
||||
final now = DateTime.now();
|
||||
_createNewSession(now);
|
||||
Logger.analyticsSdk('会话管理器初始化(冷启动),创建新会话');
|
||||
}
|
||||
}
|
||||
|
||||
/// 应用进入后台
|
||||
/// 在应用生命周期变为后台时调用
|
||||
void onAppBackground() {
|
||||
_backgroundTime = DateTime.now();
|
||||
Logger.analyticsSdk('应用进入后台,记录时间: $_backgroundTime');
|
||||
}
|
||||
|
||||
/// 应用从后台恢复
|
||||
/// 在应用生命周期变为前台时调用
|
||||
void onAppForeground() {
|
||||
final now = DateTime.now();
|
||||
|
||||
// 如果从后台恢复,检查是否需要创建新会话
|
||||
if (_backgroundTime != null) {
|
||||
final backgroundDuration = now.difference(_backgroundTime!);
|
||||
if (backgroundDuration > sessionTimeout) {
|
||||
Logger.analyticsSdk(
|
||||
'从后台恢复,后台停留 ${backgroundDuration.inMinutes} 分钟,超过阈值,创建新会话');
|
||||
_createNewSession(now);
|
||||
} else {
|
||||
Logger.analyticsSdk(
|
||||
'从后台恢复,后台停留 ${backgroundDuration.inMinutes} 分钟,继续使用当前会话');
|
||||
// 更新最后行为时间,表示用户重新活跃
|
||||
_lastActivityTime = now;
|
||||
}
|
||||
_backgroundTime = null; // 清空后台时间
|
||||
} else {
|
||||
// 如果没有后台时间记录,直接更新最后行为时间
|
||||
_lastActivityTime = now;
|
||||
}
|
||||
}
|
||||
|
||||
/// 记录用户行为
|
||||
/// 每次生成事件时调用,用于更新最后行为时间
|
||||
void recordActivity() {
|
||||
_lastActivityTime = DateTime.now();
|
||||
}
|
||||
|
||||
/// 仅在成功进入 SDK 队列且属于有效行为时更新最后行为时间。
|
||||
void recordActivityForEvent(Map<String, dynamic> event, {DateTime? at}) {
|
||||
if (!_isEffectiveActivityEvent(event)) return;
|
||||
_lastActivityTime = at ?? DateTime.now();
|
||||
}
|
||||
|
||||
/// 获取当前会话ID(不触发新会话创建)
|
||||
String? get currentSessionId => _currentSessionId;
|
||||
|
||||
/// 获取会话开始时间
|
||||
DateTime? get sessionStartTime => _sessionStartTime;
|
||||
|
||||
/// 获取最后行为时间
|
||||
DateTime? get lastActivityTime => _lastActivityTime;
|
||||
|
||||
/// 当前 session 结束回调内可读取的逻辑结束时间。
|
||||
DateTime? get sessionEndTimeForNotification => _notifyingSessionEndTime;
|
||||
|
||||
/// 仅供测试使用:获取后台时间(验证 _shouldCreateNewSession 不提前清空)
|
||||
@visibleForTesting
|
||||
DateTime? get backgroundTimeForTest => _backgroundTime;
|
||||
|
||||
@visibleForTesting
|
||||
void setBackgroundTimeForTest(DateTime? value) {
|
||||
_backgroundTime = value;
|
||||
}
|
||||
|
||||
@visibleForTesting
|
||||
DateTime? get sessionEndTimeForNotificationForTest =>
|
||||
_notifyingSessionEndTime;
|
||||
|
||||
@visibleForTesting
|
||||
void setLastActivityTimeForTest(DateTime? value) {
|
||||
_lastActivityTime = value;
|
||||
}
|
||||
|
||||
@visibleForTesting
|
||||
void setSessionStartTimeForTest(DateTime? value) {
|
||||
_sessionStartTime = value;
|
||||
}
|
||||
|
||||
/// QA 调试用:把后台停留时间虚拟为指定时长,便于手工触发 idle summary。
|
||||
///
|
||||
/// Release 包中不生效,避免接入方误用影响真实 session 规则。
|
||||
bool debugSetBackgroundElapsedForQa(Duration elapsed) {
|
||||
if (kReleaseMode) return false;
|
||||
if (elapsed <= Duration.zero) return false;
|
||||
final now = DateTime.now();
|
||||
_backgroundTime = now.subtract(elapsed);
|
||||
_lastActivityTime = now.subtract(elapsed);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// 手动重置会话(用于测试或特殊场景)
|
||||
void reset() {
|
||||
_currentSessionId = null;
|
||||
_sessionStartTime = null;
|
||||
_lastActivityTime = null;
|
||||
_backgroundTime = null;
|
||||
_notifyingSessionEndTime = null;
|
||||
_isInitialized = false;
|
||||
Logger.analyticsSdk('会话已重置');
|
||||
}
|
||||
|
||||
void _notifySessionEndingIfNeeded(String reason) {
|
||||
if (_currentSessionId == null || !_isInitialized) return;
|
||||
final previousEndTime = _notifyingSessionEndTime;
|
||||
_notifyingSessionEndTime = _logicalSessionEndTime(
|
||||
reason: reason,
|
||||
now: DateTime.now(),
|
||||
);
|
||||
try {
|
||||
onSessionEnding?.call(reason);
|
||||
} catch (e) {
|
||||
Logger.analyticsSdk('旧 session 结束回调执行失败: $e', level: LogLevel.warn);
|
||||
} finally {
|
||||
_notifyingSessionEndTime = previousEndTime;
|
||||
}
|
||||
}
|
||||
|
||||
DateTime _logicalSessionEndTime({
|
||||
required String reason,
|
||||
required DateTime now,
|
||||
}) {
|
||||
if (reason != 'idle') return now;
|
||||
final sessionStart = _sessionStartTime ?? now;
|
||||
final rawLastActivity = _lastActivityTime ?? sessionStart;
|
||||
final lastActivity =
|
||||
rawLastActivity.isBefore(sessionStart) ? sessionStart : rawLastActivity;
|
||||
final logicalEnd = lastActivity.add(sessionTimeout);
|
||||
return logicalEnd.isAfter(now) ? now : logicalEnd;
|
||||
}
|
||||
|
||||
bool _isEffectiveActivityEvent(Map<String, dynamic> event) {
|
||||
final eventName = event['event']?.toString() ?? '';
|
||||
if (eventName.isEmpty) return false;
|
||||
if (eventName == 'app_lifecycle') {
|
||||
final key = _lifecycleEventKey(event);
|
||||
return _effectiveLifecycleKeys.contains(key);
|
||||
}
|
||||
if (_nonBusinessActivityEvents.contains(eventName)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
String _lifecycleEventKey(Map<String, dynamic> event) {
|
||||
final payload = event['payload'];
|
||||
if (payload is Map) {
|
||||
final value = payload['lifecycle_event_key']?.toString().trim() ?? '';
|
||||
if (value.isNotEmpty) return value;
|
||||
}
|
||||
return event['lifecycle_event_key']?.toString().trim() ?? '';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:crypto/crypto.dart';
|
||||
|
||||
/// Session 内自动汇总指标。
|
||||
///
|
||||
/// 只记录成功进入 SDK 队列的事件。这里不采集滚动轨迹、键盘输入内容、
|
||||
/// 长按、双指缩放等高风险/不稳定指标,避免为了汇总事件扩大监听面。
|
||||
class SessionMetricsManager {
|
||||
static final SessionMetricsManager instance =
|
||||
SessionMetricsManager._internal();
|
||||
|
||||
factory SessionMetricsManager() => instance;
|
||||
|
||||
SessionMetricsManager._internal();
|
||||
|
||||
static const int _maxBehaviorSequence = 200;
|
||||
static const int _maxPageStayRecords = 100;
|
||||
|
||||
static const Set<String> _nonBusinessEvents = {
|
||||
'sdk_init',
|
||||
'app_lifecycle',
|
||||
'network_state_change',
|
||||
'app_session_summary',
|
||||
'app_page_view',
|
||||
'page_click',
|
||||
'navigation',
|
||||
'page_lifecycle',
|
||||
};
|
||||
|
||||
String _sid = '';
|
||||
int _eventCountTotal = 0;
|
||||
int _businessEventCount = 0;
|
||||
int _foregroundCount = 0;
|
||||
int _backgroundCountInSid = 0;
|
||||
int _pageViewCount = 0;
|
||||
int _pageClickCount = 0;
|
||||
int _networkChangeCount = 0;
|
||||
String _lastEventName = '';
|
||||
final Set<String> _pageKeys = <String>{};
|
||||
final Set<String> _pagesWithInteraction = <String>{};
|
||||
final List<String> _behaviorSequence = <String>[];
|
||||
final List<_PageStayRecord> _activePageStays = <_PageStayRecord>[];
|
||||
final List<_PageStayRecord> _completedPageStays = <_PageStayRecord>[];
|
||||
|
||||
void recordAcceptedEvent(Map<String, dynamic> event) {
|
||||
final eventName = event['event']?.toString() ?? '';
|
||||
if (eventName.isEmpty || eventName == 'app_session_summary') return;
|
||||
|
||||
final sid = event['sid']?.toString() ?? '';
|
||||
if (sid != _sid) {
|
||||
reset(sid: sid);
|
||||
}
|
||||
|
||||
_eventCountTotal += 1;
|
||||
_lastEventName = eventName;
|
||||
_recordBehaviorSequence(eventName, _payloadPageKey(event));
|
||||
|
||||
if (!_nonBusinessEvents.contains(eventName)) {
|
||||
_businessEventCount += 1;
|
||||
}
|
||||
|
||||
if (eventName == 'app_page_view') {
|
||||
_pageViewCount += 1;
|
||||
_recordPageKey(event);
|
||||
} else if (eventName == 'page_click') {
|
||||
_pageClickCount += 1;
|
||||
_recordPageKey(event);
|
||||
final pageKey = _payloadPageKey(event);
|
||||
if (pageKey.isNotEmpty) _pagesWithInteraction.add(pageKey);
|
||||
} else if (eventName == 'app_lifecycle') {
|
||||
final key = _payloadString(event, 'lifecycle_event_key',
|
||||
fallback: event['lifecycle_event_key']);
|
||||
if (key == 'foreground') {
|
||||
_foregroundCount += 1;
|
||||
} else if (key == 'background') {
|
||||
_backgroundCountInSid += 1;
|
||||
}
|
||||
} else if (eventName == 'network_state_change') {
|
||||
_networkChangeCount += 1;
|
||||
}
|
||||
}
|
||||
|
||||
void recordPageEnter({
|
||||
required String pageKey,
|
||||
required String pageName,
|
||||
required DateTime enterTime,
|
||||
}) {
|
||||
final normalizedPageKey = pageKey.trim();
|
||||
if (normalizedPageKey.isEmpty) return;
|
||||
_pageKeys.add(normalizedPageKey);
|
||||
_closeActivePageStays(exitTime: enterTime);
|
||||
_activePageStays.add(_PageStayRecord(
|
||||
pageKey: normalizedPageKey,
|
||||
pageName: pageName,
|
||||
enterTime: enterTime,
|
||||
));
|
||||
}
|
||||
|
||||
void recordPageExit({
|
||||
required String pageKey,
|
||||
required String pageName,
|
||||
required DateTime enterTime,
|
||||
required DateTime exitTime,
|
||||
}) {
|
||||
final normalizedPageKey = pageKey.trim();
|
||||
if (normalizedPageKey.isEmpty) return;
|
||||
_pageKeys.add(normalizedPageKey);
|
||||
|
||||
final index = _findActivePageStayIndex(normalizedPageKey, enterTime);
|
||||
if (index < 0) return;
|
||||
final record = _activePageStays.removeAt(index);
|
||||
_completedPageStays.add(record.close(
|
||||
exitTime: exitTime,
|
||||
hasInteraction: _pagesWithInteraction.contains(normalizedPageKey),
|
||||
));
|
||||
_trimCompletedPageStays();
|
||||
}
|
||||
|
||||
SessionMetricsSnapshot snapshot({DateTime? endedAt}) {
|
||||
final now = endedAt ?? DateTime.now();
|
||||
return SessionMetricsSnapshot(
|
||||
eventCountTotal: _eventCountTotal,
|
||||
businessEventCount: _businessEventCount,
|
||||
foregroundCount: _foregroundCount,
|
||||
backgroundCountInSid: _backgroundCountInSid,
|
||||
pageCount: _pageKeys.length,
|
||||
pageViewCount: _pageViewCount,
|
||||
pageClickCount: _pageClickCount,
|
||||
networkChangeCount: _networkChangeCount,
|
||||
sessionSid: _sid,
|
||||
lastEventName: _lastEventName,
|
||||
pageStayList: _pageStayList(now),
|
||||
behaviorSequenceHash: _behaviorSequenceHash(),
|
||||
);
|
||||
}
|
||||
|
||||
void reset({String sid = ''}) {
|
||||
_sid = sid;
|
||||
_eventCountTotal = 0;
|
||||
_businessEventCount = 0;
|
||||
_foregroundCount = 0;
|
||||
_backgroundCountInSid = 0;
|
||||
_pageViewCount = 0;
|
||||
_pageClickCount = 0;
|
||||
_networkChangeCount = 0;
|
||||
_lastEventName = '';
|
||||
_pageKeys.clear();
|
||||
_pagesWithInteraction.clear();
|
||||
_behaviorSequence.clear();
|
||||
_activePageStays.clear();
|
||||
_completedPageStays.clear();
|
||||
}
|
||||
|
||||
void _recordPageKey(Map<String, dynamic> event) {
|
||||
final pageKey = _payloadPageKey(event);
|
||||
if (pageKey.isNotEmpty) _pageKeys.add(pageKey);
|
||||
}
|
||||
|
||||
String _payloadPageKey(Map<String, dynamic> event) {
|
||||
return _payloadString(event, 'page_key');
|
||||
}
|
||||
|
||||
String _payloadString(
|
||||
Map<String, dynamic> event,
|
||||
String key, {
|
||||
Object? fallback,
|
||||
}) {
|
||||
final payload = event['payload'];
|
||||
if (payload is Map) {
|
||||
final value = payload[key]?.toString().trim() ?? '';
|
||||
if (value.isNotEmpty) return value;
|
||||
}
|
||||
return fallback?.toString().trim() ?? '';
|
||||
}
|
||||
|
||||
void _recordBehaviorSequence(String eventName, String pageKey) {
|
||||
final token = pageKey.isEmpty ? eventName : '$eventName:$pageKey';
|
||||
_behaviorSequence.add(token);
|
||||
if (_behaviorSequence.length > _maxBehaviorSequence) {
|
||||
_behaviorSequence.removeAt(0);
|
||||
}
|
||||
}
|
||||
|
||||
String _behaviorSequenceHash() {
|
||||
if (_behaviorSequence.isEmpty) return '';
|
||||
return sha256.convert(utf8.encode(_behaviorSequence.join('|'))).toString();
|
||||
}
|
||||
|
||||
List<Map<String, dynamic>> _pageStayList(DateTime endedAt) {
|
||||
final all = [
|
||||
..._completedPageStays,
|
||||
..._activePageStays.map((record) => record.close(
|
||||
exitTime: endedAt,
|
||||
hasInteraction: _pagesWithInteraction.contains(record.pageKey),
|
||||
)),
|
||||
]..sort((a, b) => a.enterTime.compareTo(b.enterTime));
|
||||
final capped = all.length > _maxPageStayRecords
|
||||
? all.sublist(all.length - _maxPageStayRecords)
|
||||
: all;
|
||||
return capped.map((record) => record.toJson()).toList();
|
||||
}
|
||||
|
||||
void _closeActivePageStays({required DateTime exitTime}) {
|
||||
if (_activePageStays.isEmpty) return;
|
||||
for (final record in _activePageStays) {
|
||||
_completedPageStays.add(record.close(
|
||||
exitTime: exitTime,
|
||||
hasInteraction: _pagesWithInteraction.contains(record.pageKey),
|
||||
));
|
||||
}
|
||||
_activePageStays.clear();
|
||||
_trimCompletedPageStays();
|
||||
}
|
||||
|
||||
int _findActivePageStayIndex(String pageKey, DateTime enterTime) {
|
||||
for (var i = _activePageStays.length - 1; i >= 0; i--) {
|
||||
final record = _activePageStays[i];
|
||||
if (record.pageKey == pageKey && record.enterTime == enterTime) return i;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
void _trimCompletedPageStays() {
|
||||
while (_completedPageStays.length > _maxPageStayRecords) {
|
||||
_completedPageStays.removeAt(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class SessionMetricsSnapshot {
|
||||
final int eventCountTotal;
|
||||
final int businessEventCount;
|
||||
final int foregroundCount;
|
||||
final int backgroundCountInSid;
|
||||
final int pageCount;
|
||||
final int pageViewCount;
|
||||
final int pageClickCount;
|
||||
final int networkChangeCount;
|
||||
final String sessionSid;
|
||||
final String lastEventName;
|
||||
final List<Map<String, dynamic>> pageStayList;
|
||||
final String behaviorSequenceHash;
|
||||
|
||||
const SessionMetricsSnapshot({
|
||||
required this.eventCountTotal,
|
||||
required this.businessEventCount,
|
||||
required this.foregroundCount,
|
||||
required this.backgroundCountInSid,
|
||||
required this.pageCount,
|
||||
required this.pageViewCount,
|
||||
required this.pageClickCount,
|
||||
required this.networkChangeCount,
|
||||
required this.sessionSid,
|
||||
required this.lastEventName,
|
||||
required this.pageStayList,
|
||||
required this.behaviorSequenceHash,
|
||||
});
|
||||
}
|
||||
|
||||
class _PageStayRecord {
|
||||
final String pageKey;
|
||||
final String pageName;
|
||||
final DateTime enterTime;
|
||||
final DateTime? exitTime;
|
||||
final bool hasInteraction;
|
||||
|
||||
const _PageStayRecord({
|
||||
required this.pageKey,
|
||||
required this.pageName,
|
||||
required this.enterTime,
|
||||
this.exitTime,
|
||||
this.hasInteraction = false,
|
||||
});
|
||||
|
||||
_PageStayRecord close({
|
||||
required DateTime exitTime,
|
||||
required bool hasInteraction,
|
||||
}) {
|
||||
return _PageStayRecord(
|
||||
pageKey: pageKey,
|
||||
pageName: pageName,
|
||||
enterTime: enterTime,
|
||||
exitTime: exitTime,
|
||||
hasInteraction: hasInteraction,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final endTime = exitTime ?? DateTime.now();
|
||||
final stayDurationMs = endTime.difference(enterTime).inMilliseconds;
|
||||
return {
|
||||
'page_key': pageKey,
|
||||
'page_name': pageName,
|
||||
'stay_duration_ms': stayDurationMs < 0 ? 0 : stayDurationMs,
|
||||
'enter_time': enterTime.millisecondsSinceEpoch ~/ 1000,
|
||||
'has_interaction': hasInteraction ? 1 : 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export 'session_sensor_proximity_stub.dart'
|
||||
if (dart.library.io) 'session_sensor_proximity_native.dart';
|
||||
@@ -0,0 +1,17 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
const EventChannel _proximityChannel = EventChannel('analytics_sdk/proximity');
|
||||
|
||||
Stream<int> proximityEvents() {
|
||||
if (!Platform.isAndroid && !Platform.isIOS) {
|
||||
return const Stream<int>.empty();
|
||||
}
|
||||
return _proximityChannel.receiveBroadcastStream().map((value) {
|
||||
if (value is bool) return value ? 1 : 0;
|
||||
if (value is num) return value == 0 ? 0 : 1;
|
||||
final text = value?.toString().trim().toLowerCase() ?? '';
|
||||
return text == 'true' || text == '1' ? 1 : 0;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
Stream<int> proximityEvents() => const Stream<int>.empty();
|
||||
@@ -0,0 +1,467 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:analytics_sdk/manager/session_sensor_proximity.dart';
|
||||
import 'package:analytics_sdk/utils/logger.dart';
|
||||
import 'package:crypto/crypto.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:sensors_plus/sensors_plus.dart';
|
||||
|
||||
enum SensorSampleType {
|
||||
accelerometer,
|
||||
gyroscope,
|
||||
magnetometer,
|
||||
proximity,
|
||||
}
|
||||
|
||||
class SensorMagnitudeSample {
|
||||
final SensorSampleType type;
|
||||
final double magnitude;
|
||||
final DateTime sampledAt;
|
||||
|
||||
const SensorMagnitudeSample({
|
||||
required this.type,
|
||||
required this.magnitude,
|
||||
required this.sampledAt,
|
||||
});
|
||||
}
|
||||
|
||||
class SessionSensorSnapshot {
|
||||
final String sensorSignature;
|
||||
final int sensorCountActive;
|
||||
|
||||
const SessionSensorSnapshot({
|
||||
required this.sensorSignature,
|
||||
required this.sensorCountActive,
|
||||
});
|
||||
|
||||
const SessionSensorSnapshot.unsupported()
|
||||
: sensorSignature = '',
|
||||
sensorCountActive = -1;
|
||||
}
|
||||
|
||||
typedef SensorStreamFactory = Stream<SensorMagnitudeSample> Function(
|
||||
SensorSampleType type,
|
||||
Duration samplingPeriod,
|
||||
);
|
||||
|
||||
typedef PlatformSupportResolver = bool Function();
|
||||
|
||||
/// Android/iOS session-level sensor sampler.
|
||||
///
|
||||
/// It only keeps rolling aggregate statistics for the current sid. Raw sensor
|
||||
/// sequences are never retained or exposed.
|
||||
class SessionSensorSampler {
|
||||
static final SessionSensorSampler instance = SessionSensorSampler._internal();
|
||||
|
||||
static const Duration _defaultWindowInterval = Duration(seconds: 60);
|
||||
static const Duration _defaultWindowDuration = Duration(seconds: 5);
|
||||
static const Duration _defaultSamplingPeriod = Duration(milliseconds: 200);
|
||||
static const int _defaultMaxWindowCount = 6;
|
||||
|
||||
final PlatformSupportResolver _isSupportedPlatform;
|
||||
final SensorStreamFactory _sensorStreams;
|
||||
final Duration _windowInterval;
|
||||
final Duration _windowDuration;
|
||||
final Duration _samplingPeriod;
|
||||
final int _maxWindowCount;
|
||||
|
||||
String _sid = '';
|
||||
bool _isForeground = true;
|
||||
bool _hasForegroundPageView = false;
|
||||
bool _samplingStarted = false;
|
||||
bool _samplingUnavailable = false;
|
||||
bool _windowActive = false;
|
||||
int _windowsStarted = 0;
|
||||
Timer? _nextWindowTimer;
|
||||
Timer? _windowStopTimer;
|
||||
final List<StreamSubscription<SensorMagnitudeSample>> _subscriptions =
|
||||
<StreamSubscription<SensorMagnitudeSample>>[];
|
||||
final Map<SensorSampleType, _SensorStats> _stats =
|
||||
<SensorSampleType, _SensorStats>{};
|
||||
|
||||
factory SessionSensorSampler() => instance;
|
||||
|
||||
SessionSensorSampler._internal()
|
||||
: _isSupportedPlatform = _defaultPlatformSupported,
|
||||
_sensorStreams = _defaultSensorStreams,
|
||||
_windowInterval = _defaultWindowInterval,
|
||||
_windowDuration = _defaultWindowDuration,
|
||||
_samplingPeriod = _defaultSamplingPeriod,
|
||||
_maxWindowCount = _defaultMaxWindowCount;
|
||||
|
||||
@visibleForTesting
|
||||
SessionSensorSampler.forTest({
|
||||
required PlatformSupportResolver isSupportedPlatform,
|
||||
required SensorStreamFactory sensorStreams,
|
||||
Duration windowInterval = _defaultWindowInterval,
|
||||
Duration windowDuration = _defaultWindowDuration,
|
||||
Duration samplingPeriod = _defaultSamplingPeriod,
|
||||
int maxWindowCount = _defaultMaxWindowCount,
|
||||
}) : _isSupportedPlatform = isSupportedPlatform,
|
||||
_sensorStreams = sensorStreams,
|
||||
_windowInterval = windowInterval,
|
||||
_windowDuration = windowDuration,
|
||||
_samplingPeriod = samplingPeriod,
|
||||
_maxWindowCount = maxWindowCount;
|
||||
|
||||
void recordAcceptedEvent(Map<String, dynamic> event) {
|
||||
final eventName = event['event']?.toString() ?? '';
|
||||
if (eventName.isEmpty || eventName == 'app_session_summary') return;
|
||||
|
||||
final sid = event['sid']?.toString() ?? '';
|
||||
if (sid != _sid) {
|
||||
reset(sid: sid);
|
||||
}
|
||||
|
||||
if (eventName == 'app_page_view') {
|
||||
_hasForegroundPageView = _isForeground;
|
||||
_ensureSamplingTask();
|
||||
return;
|
||||
}
|
||||
|
||||
if (eventName != 'app_lifecycle') return;
|
||||
final key = _lifecycleEventKey(event);
|
||||
if (key == 'background') {
|
||||
markBackground();
|
||||
} else if (key == 'foreground') {
|
||||
markForeground();
|
||||
}
|
||||
}
|
||||
|
||||
void markForeground() {
|
||||
_isForeground = true;
|
||||
_hasForegroundPageView = false;
|
||||
}
|
||||
|
||||
String _lifecycleEventKey(Map<String, dynamic> event) {
|
||||
final payload = event['payload'];
|
||||
if (payload is Map) {
|
||||
final value = payload['lifecycle_event_key']?.toString().trim() ?? '';
|
||||
if (value.isNotEmpty) return value;
|
||||
}
|
||||
return event['lifecycle_event_key']?.toString().trim() ?? '';
|
||||
}
|
||||
|
||||
void markBackground() {
|
||||
_isForeground = false;
|
||||
_hasForegroundPageView = false;
|
||||
_pauseSamplingTask();
|
||||
}
|
||||
|
||||
void reset({String sid = ''}) {
|
||||
_pauseSamplingTask();
|
||||
_sid = sid;
|
||||
_isForeground = true;
|
||||
_hasForegroundPageView = false;
|
||||
_samplingStarted = false;
|
||||
_samplingUnavailable = false;
|
||||
_windowsStarted = 0;
|
||||
_stats.clear();
|
||||
}
|
||||
|
||||
SessionSensorSnapshot snapshot({int screenBrightness = -1}) {
|
||||
if (_samplingUnavailable || !_samplingStarted) {
|
||||
return const SessionSensorSnapshot.unsupported();
|
||||
}
|
||||
final activeCount = _stats.entries
|
||||
.where((entry) => entry.value.isActive(_activeThreshold(entry.key)))
|
||||
.length;
|
||||
final totalSamples =
|
||||
_stats.values.fold<int>(0, (sum, stats) => sum + stats.count);
|
||||
if (totalSamples <= 0) {
|
||||
return const SessionSensorSnapshot(
|
||||
sensorSignature: '',
|
||||
sensorCountActive: 0,
|
||||
);
|
||||
}
|
||||
if (activeCount <= 0) {
|
||||
return const SessionSensorSnapshot(
|
||||
sensorSignature: '',
|
||||
sensorCountActive: 0,
|
||||
);
|
||||
}
|
||||
final signatureInput = _signatureInput(screenBrightness: screenBrightness);
|
||||
return SessionSensorSnapshot(
|
||||
sensorSignature: sha256.convert(utf8.encode(signatureInput)).toString(),
|
||||
sensorCountActive: activeCount,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> dispose() async {
|
||||
_pauseSamplingTask();
|
||||
_stats.clear();
|
||||
}
|
||||
|
||||
@visibleForTesting
|
||||
void debugMarkSamplingStartedForTest() {
|
||||
_samplingStarted = true;
|
||||
_samplingUnavailable = false;
|
||||
}
|
||||
|
||||
@visibleForTesting
|
||||
void debugRecordSampleForTest(
|
||||
SensorSampleType type, {
|
||||
required double magnitude,
|
||||
required DateTime sampledAt,
|
||||
}) {
|
||||
_recordSample(SensorMagnitudeSample(
|
||||
type: type,
|
||||
magnitude: magnitude,
|
||||
sampledAt: sampledAt,
|
||||
));
|
||||
}
|
||||
|
||||
void _ensureSamplingTask() {
|
||||
if (!_isForeground || !_hasForegroundPageView) return;
|
||||
if (_windowsStarted >= _maxWindowCount) return;
|
||||
if (_windowActive || _nextWindowTimer != null) return;
|
||||
if (!_isSupportedPlatform()) {
|
||||
_samplingUnavailable = true;
|
||||
return;
|
||||
}
|
||||
_startWindow();
|
||||
}
|
||||
|
||||
void _startWindow() {
|
||||
if (!_isForeground || !_hasForegroundPageView) return;
|
||||
if (_windowsStarted >= _maxWindowCount) return;
|
||||
if (!_isSupportedPlatform()) {
|
||||
_samplingUnavailable = true;
|
||||
return;
|
||||
}
|
||||
|
||||
final subscriptions = <StreamSubscription<SensorMagnitudeSample>>[];
|
||||
for (final type in SensorSampleType.values) {
|
||||
try {
|
||||
final subscription = _sensorStreams(type, _samplingPeriod).listen(
|
||||
_recordSample,
|
||||
onError: (Object error) {
|
||||
Logger.analyticsSdk('传感器采样异常($type): $error', level: LogLevel.warn);
|
||||
},
|
||||
cancelOnError: false,
|
||||
);
|
||||
subscriptions.add(subscription);
|
||||
} catch (e) {
|
||||
Logger.analyticsSdk('传感器监听启动失败($type): $e', level: LogLevel.warn);
|
||||
}
|
||||
}
|
||||
|
||||
if (subscriptions.isEmpty) {
|
||||
_samplingUnavailable = true;
|
||||
return;
|
||||
}
|
||||
|
||||
_subscriptions.addAll(subscriptions);
|
||||
_samplingStarted = true;
|
||||
_samplingUnavailable = false;
|
||||
_windowActive = true;
|
||||
_windowsStarted += 1;
|
||||
_windowStopTimer?.cancel();
|
||||
_windowStopTimer = Timer(_windowDuration, _finishWindow);
|
||||
}
|
||||
|
||||
void _finishWindow() {
|
||||
_stopCurrentWindow();
|
||||
if (!_isForeground || !_hasForegroundPageView) return;
|
||||
if (_windowsStarted >= _maxWindowCount) return;
|
||||
_nextWindowTimer?.cancel();
|
||||
_nextWindowTimer = Timer(_windowInterval, () {
|
||||
_nextWindowTimer = null;
|
||||
_startWindow();
|
||||
});
|
||||
}
|
||||
|
||||
void _pauseSamplingTask() {
|
||||
_nextWindowTimer?.cancel();
|
||||
_nextWindowTimer = null;
|
||||
_windowStopTimer?.cancel();
|
||||
_windowStopTimer = null;
|
||||
_stopCurrentWindow();
|
||||
}
|
||||
|
||||
void _stopCurrentWindow() {
|
||||
_windowActive = false;
|
||||
for (final subscription in _subscriptions) {
|
||||
unawaited(subscription.cancel());
|
||||
}
|
||||
_subscriptions.clear();
|
||||
}
|
||||
|
||||
void _recordSample(SensorMagnitudeSample sample) {
|
||||
if (!sample.magnitude.isFinite) return;
|
||||
final stats = _stats.putIfAbsent(sample.type, _SensorStats.new);
|
||||
if (!stats.canRecord(sample.sampledAt, _samplingPeriod)) return;
|
||||
stats.record(sample.magnitude, sample.sampledAt);
|
||||
}
|
||||
|
||||
String _signatureInput({required int screenBrightness}) {
|
||||
final tokens = <String>['sid=$_sid', 'windows=$_windowsStarted'];
|
||||
if (screenBrightness >= 0 && screenBrightness <= 100) {
|
||||
tokens.add('screen_brightness=$screenBrightness');
|
||||
}
|
||||
for (final type in SensorSampleType.values) {
|
||||
final stats = _stats[type];
|
||||
if (stats == null || stats.count == 0) continue;
|
||||
tokens.add('${_typeCode(type)}:${stats.signatureToken}');
|
||||
}
|
||||
return tokens.join('|');
|
||||
}
|
||||
|
||||
static bool _defaultPlatformSupported() {
|
||||
return isSupportedPlatformForTest(
|
||||
isWeb: kIsWeb,
|
||||
platform: defaultTargetPlatform,
|
||||
);
|
||||
}
|
||||
|
||||
@visibleForTesting
|
||||
static bool isSupportedPlatformForTest({
|
||||
required bool isWeb,
|
||||
required TargetPlatform platform,
|
||||
}) {
|
||||
return !isWeb &&
|
||||
(platform == TargetPlatform.android || platform == TargetPlatform.iOS);
|
||||
}
|
||||
|
||||
static Stream<SensorMagnitudeSample> _defaultSensorStreams(
|
||||
SensorSampleType type,
|
||||
Duration samplingPeriod,
|
||||
) {
|
||||
switch (type) {
|
||||
case SensorSampleType.accelerometer:
|
||||
return accelerometerEvents.map(
|
||||
(event) => SensorMagnitudeSample(
|
||||
type: type,
|
||||
magnitude: _magnitude(event.x, event.y, event.z),
|
||||
sampledAt: DateTime.now(),
|
||||
),
|
||||
);
|
||||
case SensorSampleType.gyroscope:
|
||||
return gyroscopeEvents.map(
|
||||
(event) => SensorMagnitudeSample(
|
||||
type: type,
|
||||
magnitude: _magnitude(event.x, event.y, event.z),
|
||||
sampledAt: DateTime.now(),
|
||||
),
|
||||
);
|
||||
case SensorSampleType.magnetometer:
|
||||
return magnetometerEvents.map(
|
||||
(event) => SensorMagnitudeSample(
|
||||
type: type,
|
||||
magnitude: _magnitude(event.x, event.y, event.z),
|
||||
sampledAt: DateTime.now(),
|
||||
),
|
||||
);
|
||||
case SensorSampleType.proximity:
|
||||
return proximityEvents().map(
|
||||
(value) => SensorMagnitudeSample(
|
||||
type: type,
|
||||
magnitude: value == 0 ? 0 : 1,
|
||||
sampledAt: DateTime.now(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
static double _magnitude(double x, double y, double z) {
|
||||
return sqrt(x * x + y * y + z * z);
|
||||
}
|
||||
|
||||
static String _typeCode(SensorSampleType type) {
|
||||
switch (type) {
|
||||
case SensorSampleType.accelerometer:
|
||||
return 'acc';
|
||||
case SensorSampleType.gyroscope:
|
||||
return 'gyro';
|
||||
case SensorSampleType.magnetometer:
|
||||
return 'mag';
|
||||
case SensorSampleType.proximity:
|
||||
return 'prox';
|
||||
}
|
||||
}
|
||||
|
||||
static double _activeThreshold(SensorSampleType type) {
|
||||
switch (type) {
|
||||
case SensorSampleType.accelerometer:
|
||||
return 0.8;
|
||||
case SensorSampleType.gyroscope:
|
||||
return 0.05;
|
||||
case SensorSampleType.magnetometer:
|
||||
return 3.0;
|
||||
case SensorSampleType.proximity:
|
||||
return 1.0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _SensorStats {
|
||||
int count = 0;
|
||||
double mean = 0;
|
||||
double _m2 = 0;
|
||||
double minValue = double.infinity;
|
||||
double maxValue = double.negativeInfinity;
|
||||
DateTime? _lastSampleAt;
|
||||
int _intervalCount = 0;
|
||||
int _totalIntervalMs = 0;
|
||||
|
||||
bool canRecord(DateTime sampledAt, Duration minInterval) {
|
||||
final last = _lastSampleAt;
|
||||
if (last == null) return true;
|
||||
return sampledAt.difference(last) >= minInterval;
|
||||
}
|
||||
|
||||
void record(double value, DateTime sampledAt) {
|
||||
final last = _lastSampleAt;
|
||||
if (last != null) {
|
||||
final intervalMs = sampledAt.difference(last).inMilliseconds;
|
||||
if (intervalMs > 0) {
|
||||
_intervalCount += 1;
|
||||
_totalIntervalMs += intervalMs;
|
||||
}
|
||||
}
|
||||
_lastSampleAt = sampledAt;
|
||||
|
||||
count += 1;
|
||||
final delta = value - mean;
|
||||
mean += delta / count;
|
||||
final delta2 = value - mean;
|
||||
_m2 += delta * delta2;
|
||||
if (value < minValue) minValue = value;
|
||||
if (value > maxValue) maxValue = value;
|
||||
}
|
||||
|
||||
double get variance => count > 1 ? _m2 / (count - 1) : 0;
|
||||
|
||||
double get range {
|
||||
if (count <= 0) return 0;
|
||||
return maxValue - minValue;
|
||||
}
|
||||
|
||||
int get averageIntervalMs {
|
||||
if (_intervalCount <= 0) return 0;
|
||||
return (_totalIntervalMs / _intervalCount).round();
|
||||
}
|
||||
|
||||
bool isActive(double threshold) {
|
||||
return count > 0 && range >= threshold;
|
||||
}
|
||||
|
||||
String get signatureToken {
|
||||
return [
|
||||
'n=$count',
|
||||
'mean=${_fixed(mean)}',
|
||||
'var=${_fixed(variance)}',
|
||||
'min=${_fixed(minValue)}',
|
||||
'max=${_fixed(maxValue)}',
|
||||
'range=${_fixed(range)}',
|
||||
'avg_i=$averageIntervalMs',
|
||||
].join(',');
|
||||
}
|
||||
|
||||
static String _fixed(double value) {
|
||||
if (!value.isFinite) return '0.000';
|
||||
return value.toStringAsFixed(3);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import 'package:analytics_sdk/enum/user_type_enum.dart';
|
||||
|
||||
/// 全局用户管理器
|
||||
/// 负责管理用户类型信息,支持用户类型更新和登出操作
|
||||
class UserManager {
|
||||
static final UserManager instance = UserManager._internal();
|
||||
|
||||
factory UserManager() => instance;
|
||||
|
||||
UserManager._internal();
|
||||
|
||||
/// 当前用户类型:normal、vip 等
|
||||
String _userType = UserTypeEnum.normal.label;
|
||||
|
||||
String get userType => _userType;
|
||||
|
||||
static String normalizeUserType(String? value) {
|
||||
return value?.trim().toLowerCase() == UserTypeEnum.vip.label
|
||||
? UserTypeEnum.vip.label
|
||||
: UserTypeEnum.normal.label;
|
||||
}
|
||||
|
||||
/// 更新用户类型(登录、升级会员、切换账号时调用)
|
||||
void updateUserType(String newType) {
|
||||
final normalized = normalizeUserType(newType);
|
||||
if (_userType != normalized) {
|
||||
_userType = normalized;
|
||||
}
|
||||
}
|
||||
|
||||
/// 登出
|
||||
void logout() {
|
||||
_userType = UserTypeEnum.normal.label; // 或 'guest',根据业务决定
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user