Initial commit: 91dj android client dev_1.0.0

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