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();
|
|||
|
|
}
|
|||
|
|
}
|