591 lines
18 KiB
Dart
591 lines
18 KiB
Dart
import 'dart:async';
|
||
import 'dart:collection';
|
||
import 'dart:convert';
|
||
import 'dart:io';
|
||
|
||
import 'package:flutter/services.dart';
|
||
import 'package:guru_analytics_flutter/events_constants.dart';
|
||
import 'package:guru_analytics_flutter/events_constants.dart';
|
||
import 'package:guru_app/account/account_data_store.dart';
|
||
import 'package:guru_app/analytics/guru_analytics.dart';
|
||
import 'package:guru_app/firebase/remoteconfig/remote_config_manager.dart';
|
||
import 'package:guru_app/guru_app.dart';
|
||
import 'package:guru_app/property/app_property.dart';
|
||
import 'package:guru_app/property/settings/guru_settings.dart';
|
||
import 'package:guru_app/utils/guru_file_utils_extension.dart';
|
||
import 'package:guru_utils/core/ext.dart';
|
||
import 'package:guru_utils/file/file_utils.dart';
|
||
import 'package:guru_utils/log/log.dart';
|
||
import 'package:guru_utils/property/runtime_property.dart';
|
||
import 'package:guru_utils/quiver/cache.dart';
|
||
import 'package:guru_utils/quiver/collection.dart';
|
||
import 'package:guru_utils/settings/settings.dart';
|
||
import 'package:guru_utils/tuple/tuple.dart';
|
||
|
||
abstract class EventMatcher {
|
||
bool match(String eventName);
|
||
}
|
||
|
||
class UniversalMatcher extends EventMatcher {
|
||
@override
|
||
bool match(String eventName) => true;
|
||
|
||
@override
|
||
String toString() {
|
||
return 'UniversalMatcher';
|
||
}
|
||
}
|
||
|
||
class RegexMatcher extends EventMatcher {
|
||
final RegExp re;
|
||
|
||
RegexMatcher(String pattern) : re = RegExp(pattern);
|
||
|
||
@override
|
||
bool match(String eventName) => re.hasMatch(eventName);
|
||
|
||
@override
|
||
String toString() {
|
||
return 'RegexMatcher:${re.pattern}';
|
||
}
|
||
}
|
||
|
||
class WildcardMatcher extends RegexMatcher {
|
||
final String wildcard;
|
||
|
||
WildcardMatcher(this.wildcard) : super("^${wildcard.replaceAll("*", ".*")}\$");
|
||
|
||
@override
|
||
bool match(String eventName) => super.match(eventName);
|
||
|
||
@override
|
||
String toString() {
|
||
return 'WildcardMatcher:$wildcard => ${re.pattern}';
|
||
}
|
||
}
|
||
|
||
abstract class StrategyValidator {
|
||
bool get alwaysVerify => false;
|
||
|
||
const StrategyValidator();
|
||
|
||
bool validate();
|
||
}
|
||
|
||
class UnlimitedValidator extends StrategyValidator {
|
||
const UnlimitedValidator();
|
||
|
||
@override
|
||
bool validate() => true;
|
||
|
||
@override
|
||
String toString() {
|
||
return 'UnlimitedValidator';
|
||
}
|
||
}
|
||
|
||
class DisabledValidator extends StrategyValidator {
|
||
@override
|
||
bool validate() => false;
|
||
|
||
@override
|
||
String toString() {
|
||
return 'DisabledValidator';
|
||
}
|
||
}
|
||
|
||
class PlatformValidator extends StrategyValidator {
|
||
final String platform;
|
||
|
||
PlatformValidator(this.platform);
|
||
|
||
@override
|
||
bool validate() => Platform.isAndroid ? platform == "android" : platform == "ios";
|
||
|
||
@override
|
||
String toString() {
|
||
return 'PlatformValidator($platform)';
|
||
}
|
||
}
|
||
|
||
class CountryCodeValidator extends StrategyValidator {
|
||
final Set<String> included;
|
||
final Set<String> excluded;
|
||
|
||
CountryCodeValidator(this.included, this.excluded);
|
||
|
||
@override
|
||
bool validate() {
|
||
final countryCode = AccountDataStore.instance.countryCode;
|
||
|
||
// 如果excluded不为空,证明存在排除选项,该validate将只判断所有excluded中的逻辑,
|
||
// 将不会在判断included中的逻辑
|
||
if (excluded.isNotEmpty) {
|
||
return !excluded.contains(countryCode);
|
||
}
|
||
|
||
if (included.contains(countryCode)) {
|
||
return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
@override
|
||
String toString() {
|
||
return 'CountryCodeValidator{included: $included, excluded: $excluded}';
|
||
}
|
||
}
|
||
|
||
class UserPropertyValidator extends StrategyValidator {
|
||
@override
|
||
bool get alwaysVerify => true;
|
||
|
||
final List<Tuple2<String, String>> validProperties;
|
||
|
||
UserPropertyValidator(this.validProperties);
|
||
|
||
@override
|
||
bool validate() {
|
||
for (var tuple in validProperties) {
|
||
if (GuruAnalytics.instance.getProperty(tuple.item1) != tuple.item2) {
|
||
return false;
|
||
}
|
||
}
|
||
return true;
|
||
}
|
||
|
||
@override
|
||
String toString() {
|
||
return 'UserPropertyValidator{validProperties: $validProperties}';
|
||
}
|
||
}
|
||
|
||
class RandomValidator extends StrategyValidator {
|
||
final int percent;
|
||
|
||
RandomValidator(int percent) : percent = percent.clamp(10, 90);
|
||
|
||
@override
|
||
bool validate() {
|
||
final firstInstallTime =
|
||
RuntimeProperty.instance.getInt(UtilsSettingsKeys.firstInstallTime, defValue: -1);
|
||
return (firstInstallTime % 9) >= (percent ~/ 10 - 1);
|
||
}
|
||
|
||
@override
|
||
String toString() {
|
||
return 'RandomValidator{percent: $percent}';
|
||
}
|
||
}
|
||
|
||
class VersionValidator extends StrategyValidator {
|
||
final String opt;
|
||
final String buildId;
|
||
|
||
VersionValidator(this.opt, this.buildId);
|
||
|
||
@override
|
||
bool validate() {
|
||
final buildNumber = GuruSettings.instance.buildNumber.get();
|
||
switch (opt) {
|
||
case "ve":
|
||
return buildNumber == buildId;
|
||
case "vg":
|
||
return buildNumber.compareTo(buildId) > 0;
|
||
case "vge":
|
||
return buildNumber.compareTo(buildId) >= 0;
|
||
case "vl":
|
||
return buildNumber.compareTo(buildId) < 0;
|
||
case "vle":
|
||
return buildNumber.compareTo(buildId) <= 0;
|
||
default:
|
||
return false;
|
||
}
|
||
}
|
||
|
||
@override
|
||
String toString() {
|
||
return 'VersionValidator{opt: $opt, buildId: $buildId}';
|
||
}
|
||
}
|
||
|
||
class StrategyRuleTypeException implements Exception {
|
||
final String message;
|
||
|
||
StrategyRuleTypeException([this.message = "Type mismatch: Expected a StrategyRuleItem."]);
|
||
|
||
@override
|
||
String toString() => "StrategyRuleTypeException: $message";
|
||
}
|
||
|
||
class StrategyRule {
|
||
final EventMatcher? matcher;
|
||
|
||
final StrategyValidator validator;
|
||
|
||
final AppEventCapabilities appEventCapabilities;
|
||
|
||
final String? adjustToken;
|
||
|
||
AppEventOptions? _options;
|
||
|
||
StrategyRule(this.validator, this.appEventCapabilities, {this.matcher, this.adjustToken});
|
||
|
||
AppEventOptions? getAppEventOptions() {
|
||
if ((_options != null && !validator.alwaysVerify) || validator.validate()) {
|
||
return _options ??= AppEventOptions(capabilities: appEventCapabilities);
|
||
}
|
||
return null;
|
||
}
|
||
|
||
@override
|
||
String toString() {
|
||
return 'StrategyRule{matcher: $matcher, validator: $validator, appEventCapabilities: $appEventCapabilities, adjustToken: $adjustToken}';
|
||
}
|
||
}
|
||
|
||
class StrategyRuleParser {
|
||
static final invalidWildcardReg = RegExp(r'[^a-zA-Z=0-9_*]');
|
||
static final adjustTokenReg = RegExp(r'^[a-z0-9]{6}$');
|
||
static final randomStrategyReg = RegExp(r'^r([1-9]0)$');
|
||
static final userPropertyStrategyReg = RegExp(r'^up:(.+)=(.+)$');
|
||
static final versionStrategyReg = RegExp(r'^(ve|vg|vl|vge|vle)(\d{8})$');
|
||
static final countryStrategyReg = RegExp(r'^cc:(.+)$');
|
||
static final countryCodeValidReg = RegExp(r'^[a-z]{2}|\![a-z]{2}$');
|
||
|
||
final List<String> fields;
|
||
|
||
StrategyRuleParser(this.fields);
|
||
|
||
EventMatcher? createEventMatcher(String event) {
|
||
if (event == "_all_") {
|
||
return UniversalMatcher();
|
||
} else if (!invalidWildcardReg.hasMatch(event)) {
|
||
if (event.contains("*")) {
|
||
return WildcardMatcher(event);
|
||
} else {
|
||
// 返回空的话,表示精确匹配,无需提供matcher
|
||
return null;
|
||
}
|
||
} else {
|
||
return RegexMatcher(event);
|
||
}
|
||
}
|
||
|
||
StrategyValidator? createStrategyValidator(String strategy) {
|
||
if (strategy == "unlimited") {
|
||
return const UnlimitedValidator();
|
||
} else if (strategy == "disabled") {
|
||
return DisabledValidator();
|
||
} else if (strategy == "android" || strategy == "ios") {
|
||
return PlatformValidator(strategy);
|
||
} else {
|
||
final randomMatch = randomStrategyReg.firstMatch(strategy);
|
||
final randomPercent = randomMatch?.group(1);
|
||
if (!DartExt.isBlank(randomPercent)) {
|
||
return RandomValidator(int.parse(randomPercent!));
|
||
}
|
||
|
||
final userPropertyMatch = userPropertyStrategyReg.firstMatch(strategy);
|
||
final userPropertyKey = userPropertyMatch?.group(1);
|
||
final userPropertyValue = userPropertyMatch?.group(2);
|
||
if (!DartExt.isBlank(userPropertyKey) && !DartExt.isBlank(userPropertyValue)) {
|
||
return UserPropertyValidator([Tuple2(userPropertyKey!, userPropertyValue!)]);
|
||
}
|
||
|
||
final versionMatch = versionStrategyReg.firstMatch(strategy);
|
||
final versionOpt = versionMatch?.group(1);
|
||
final versionBuildId = versionMatch?.group(2);
|
||
if (!DartExt.isBlank(versionOpt) && !DartExt.isBlank(versionBuildId)) {
|
||
return VersionValidator(versionOpt!, versionBuildId!);
|
||
}
|
||
|
||
final countryCodeMatch = countryStrategyReg.firstMatch(strategy);
|
||
final countryCodeExpression = countryCodeMatch?.group(1);
|
||
if (!DartExt.isBlank(countryCodeExpression)) {
|
||
final included = <String>{};
|
||
final excluded = <String>{};
|
||
final countryCodes = countryCodeExpression!
|
||
.split("|")
|
||
.where((cc) => countryCodeValidReg.hasMatch(cc))
|
||
.toSet();
|
||
for (var cc in countryCodes) {
|
||
if (cc.startsWith("!")) {
|
||
excluded.add(cc.substring(1));
|
||
} else {
|
||
included.add(cc);
|
||
}
|
||
}
|
||
|
||
return CountryCodeValidator(included, excluded);
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
StrategyRuleItem? fromData(List<String> data) {
|
||
if (data.length != fields.length) {
|
||
return null;
|
||
}
|
||
String? event;
|
||
EventMatcher? eventMatcher;
|
||
StrategyValidator? validator;
|
||
int appEventCapabilitiesFlag = 0;
|
||
String? adjustToken;
|
||
for (int i = 0; i < fields.length; ++i) {
|
||
final field = fields[i];
|
||
final value = data[i];
|
||
|
||
if (field == "event") {
|
||
event = value;
|
||
if (event.isEmpty) {
|
||
return null;
|
||
}
|
||
try {
|
||
eventMatcher = createEventMatcher(value);
|
||
Log.d("eventMatcher:$eventMatcher");
|
||
} catch (error, stacktrace) {
|
||
Log.w("createEventMatcher error! $error", stackTrace: stacktrace);
|
||
return null;
|
||
}
|
||
} else if (field == "guru") {
|
||
if (value == '1') {
|
||
appEventCapabilitiesFlag |= AppEventCapabilities.guru;
|
||
}
|
||
} else if (field == "firebase") {
|
||
if (value == '1') {
|
||
appEventCapabilitiesFlag |= AppEventCapabilities.firebase;
|
||
}
|
||
} else if (field == "facebook") {
|
||
if (value == '1') {
|
||
appEventCapabilitiesFlag |= AppEventCapabilities.facebook;
|
||
}
|
||
} else if (field == "adjust") {
|
||
if (value == '1') {}
|
||
} else if (field == "strategy") {
|
||
validator = createStrategyValidator(value);
|
||
} else if ((Platform.isAndroid && field == "ata") || (Platform.isIOS && field == "ati")) {
|
||
if (value.isNotEmpty && adjustTokenReg.hasMatch(value)) {
|
||
adjustToken = value;
|
||
}
|
||
}
|
||
}
|
||
if (event != null && validator != null) {
|
||
return StrategyRuleItem(
|
||
event,
|
||
StrategyRule(validator, AppEventCapabilities(appEventCapabilitiesFlag),
|
||
matcher: eventMatcher, adjustToken: adjustToken));
|
||
}
|
||
return null;
|
||
}
|
||
}
|
||
|
||
class StrategyRuleItem extends Comparable {
|
||
final String eventName;
|
||
final StrategyRule rule;
|
||
|
||
StrategyRuleItem(this.eventName, this.rule);
|
||
|
||
@override
|
||
int compareTo(other) {
|
||
if (other is StrategyRuleItem) {
|
||
return eventName.compareTo(other.eventName);
|
||
}
|
||
throw StrategyRuleTypeException();
|
||
}
|
||
}
|
||
|
||
class GuruAnalyticsStrategy {
|
||
static const String tag = "GuruAnalyticsStrategy";
|
||
final List<StrategyRule> priorityRules = [];
|
||
final SplayTreeMap<String, StrategyRule> explicitRules = SplayTreeMap();
|
||
final Map<String, AdjustEventConverter> iosAdjustEventConverters = {};
|
||
final Map<String, AdjustEventConverter> androidAdjustEventConverts = {};
|
||
|
||
bool loaded = false;
|
||
|
||
final LinkedLruHashMap<String, StrategyRule> eventRules = LinkedLruHashMap(maximumSize: 128);
|
||
|
||
GuruAnalyticsStrategy._();
|
||
|
||
static final GuruAnalyticsStrategy instance = GuruAnalyticsStrategy._();
|
||
|
||
void reset() {
|
||
priorityRules.clear();
|
||
explicitRules.clear();
|
||
}
|
||
|
||
static const guruAnalyticsStrategyExtension = ".gas"; // Guru Analytics Strategy
|
||
|
||
Future<File?> checkAndCreateLocalStrategyFile() async {
|
||
final currentLocalStrategy =
|
||
"${GuruSettings.instance.buildNumber.get()}$guruAnalyticsStrategyExtension";
|
||
final file = await FileUtils.instance.getGuruConfigFile("analytics", currentLocalStrategy);
|
||
if (!file.existsSync()) {
|
||
try {
|
||
final data = await rootBundle.loadString("assets/guru/analytics_strategy.csv");
|
||
file.writeAsStringSync(data);
|
||
Log.i("load local strategy success! [$currentLocalStrategy]", tag: tag);
|
||
return file;
|
||
} catch (error, stacktrace) {
|
||
Log.w("not config local strategy!", tag: tag);
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
Future load() async {
|
||
try {
|
||
final analyticsConfig = RemoteConfigManager.instance.getAnalyticsConfig();
|
||
|
||
if (!GuruApp.instance.appSpec.deployment.enabledGuruAnalyticsStrategy ||
|
||
!analyticsConfig.enabledStrategy) {
|
||
Log.w("analytics strategy disabled!", tag: tag);
|
||
return;
|
||
}
|
||
|
||
final String remoteAnalyticsStrategy = analyticsConfig.strategy;
|
||
final latestAnalyticsStrategy = await AppProperty.getInstance().getLatestAnalyticsStrategy();
|
||
if (remoteAnalyticsStrategy != latestAnalyticsStrategy) {
|
||
loaded = false;
|
||
}
|
||
if (loaded) {
|
||
Log.w("already loaded! ignore!", tag: tag);
|
||
return;
|
||
}
|
||
File? file;
|
||
// 如果remoteAnalyticsStrategy非空表示云控配置了strategy
|
||
if (!DartExt.isBlank(remoteAnalyticsStrategy)) {
|
||
file = await FileUtils.instance.getGuruConfigFile("analytics", remoteAnalyticsStrategy);
|
||
if (!file.existsSync()) {
|
||
try {
|
||
await FileUtils.instance.downloadFile(
|
||
"${GuruApp.instance.details.storagePrefix}/guru%2Fanalytics%2F$remoteAnalyticsStrategy?alt=media",
|
||
file);
|
||
Log.i("download analytics strategy[$remoteAnalyticsStrategy] success", tag: tag);
|
||
} catch (error, stacktrace) {
|
||
Log.w("downloadFile error! $error try to fallback", tag: tag);
|
||
// 这里没有使用上一次的strategy做回滚的原因,
|
||
// 主要是考虑到上一次的云端strategy可能没有本地的strategy可靠,
|
||
// SDK假设本地的strategy比Firebase Storage中配置的strategy更可靠
|
||
// 因此这里在出现下载异常的情况下,会回滚到本地strategy上
|
||
// 如果不想使用这个机制,可以在自己的项目中不配置任何strategy
|
||
}
|
||
}
|
||
if (remoteAnalyticsStrategy != latestAnalyticsStrategy) {
|
||
AppProperty.getInstance().setLatestAnalyticsStrategy(remoteAnalyticsStrategy);
|
||
final latestStrategyFile =
|
||
await FileUtils.instance.getGuruConfigFile("analytics", latestAnalyticsStrategy);
|
||
if (latestStrategyFile.existsSync()) {
|
||
FileUtils.instance.deleteFile(latestStrategyFile);
|
||
}
|
||
}
|
||
}
|
||
|
||
// 如果当前文件为空或是不存在,证明有可能相应的strategy下载失败,或是没有设置
|
||
// 因此这种情况下尝试使用本地的strategy进行加载
|
||
if (file?.existsSync() != true) {
|
||
file = await checkAndCreateLocalStrategyFile();
|
||
if (file?.existsSync() != true) {
|
||
return;
|
||
}
|
||
}
|
||
|
||
final Stream<String> strategyTextStream = file!.openRead().transform(utf8.decoder);
|
||
|
||
StrategyRule? newDefaultRule;
|
||
final List<StrategyRule> newPriorityRules = [];
|
||
final Map<String, StrategyRule> newExplicitRules = {};
|
||
StrategyRuleParser? parser;
|
||
int lineNum = 0;
|
||
await for (var line in strategyTextStream.transform(const LineSplitter())) {
|
||
final list = line.split(",");
|
||
Log.d("[${lineNum++}] $list", tag: tag);
|
||
if (parser == null) {
|
||
parser = StrategyRuleParser(list);
|
||
} else {
|
||
final ruleItem = parser.fromData(list);
|
||
if (ruleItem == null) {
|
||
continue;
|
||
}
|
||
if (ruleItem.eventName == "_all_") {
|
||
newDefaultRule = ruleItem.rule;
|
||
} else if (ruleItem.rule.matcher != null) {
|
||
newPriorityRules.add(ruleItem.rule);
|
||
} else {
|
||
newExplicitRules[ruleItem.eventName] = ruleItem.rule;
|
||
}
|
||
|
||
if (ruleItem.rule.adjustToken != null) {
|
||
if (Platform.isAndroid) {
|
||
androidAdjustEventConverts[ruleItem.eventName] =
|
||
(_) => AdjustEvent(ruleItem.rule.adjustToken!);
|
||
} else if (Platform.isIOS) {
|
||
iosAdjustEventConverters[ruleItem.eventName] =
|
||
(_) => AdjustEvent(ruleItem.rule.adjustToken!);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
reset();
|
||
priorityRules.addAll(newPriorityRules.reversed);
|
||
if (newDefaultRule != null) {
|
||
priorityRules.add(newDefaultRule);
|
||
}
|
||
explicitRules.addAll(newExplicitRules);
|
||
|
||
loaded = true;
|
||
Log.d(
|
||
"analytics strategy loaded! ${eventRules.length} ${explicitRules.length} ${priorityRules.length}",
|
||
tag: tag);
|
||
} catch (error, stacktrace) {}
|
||
}
|
||
|
||
StrategyRule? getStrategyRule(String eventName) {
|
||
Log.d(
|
||
"[$loaded]getStrategyRule:$eventName ${eventRules.length} ${explicitRules.length} ${priorityRules.length}");
|
||
if (!loaded) {
|
||
return null;
|
||
}
|
||
|
||
final rule = eventRules[eventName];
|
||
if (rule != null) {
|
||
return rule;
|
||
}
|
||
|
||
final explicitRule = explicitRules[eventName];
|
||
if (explicitRule != null) {
|
||
return explicitRule;
|
||
}
|
||
|
||
for (var rule in priorityRules) {
|
||
Log.d("matcher: ${rule.matcher} eventName: $eventName ${rule.matcher?.match(eventName)}",
|
||
tag: tag);
|
||
if (rule.matcher?.match(eventName) == true) {
|
||
return rule;
|
||
}
|
||
}
|
||
// 如果没有启用strategy,默认按之前逻辑处理
|
||
return null;
|
||
}
|
||
|
||
AdjustEventConverter? getAdjustEventConverter(String eventName) {
|
||
if (Platform.isAndroid) {
|
||
return androidAdjustEventConverts[eventName];
|
||
} else if (Platform.isIOS) {
|
||
return iosAdjustEventConverters[eventName];
|
||
}
|
||
return null;
|
||
}
|
||
|
||
void testRule(String eventName) {
|
||
final rule = getStrategyRule(eventName);
|
||
if (rule?.matcher?.match(eventName) != false) {
|
||
Log.d("testMatch: $eventName => $rule success!", tag: tag);
|
||
} else {
|
||
Log.d("testMatch: $eventName => $rule error!", tag: tag);
|
||
}
|
||
}
|
||
}
|