guru_sdk/guru_app/lib/analytics/abtest/abtest_model.dart

389 lines
11 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

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