import 'dart:io'; import 'package:guru_app/account/account_data_store.dart'; import 'package:guru_app/firebase/remoteconfig/remote_config_manager.dart'; import 'package:guru_app/property/settings/guru_settings.dart'; import 'package:guru_utils/device/device_utils.dart'; import 'package:guru_utils/extensions/extensions.dart'; import 'package:guru_utils/log/log.dart'; import 'package:guru_utils/random/random_utils.dart'; import 'package:json_annotation/json_annotation.dart'; part 'abtest_model.g.dart'; class ConditionOpt { static const equals = "eq"; static const greaterThan = "gt"; static const greaterThanOrEquals = "gte"; static const lessThan = "lt"; static const lessThanOrEquals = "lte"; static const notEquals = "ne"; static bool evaluate(T value, T target, String opt) { final result = value.compareTo(target); switch (opt) { case ConditionOpt.equals: return result == 0; case ConditionOpt.greaterThan: return result > 0; case ConditionOpt.greaterThanOrEquals: return result >= 0; case ConditionOpt.lessThan: return result < 0; case ConditionOpt.lessThanOrEquals: return result <= 0; case ConditionOpt.notEquals: return result != 0; default: return false; } } } abstract class ABTestFilter { static const platform = 1; static const version = 2; static const country = 3; static const newUser = 4; final int type; const ABTestFilter(this.type); bool filter(); factory ABTestFilter.fromJson(Map json) { final type = json["type"] = json["type"] ?? 0; switch (type) { case ABTestFilter.platform: return PlatformFilter.fromJson(json); case ABTestFilter.country: return CountryFilter.fromJson(json); case ABTestFilter.version: return VersionFilter.fromJson(json); case ABTestFilter.newUser: return NewUserFilter.fromJson(json); default: throw UnimplementedError("Unknown ABTestFilter type: $type"); } } Map toJson() => toJson()..addAll({"type": type}); } abstract class ABTestCondition { bool validate(); } /// 为了后面可以做一些定制,因此这里按平台进行区分 @JsonSerializable() class AndroidCondition extends ABTestCondition { @JsonKey(name: "opt") final String? opt; @JsonKey(name: "sdk") final int? sdkInt; AndroidCondition({this.opt, this.sdkInt}); factory AndroidCondition.fromJson(Map json) => _$AndroidConditionFromJson(json); Map toJson() => _$AndroidConditionToJson(this); @override bool validate() { final versionOpt = opt; final targetVersion = sdkInt; if (versionOpt != null && targetVersion != null) { final versionCode = DeviceUtils.peekOSVersion(); // 操作系统版本号获取失败,直接返回false if (versionCode == -1) { return false; } if (!ConditionOpt.evaluate(versionCode, targetVersion, versionOpt)) { return false; } } // 方便后面扩展其它字段 return true; } } @JsonSerializable() class IosCondition extends ABTestCondition { @JsonKey(name: "opt") final String? opt; @JsonKey(name: "ver") final int? version; // 这里只记录大版本号 IosCondition({this.opt, this.version}); factory IosCondition.fromJson(Map json) => _$IosConditionFromJson(json); Map toJson() => _$IosConditionToJson(this); @override bool validate() { final versionOpt = opt; final targetVersion = version; if (versionOpt != null && targetVersion != null) { final versionCode = DeviceUtils.peekOSVersion(); // 操作系统版本号获取失败,直接返回false if (versionCode == -1) { return false; } if (!ConditionOpt.evaluate(versionCode, targetVersion, versionOpt)) { return false; } } // 方便后面扩展其它字段 return true; } } @JsonSerializable() class PlatformFilter extends ABTestFilter { @JsonKey(name: "ac") final AndroidCondition? androidCondition; @JsonKey(name: "ic") final IosCondition? iosCondition; PlatformFilter({this.androidCondition, this.iosCondition}) : super(ABTestFilter.platform); @override bool filter() { // 如果配了 Platform Filter, 如果指定平台没有 condition, 则默认为true if (Platform.isAndroid) { return androidCondition?.validate() != false; } else if (Platform.isIOS) { return iosCondition?.validate() != false; } return false; } factory PlatformFilter.fromJson(Map json) => _$PlatformFilterFromJson(json); @override Map toJson() => _$PlatformFilterToJson(this); } @JsonSerializable(constructor: "_") class VersionFilter extends ABTestFilter { @JsonKey(name: "opt") final String opt; @JsonKey(name: "mmp") final String mmp; // major.minor.patch VersionFilter._(this.opt, this.mmp) : super(ABTestFilter.version); VersionFilter.equals(this.mmp) : opt = ConditionOpt.equals, super(ABTestFilter.version); VersionFilter.greaterThan(this.mmp) : opt = ConditionOpt.greaterThan, super(ABTestFilter.version); VersionFilter.greaterThanOrEquals(this.mmp) : opt = ConditionOpt.greaterThanOrEquals, super(ABTestFilter.version); VersionFilter.lessThan(this.mmp) : opt = ConditionOpt.lessThan, super(ABTestFilter.version); VersionFilter.lessThanOrEquals(this.mmp) : opt = ConditionOpt.lessThanOrEquals, super(ABTestFilter.version); VersionFilter.notEquals(this.mmp) : opt = ConditionOpt.notEquals, super(ABTestFilter.version); @override bool filter() { final version = GuruSettings.instance.version.get(); Log.d("[$runtimeType] $version $opt $mmp"); return ConditionOpt.evaluate(version, mmp, opt); } @override String toString() { return 'VersionValidator{opt: $opt, mmp: $mmp}'; } factory VersionFilter.fromJson(Map json) => _$VersionFilterFromJson(json); @override Map toJson() => _$VersionFilterToJson(this); } @JsonSerializable(constructor: "_") class CountryFilter extends ABTestFilter { @JsonKey(name: "included", defaultValue: {}) final Set included; @JsonKey(name: "excluded", defaultValue: {}) final Set excluded; CountryFilter._(this.included, this.excluded) : super(ABTestFilter.country); CountryFilter.included(this.included) : excluded = {}, super(ABTestFilter.country); CountryFilter.excluded(this.excluded) : included = {}, super(ABTestFilter.country); @override bool filter() { final String countryCode = Platform.localeName.split('_').safeLast?.toLowerCase() ?? ""; Log.d("[$runtimeType] $countryCode included: $included excluded: $excluded"); if (countryCode.isEmpty) { return false; } // 如果excluded不为空,证明存在排除选项,该validate将只判断所有excluded中的逻辑, // 将不会在判断included中的逻辑 if (excluded.isNotEmpty) { return !excluded.contains(countryCode); } if (included.contains(countryCode)) { return true; } return false; } factory CountryFilter.fromJson(Map json) => _$CountryFilterFromJson(json); @override Map toJson() => _$CountryFilterToJson(this); } @JsonSerializable() class NewUserFilter extends ABTestFilter { NewUserFilter() : super(ABTestFilter.newUser); @override bool filter() { final version = GuruSettings.instance.version.get(); final fiv = GuruSettings.instance.firstInstallVersion.get(); return fiv.startsWith(version); } factory NewUserFilter.fromJson(Map json) => _$NewUserFilterFromJson(json); @override Map toJson() => _$NewUserFilterToJson(this); } @JsonSerializable() class ABTestAudience { @JsonKey(name: "filters") final List filters; @JsonKey(name: "variant", defaultValue: 2) final int variant; ABTestAudience({required this.filters, this.variant = 2}); factory ABTestAudience.fromJson(Map json) => _$ABTestAudienceFromJson(json); Map toJson() => _$ABTestAudienceToJson(this); @override String toString() { return 'ABTestAudience{filters: $filters, variant: $variant}'; } bool validate() { for (var filter in filters) { if (!filter.filter()) { return false; } } return true; } } @JsonSerializable() class ABTestExperiment { @JsonKey(name: "name") final String name; @JsonKey(name: "start_ts", defaultValue: 0) final int startTs; @JsonKey(name: "end_ts", defaultValue: 0) final int endTs; @JsonKey(name: "audience") final ABTestAudience audience; ABTestExperiment( {required String name, required this.startTs, required this.endTs, required this.audience}) : name = _validExperimentName(name); @override String toString() { return 'ABTestExperiment{name: $name, startTs: $startTs, endTs: $endTs, audience: $audience}'; } static String _validExperimentName(String experimentName) { if (experimentName.contains(RemoteConfigManager.invalidABKeyRegExp)) { Log.w("abName($experimentName) use invalid key! $experimentName! replace invalid char to _"); experimentName = experimentName.replaceAll(RemoteConfigManager.invalidABKeyRegExp, "_"); } else { if (experimentName.length > 20) { experimentName = experimentName.substring(0, 20); } } return experimentName; } factory ABTestExperiment.fromJson(Map json) => _$ABTestExperimentFromJson(json); Map toJson() => _$ABTestExperimentToJson(this); bool isExpired() { final now = DateTime.now().millisecondsSinceEpoch; return now < startTs || now > endTs; } bool isMatchAudience() { return audience.validate(); } @JsonKey(includeToJson: false) String? _variantName; String get variantName => (_variantName ??= _toVariantName(RandomUtils.nextInt(audience.variant))); static const _originalVariant = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"; String _toVariantName(int value) { String codes = ""; int nv = value; while (true) { final nextNum = nv ~/ _originalVariant.length; if (nextNum <= 0) { break; } codes = "${_originalVariant[nv % _originalVariant.length]}$codes"; nv = nextNum; } final tailIndex = nv % _originalVariant.length; if (tailIndex >= 0) { codes = "${_originalVariant[tailIndex]}$codes"; } return codes.toString(); } }