389 lines
		
	
	
		
			11 KiB
		
	
	
	
		
			Dart
		
	
	
			
		
		
	
	
			389 lines
		
	
	
		
			11 KiB
		
	
	
	
		
			Dart
		
	
	
| 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 extends Comparable>(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<String, dynamic> 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<String, dynamic> 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<String, dynamic> json) => _$AndroidConditionFromJson(json);
 | ||
| 
 | ||
|   Map<String, dynamic> 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<int>(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<String, dynamic> json) => _$IosConditionFromJson(json);
 | ||
| 
 | ||
|   Map<String, dynamic> 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<int>(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<String, dynamic> json) => _$PlatformFilterFromJson(json);
 | ||
| 
 | ||
|   @override
 | ||
|   Map<String, dynamic> 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<String>(version, mmp, opt);
 | ||
|   }
 | ||
| 
 | ||
|   @override
 | ||
|   String toString() {
 | ||
|     return 'VersionValidator{opt: $opt, mmp: $mmp}';
 | ||
|   }
 | ||
| 
 | ||
|   factory VersionFilter.fromJson(Map<String, dynamic> json) => _$VersionFilterFromJson(json);
 | ||
| 
 | ||
|   @override
 | ||
|   Map<String, dynamic> toJson() => _$VersionFilterToJson(this);
 | ||
| }
 | ||
| 
 | ||
| @JsonSerializable(constructor: "_")
 | ||
| class CountryFilter extends ABTestFilter {
 | ||
|   @JsonKey(name: "included", defaultValue: {})
 | ||
|   final Set<String> included;
 | ||
| 
 | ||
|   @JsonKey(name: "excluded", defaultValue: {})
 | ||
|   final Set<String> 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<String, dynamic> json) => _$CountryFilterFromJson(json);
 | ||
| 
 | ||
|   @override
 | ||
|   Map<String, dynamic> 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<String, dynamic> json) => _$NewUserFilterFromJson(json);
 | ||
| 
 | ||
|   @override
 | ||
|   Map<String, dynamic> toJson() => _$NewUserFilterToJson(this);
 | ||
| }
 | ||
| 
 | ||
| @JsonSerializable()
 | ||
| class ABTestAudience {
 | ||
|   @JsonKey(name: "filters")
 | ||
|   final List<ABTestFilter> filters;
 | ||
| 
 | ||
|   @JsonKey(name: "variant", defaultValue: 2)
 | ||
|   final int variant;
 | ||
| 
 | ||
|   ABTestAudience({required this.filters, this.variant = 2});
 | ||
| 
 | ||
|   factory ABTestAudience.fromJson(Map<String, dynamic> json) => _$ABTestAudienceFromJson(json);
 | ||
| 
 | ||
|   Map<String, dynamic> 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<String, dynamic> json) => _$ABTestExperimentFromJson(json);
 | ||
| 
 | ||
|   Map<String, dynamic> 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();
 | ||
|   }
 | ||
| }
 |