289 lines
10 KiB
Dart
289 lines
10 KiB
Dart
import 'dart:async';
|
||
import 'dart:convert';
|
||
|
||
import 'package:adjust_sdk/adjust_attribution.dart';
|
||
import 'package:adjust_sdk/adjust.dart';
|
||
import 'package:flutter/foundation.dart';
|
||
import 'package:play_install_referrer/play_install_referrer.dart';
|
||
|
||
import '../log/app_logger.dart';
|
||
|
||
/// 归因信息服务(优先从 Adjust 获取,fallback 使用 Play Install Referrer)
|
||
class ReferrerService {
|
||
ReferrerService._();
|
||
|
||
static final _log = AppLogger('ReferrerService');
|
||
|
||
static String? _cachedReferrer;
|
||
static String _referrerSource = 'gg';
|
||
static final Completer<String?> _completer = Completer<String?>();
|
||
|
||
/// attribution 回调的 Completer,与 getAttributionWithTimeout 竞速,谁先返回用谁
|
||
static final Completer<AdjustAttribution?> _attributionCallbackCompleter =
|
||
Completer<AdjustAttribution?>();
|
||
|
||
/// 过长会拖慢 [AuthService.init] 内首次 getReferrer;过短可能拿不到 Adjust,仍有 Play Install Referrer fallback
|
||
static const int _adjustTimeoutMs = 4500;
|
||
|
||
/// 与 [Future.any] 外层兜底,避免回调 Future 永不完成时永远挂起
|
||
static const Duration _adjustRaceTimeout = Duration(seconds: 6);
|
||
|
||
/// Play Install Referrer 读取超时(部分机型/系统上可能极慢)
|
||
static const Duration _playInstallReferrerTimeout = Duration(seconds: 10);
|
||
|
||
/// 自然量轮询:每次检查间隔
|
||
static const Duration _organicPollInterval = Duration(seconds: 5);
|
||
|
||
/// 自然量轮询:最长持续时间(达到后无论是否仍是自然量都停止)
|
||
static const Duration _organicPollMaxDuration = Duration(minutes: 5);
|
||
|
||
static bool _organicPollRunning = false;
|
||
static Timer? _organicPollTimer;
|
||
|
||
/// 由 Adjust attributionCallback 调用,首次安装时归因往往通过此回调返回(晚于 getAttributionWithTimeout)
|
||
static void receiveAttributionFromCallback(AdjustAttribution attribution) {
|
||
if (!_attributionCallbackCompleter.isCompleted) {
|
||
_attributionCallbackCompleter.complete(attribution);
|
||
}
|
||
}
|
||
|
||
/// 归因来源:Adjust 为 android_adjust,Play Install Referrer 为 gg
|
||
static String get referrerSource => _referrerSource;
|
||
|
||
/// JSON 可编码的 costAmount:保留字段,非有限数字用字符串避免 encode 抛错
|
||
static Object? _jsonEncodableCostAmount(num? v) {
|
||
if (v == null) return null;
|
||
if (v is double && !v.isFinite) return v.toString();
|
||
return v;
|
||
}
|
||
|
||
/// 将完整 AdjustAttribution 序列化为 JSON(供 digest / Base64),不省略任何键
|
||
static String _attributionToDigest(AdjustAttribution attr) {
|
||
final map = <String, dynamic>{
|
||
'trackerToken': attr.trackerToken,
|
||
'trackerName': attr.trackerName,
|
||
'network': attr.network,
|
||
'campaign': attr.campaign,
|
||
'adgroup': attr.adgroup,
|
||
'creative': attr.creative,
|
||
'clickLabel': attr.clickLabel,
|
||
'costType': attr.costType,
|
||
'costAmount': _jsonEncodableCostAmount(attr.costAmount),
|
||
'costCurrency': attr.costCurrency,
|
||
'jsonResponse': attr.jsonResponse,
|
||
'fbInstallReferrer': attr.fbInstallReferrer,
|
||
};
|
||
try {
|
||
return jsonEncode(map);
|
||
} catch (_) {
|
||
return '';
|
||
}
|
||
}
|
||
|
||
/// 获取 referrer/digest,优先从 Adjust 归因获取,fallback 使用 Play Install Referrer
|
||
/// 同时监听 attributionCallback,首次安装时归因往往通过回调返回(可能晚于 getAttributionWithTimeout)
|
||
static Future<String?> getReferrer() async {
|
||
if (_cachedReferrer != null) return _cachedReferrer;
|
||
if (_completer.isCompleted) return _completer.future;
|
||
|
||
var digest = '';
|
||
try {
|
||
// 竞速:getAttributionWithTimeout 与 attribution 回调,谁先返回用谁
|
||
final attribution = await Future.any<AdjustAttribution?>([
|
||
(() async {
|
||
try {
|
||
return await Adjust.getAttributionWithTimeout(_adjustTimeoutMs);
|
||
} catch (_) {
|
||
return null;
|
||
}
|
||
})(),
|
||
_attributionCallbackCompleter.future,
|
||
]).timeout(_adjustRaceTimeout, onTimeout: () => null);
|
||
if (attribution != null) {
|
||
final raw = _attributionToDigest(attribution);
|
||
if (raw.isNotEmpty) {
|
||
_referrerSource = 'android_adjust';
|
||
digest = base64Encode(utf8.encode(raw));
|
||
}
|
||
}
|
||
} catch (_) {
|
||
digest = '';
|
||
}
|
||
|
||
if (digest.isEmpty) {
|
||
_referrerSource = 'gg';
|
||
if (defaultTargetPlatform == TargetPlatform.android) {
|
||
try {
|
||
final details = await PlayInstallReferrer.installReferrer
|
||
.timeout(_playInstallReferrerTimeout);
|
||
digest = details.installReferrer ?? '';
|
||
} catch (_) {
|
||
digest = '';
|
||
}
|
||
}
|
||
}
|
||
|
||
_cachedReferrer = digest;
|
||
if (!_completer.isCompleted) _completer.complete(_cachedReferrer);
|
||
return _cachedReferrer;
|
||
}
|
||
|
||
/// 初始化并缓存 referrer(建议在 app 启动时调用)
|
||
static Future<void> init() async {
|
||
await getReferrer();
|
||
}
|
||
|
||
/// 仅 Adjust 归因 digest(Base64 JSON),用于 /v1/user/referrer accolade=android_adjust
|
||
static Future<String> getAdjustReferrerDigest() async {
|
||
try {
|
||
final attr = await Adjust.getAttribution();
|
||
final raw = _attributionToDigest(attr);
|
||
if (raw.isEmpty) return '';
|
||
return base64Encode(utf8.encode(raw));
|
||
} catch (_) {
|
||
return '';
|
||
}
|
||
}
|
||
|
||
/// 仅 Google Play Install Referrer 字符串,用于 /v1/user/referrer accolade=gg
|
||
static Future<String> getGgReferrerDigest() async {
|
||
if (defaultTargetPlatform != TargetPlatform.android) return '';
|
||
try {
|
||
final details = await PlayInstallReferrer.installReferrer
|
||
.timeout(_playInstallReferrerTimeout);
|
||
return details.installReferrer ?? '';
|
||
} catch (_) {
|
||
return '';
|
||
}
|
||
}
|
||
|
||
/// 启动“自然量→渠道”轮询:仅当 Adjust 与 Play Install Referrer 当前都判定为自然量时开始;
|
||
/// 之后每 [_organicPollInterval] 重新判定一次,直到任一渠道变为非自然量或超过
|
||
/// [_organicPollMaxDuration]。结束时调用 [onStopped],参数表示是否检测到非自然量。
|
||
///
|
||
/// 多次调用是幂等的:已在轮询时直接返回。
|
||
static Future<void> startOrganicPolling({
|
||
required Future<void> Function(bool detectedNonOrganic) onStopped,
|
||
}) async {
|
||
if (_organicPollRunning) {
|
||
_log.d('startOrganicPolling: already running, skip');
|
||
return;
|
||
}
|
||
|
||
final initiallyOrganic = await _isCurrentlyOrganic();
|
||
if (!initiallyOrganic) {
|
||
_log.d('startOrganicPolling: 初始归因即为渠道,无需轮询');
|
||
return;
|
||
}
|
||
|
||
_organicPollRunning = true;
|
||
final startedAt = DateTime.now();
|
||
var tickCount = 0;
|
||
_log.d(
|
||
'startOrganicPolling: 启动自然量轮询,每 ${_organicPollInterval.inSeconds}s 一次,最长 ${_organicPollMaxDuration.inMinutes}min');
|
||
|
||
Future<void> finish(bool detectedNonOrganic, {required String reason}) async {
|
||
_stopOrganicPoll();
|
||
_log.d('startOrganicPolling: 结束,原因=$reason changed=$detectedNonOrganic');
|
||
try {
|
||
await onStopped(detectedNonOrganic);
|
||
} catch (e, st) {
|
||
_log.d('startOrganicPolling: onStopped 回调异常: $e');
|
||
_log.d('startOrganicPolling: stack $st');
|
||
}
|
||
}
|
||
|
||
Future<void> tick() async {
|
||
if (!_organicPollRunning) return;
|
||
|
||
tickCount++;
|
||
final elapsed = DateTime.now().difference(startedAt);
|
||
if (elapsed >= _organicPollMaxDuration) {
|
||
await finish(false, reason: 'timeout');
|
||
return;
|
||
}
|
||
|
||
final adjustNetwork = await _readAdjustNetwork();
|
||
final adjustOrganic = _attributionIsOrganicByNetwork(adjustNetwork);
|
||
final pirRef = await _readPlayInstallReferrerRaw();
|
||
final pirOrganic = _installReferrerIsOrganic(pirRef);
|
||
final stillOrganic = adjustOrganic && pirOrganic;
|
||
_log.d(
|
||
'organic poll tick#$tickCount elapsed=${elapsed.inSeconds}s '
|
||
'adjustNetwork="${adjustNetwork ?? '<null>'}" adjustOrganic=$adjustOrganic '
|
||
'pir="$pirRef" pirOrganic=$pirOrganic stillOrganic=$stillOrganic');
|
||
|
||
if (!_organicPollRunning) return;
|
||
|
||
if (!stillOrganic) {
|
||
await finish(true, reason: 'detectedNonOrganic');
|
||
return;
|
||
}
|
||
|
||
_organicPollTimer = Timer(_organicPollInterval, () {
|
||
if (!_organicPollRunning) return;
|
||
unawaited(tick());
|
||
});
|
||
}
|
||
|
||
_organicPollTimer = Timer(_organicPollInterval, () {
|
||
if (!_organicPollRunning) return;
|
||
unawaited(tick());
|
||
});
|
||
}
|
||
|
||
static void _stopOrganicPoll() {
|
||
_organicPollTimer?.cancel();
|
||
_organicPollTimer = null;
|
||
_organicPollRunning = false;
|
||
}
|
||
|
||
/// 当 Adjust 归因和 Play Install Referrer 都判定为自然量时返回 true
|
||
static Future<bool> _isCurrentlyOrganic() async {
|
||
final adjustNetwork = await _readAdjustNetwork();
|
||
final adjustOrganic = _attributionIsOrganicByNetwork(adjustNetwork);
|
||
final pirRef = await _readPlayInstallReferrerRaw();
|
||
final pirOrganic = _installReferrerIsOrganic(pirRef);
|
||
return adjustOrganic && pirOrganic;
|
||
}
|
||
|
||
/// 读取 Adjust 当前归因的 network 字段(异常返回 null,按未归因处理)
|
||
static Future<String?> _readAdjustNetwork() async {
|
||
try {
|
||
final attr = await Adjust.getAttribution();
|
||
return attr.network;
|
||
} catch (_) {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/// network 为空 / 'organic' 视为自然量(含未归因情况)
|
||
static bool _attributionIsOrganicByNetwork(String? network) {
|
||
final n = (network ?? '').trim().toLowerCase();
|
||
return n.isEmpty || n == 'organic';
|
||
}
|
||
|
||
/// 读取 Play Install Referrer 原始字符串(非 Android / 异常返回空串)
|
||
static Future<String> _readPlayInstallReferrerRaw() async {
|
||
if (defaultTargetPlatform != TargetPlatform.android) return '';
|
||
try {
|
||
final details = await PlayInstallReferrer.installReferrer
|
||
.timeout(_playInstallReferrerTimeout);
|
||
return details.installReferrer ?? '';
|
||
} catch (_) {
|
||
return '';
|
||
}
|
||
}
|
||
|
||
/// Google Play 自然量典型 referrer:`utm_source=google-play&utm_medium=organic`;
|
||
/// 空串/缺少 `utm_medium=` 视为尚未归因,按自然量处理。
|
||
static bool _installReferrerIsOrganic(String ref) {
|
||
final s = ref.trim();
|
||
if (s.isEmpty) return true;
|
||
final lower = s.toLowerCase();
|
||
if (lower.contains('utm_medium=organic')) return true;
|
||
if (!lower.contains('utm_medium=')) return true;
|
||
return false;
|
||
}
|
||
}
|