diff --git a/CHANGELOG.md b/CHANGELOG.md index 11ecba8..aca0411 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,8 @@ +# 3.9.4 +- New Operators $inGroup and $notInGroup to check Saved Groups by reference +- Add argument to evalCondition for definition of Saved Groups +- Add test cases for evalCondition, feature, and run using the new operators + # 3.9.3 - Support Caching Manager on Flutter Web - Remove a broken test case diff --git a/lib/src/Evaluator/condition_evaluator.dart b/lib/src/Evaluator/condition_evaluator.dart index 9e0e425..1f13dce 100644 --- a/lib/src/Evaluator/condition_evaluator.dart +++ b/lib/src/Evaluator/condition_evaluator.dart @@ -40,7 +40,13 @@ class GBConditionEvaluator { /// This is the main function used to evaluate a condition. It loops through the condition key/value pairs and checks each entry: /// - attributes : User Attributes /// - condition : to be evaluated - bool isEvalCondition(Map attributes, dynamic conditionObj) { + bool isEvalCondition( + Map attributes, + dynamic conditionObj, + // Must be included for `condition` to correctly evaluate group Operators + SavedGroupsValues? savedGroups, + ) { + savedGroups ??= {}; if (conditionObj is List) { return false; } @@ -49,28 +55,28 @@ class GBConditionEvaluator { var value = conditionObj[key]; switch (key) { case "\$or": - if (!isEvalOr(attributes, value)) { + if (!isEvalOr(attributes, value, savedGroups)) { return false; } break; case "\$nor": - if (isEvalOr(attributes, value)) { + if (isEvalOr(attributes, value, savedGroups)) { return false; } break; case "\$and": - if (!isEvalAnd(attributes, value)) { + if (!isEvalAnd(attributes, value, savedGroups)) { return false; } break; case "\$not": - if (isEvalCondition(attributes, value)) { + if (isEvalCondition(attributes, value, savedGroups)) { return false; } break; default: var element = getPath(attributes, key); - if (!isEvalConditionValue(value, element)) { + if (!isEvalConditionValue(value, element, savedGroups)) { return false; } } @@ -81,7 +87,7 @@ class GBConditionEvaluator { } /// Evaluate OR conditions against given attributes - bool isEvalOr(Map attributes, List conditionObj) { + bool isEvalOr(Map attributes, List conditionObj, SavedGroupsValues savedGroups) { // If conditionObj is empty, return true if (conditionObj.isEmpty) { return true; @@ -90,7 +96,7 @@ class GBConditionEvaluator { for (var item in conditionObj) { // If evalCondition(attributes, conditionObj[i]) is true, break out of // the loop and return true - if (isEvalCondition(attributes, item)) { + if (isEvalCondition(attributes, item, savedGroups)) { return true; } } @@ -100,14 +106,14 @@ class GBConditionEvaluator { } /// Evaluate AND conditions against given attributes - bool isEvalAnd(dynamic attributes, List conditionObj) { + bool isEvalAnd(dynamic attributes, List conditionObj, SavedGroupsValues savedGroups) { // Loop through the conditionObjects // Loop through the conditionObjects for (var item in conditionObj) { // If evalCondition(attributes, conditionObj[i]) is true, break out of // the loop and return false - if (!isEvalCondition(attributes, item)) { + if (!isEvalCondition(attributes, item, savedGroups)) { return false; } } @@ -180,7 +186,7 @@ class GBConditionEvaluator { } ///Evaluates Condition Value against given condition & attributes - bool isEvalConditionValue(dynamic conditionValue, dynamic attributeValue) { + bool isEvalConditionValue(dynamic conditionValue, dynamic attributeValue, SavedGroupsValues savedGroups) { // If conditionValue is a string, number, boolean, return true if it's // "equal" to attributeValue and false if not. if ((conditionValue as Object?).isPrimitive && (attributeValue as Object?).isPrimitive) { @@ -212,7 +218,7 @@ class GBConditionEvaluator { for (var key in conditionValue.keys) { // If evalOperatorCondition(key, attributeValue, value) // is false, return false - if (!evalOperatorCondition(key, attributeValue, conditionValue[key])) { + if (!evalOperatorCondition(key, attributeValue, conditionValue[key], savedGroups)) { return false; } } @@ -232,7 +238,7 @@ class GBConditionEvaluator { /// This checks if attributeValue is an array, and if so at least one of the /// array items must match the condition - bool elemMatch(dynamic attributeValue, dynamic condition) { + bool elemMatch(dynamic attributeValue, dynamic condition, SavedGroupsValues savedGroups) { // Loop through items in attributeValue if (attributeValue is List) { for (final item in attributeValue) { @@ -240,13 +246,13 @@ class GBConditionEvaluator { if (isOperatorObject(condition)) { // If evalConditionValue(condition, item), break out of loop and //return true - if (isEvalConditionValue(condition, item)) { + if (isEvalConditionValue(condition, item, savedGroups)) { return true; } } // Else if evalCondition(item, condition), break out of loop and //return true - else if (isEvalCondition(item, condition)) { + else if (isEvalCondition(item, condition, savedGroups)) { return true; } } @@ -258,7 +264,12 @@ class GBConditionEvaluator { /// This function is just a case statement that handles all the possible operators /// There are basic comparison operators in the form attributeValue {op} /// conditionValue. - bool evalOperatorCondition(String operator, dynamic attributeValue, dynamic conditionValue) { + bool evalOperatorCondition( + String operator, + dynamic attributeValue, + dynamic conditionValue, + SavedGroupsValues savedGroups, + ) { /// Evaluate TYPE operator - whether both are of the same type if (operator == "\$type") { return getType(attributeValue).name == conditionValue; @@ -266,7 +277,7 @@ class GBConditionEvaluator { /// Evaluate NOT operator - whether condition doesn't contain attribute if (operator == "\$not") { - return !isEvalConditionValue(conditionValue, attributeValue); + return !isEvalConditionValue(conditionValue, attributeValue, savedGroups); } /// Evaluate EXISTS operator - whether condition contains attribute @@ -278,6 +289,13 @@ class GBConditionEvaluator { } } + switch (operator) { + case "\$inGroup": + return isIn(attributeValue, savedGroups[conditionValue] ?? []); + case "\$notInGroup": + return !isIn(attributeValue, savedGroups[conditionValue] ?? []); + } + /// There are three operators where conditionValue is an array if (conditionValue is List) { switch (operator) { @@ -299,7 +317,7 @@ class GBConditionEvaluator { for (var con in conditionValue) { var result = false; for (var attr in attributeValue) { - if (isEvalConditionValue(con, attr)) { + if (isEvalConditionValue(con, attr, savedGroups)) { result = true; } } @@ -319,12 +337,12 @@ class GBConditionEvaluator { switch (operator) { /// Evaluate ELEMENT-MATCH operator - whether condition matches attribute case "\$elemMatch": - return elemMatch(attributeValue, conditionValue); + return elemMatch(attributeValue, conditionValue, savedGroups); /// Evaluate SIE operator - whether condition size is same as that /// of attribute case "\$size": - return isEvalConditionValue(conditionValue, attributeValue.length); + return isEvalConditionValue(conditionValue, attributeValue.length, savedGroups); default: } diff --git a/lib/src/Evaluator/experiment_evaluator.dart b/lib/src/Evaluator/experiment_evaluator.dart index 44f14ce..35e5ecb 100644 --- a/lib/src/Evaluator/experiment_evaluator.dart +++ b/lib/src/Evaluator/experiment_evaluator.dart @@ -11,9 +11,7 @@ class ExperimentEvaluator { ExperimentEvaluator({required this.attributeOverrides}); // Takes Context and Experiment and returns ExperimentResult - GBExperimentResult evaluateExperiment( - GBContext context, GBExperiment experiment, - {String? featureId}) { + GBExperimentResult evaluateExperiment(GBContext context, GBExperiment experiment, {String? featureId}) { // Check if experiment.variations has fewer than 2 variations if (experiment.variations.length < 2 || context.enabled != true) { // Return an ExperimentResult indicating not in experiment and variationId 0 @@ -26,13 +24,10 @@ class ExperimentEvaluator { ); } - if (context.forcedVariation != null && - context.forcedVariation!.containsKey(experiment.key)) { + if (context.forcedVariation != null && context.forcedVariation!.containsKey(experiment.key)) { // Retrieve the forced variation for the experiment key - if (context.forcedVariation != null && - context.forcedVariation?[experiment.key] != null) { - int forcedVariationIndex = - int.parse(context.forcedVariation![experiment.key].toString()); + if (context.forcedVariation != null && context.forcedVariation?[experiment.key] != null) { + int forcedVariationIndex = int.parse(context.forcedVariation![experiment.key].toString()); // Return the experiment result using the forced variation index and indicating that no hash was used return _getExperimentResult( @@ -58,8 +53,7 @@ class ExperimentEvaluator { final hashAttributeAndValue = GBUtils.getHashAttribute( context: context, attr: experiment.hashAttribute, - fallback: (context.stickyBucketService != null && - (experiment.disableStickyBucketing != true)) + fallback: (context.stickyBucketService != null && (experiment.disableStickyBucketing != true)) ? experiment.fallbackAttribute : null, attributeOverrides: attributeOverrides, @@ -83,8 +77,7 @@ class ExperimentEvaluator { bool foundStickyBucket = false; bool stickyBucketVersionIsBlocked = false; - if (context.stickyBucketService != null && - (experiment.disableStickyBucketing != true)) { + if (context.stickyBucketService != null && (experiment.disableStickyBucketing != true)) { final stickyBucketResult = GBUtils.getStickyBucketVariation( context: context, experimentKey: experiment.key, @@ -97,14 +90,12 @@ class ExperimentEvaluator { ); foundStickyBucket = stickyBucketResult.variation >= 0; assigned = stickyBucketResult.variation; - stickyBucketVersionIsBlocked = - stickyBucketResult.versionIsBlocked ?? false; + stickyBucketVersionIsBlocked = stickyBucketResult.versionIsBlocked ?? false; } if (!foundStickyBucket) { if (experiment.filters != null) { - if (GBUtils.isFilteredOut( - experiment.filters!, context, attributeOverrides)) { + if (GBUtils.isFilteredOut(experiment.filters!, context, attributeOverrides)) { log('Skip because of filters'); return _getExperimentResult( featureId: featureId, @@ -131,8 +122,7 @@ class ExperimentEvaluator { } if (experiment.condition != null && - !GBConditionEvaluator() - .isEvalCondition(context.attributes!, experiment.condition!)) { + !GBConditionEvaluator().isEvalCondition(context.attributes!, experiment.condition!, context.savedGroups)) { return _getExperimentResult( featureId: featureId, context: context, @@ -150,8 +140,7 @@ class ExperimentEvaluator { attributeOverrides: parentCondition.condition, ).evaluateFeature(); - if (parentResult.source?.name == - GBFeatureSource.cyclicPrerequisite.name) { + if (parentResult.source?.name == GBFeatureSource.cyclicPrerequisite.name) { return _getExperimentResult( featureId: featureId, context: context, @@ -165,6 +154,7 @@ class ExperimentEvaluator { final evalCondition = GBConditionEvaluator().isEvalCondition( evalObj, parentCondition.condition, + context.savedGroups, ); if (!evalCondition) { @@ -262,22 +252,19 @@ class ExperimentEvaluator { stickyBucketUsed: foundStickyBucket, ); - if (context.stickyBucketService != null && - (experiment.disableStickyBucketing != true)) { + if (context.stickyBucketService != null && (experiment.disableStickyBucketing != true)) { final stickyBucketDoc = GBUtils.generateStickyBucketAssignmentDoc( context: context, attributeName: hashAttribute, attributeValue: hashValue, newAssignments: { - GBUtils.getStickyBucketExperimentKey( - experiment.key, experiment.bucketVersion ?? 0): result.key + GBUtils.getStickyBucketExperimentKey(experiment.key, experiment.bucketVersion ?? 0): result.key }, ); if (stickyBucketDoc.hasChanged) { context.stickyBucketAssignmentDocs ??= {}; - context.stickyBucketAssignmentDocs![stickyBucketDoc.key] = - stickyBucketDoc.doc; + context.stickyBucketAssignmentDocs![stickyBucketDoc.key] = stickyBucketDoc.doc; context.stickyBucketService?.saveAssignments(stickyBucketDoc.doc); } } @@ -303,8 +290,7 @@ class ExperimentEvaluator { int targetVariationIndex = variationIndex; // Check whether variationIndex lies within bounds of variations size - if (targetVariationIndex < 0 || - targetVariationIndex >= experiment.variations.length) { + if (targetVariationIndex < 0 || targetVariationIndex >= experiment.variations.length) { // Set to 0 targetVariationIndex = 0; inExperiment = false; @@ -312,8 +298,7 @@ class ExperimentEvaluator { final hashResult = GBUtils.getHashAttribute( context: context, attr: experiment.hashAttribute, - fallback: (context.stickyBucketService != null && - (experiment.disableStickyBucketing != true)) + fallback: (context.stickyBucketService != null && (experiment.disableStickyBucketing != true)) ? experiment.fallbackAttribute : null, attributeOverrides: attributeOverrides, @@ -324,16 +309,13 @@ class ExperimentEvaluator { // Retrieve experiment metadata List experimentMeta = experiment.meta ?? []; - GBVariationMeta? meta = (experimentMeta.length > targetVariationIndex) - ? experimentMeta[targetVariationIndex] - : null; + GBVariationMeta? meta = + (experimentMeta.length > targetVariationIndex) ? experimentMeta[targetVariationIndex] : null; return GBExperimentResult( inExperiment: inExperiment, variationID: targetVariationIndex, - value: (experiment.variations.length > targetVariationIndex) - ? experiment.variations[targetVariationIndex] - : {}, + value: (experiment.variations.length > targetVariationIndex) ? experiment.variations[targetVariationIndex] : {}, hashAttribute: hashAttribute, hashValue: hashValue, key: meta?.key ?? '$targetVariationIndex', diff --git a/lib/src/Evaluator/feature_evaluator.dart b/lib/src/Evaluator/feature_evaluator.dart index fe1a26d..8314c8d 100644 --- a/lib/src/Evaluator/feature_evaluator.dart +++ b/lib/src/Evaluator/feature_evaluator.dart @@ -90,6 +90,7 @@ class FeatureEvaluator { bool evalCondition = GBConditionEvaluator().isEvalCondition( evalObj, parentCondition.condition, + context.savedGroups, ); // If the evaluation condition is false @@ -125,6 +126,7 @@ class FeatureEvaluator { !GBConditionEvaluator().isEvalCondition( getAttributes(), rule.condition!, + context.savedGroups, )) { log('Skip rule because of condition'); continue; // Skip to the next rule diff --git a/lib/src/Features/feature_data_source.dart b/lib/src/Features/feature_data_source.dart index 051a114..924c724 100644 --- a/lib/src/Features/feature_data_source.dart +++ b/lib/src/Features/feature_data_source.dart @@ -10,6 +10,8 @@ abstract class FeaturesFlowDelegate { void featuresFetchedSuccessfully({required GBFeatures gbFeatures, required bool isRemote}); void featuresAPIModelSuccessfully(FeaturedDataModel model); void featuresFetchFailed({required GBError? error, required bool isRemote}); + void savedGroupsFetchedSuccessfully({required SavedGroupsValues savedGroups, required bool isRemote}); + void savedGroupsFetchFailed({required GBError? error, required bool isRemote}); } class FeatureDataSource { diff --git a/lib/src/Features/features_view_model.dart b/lib/src/Features/features_view_model.dart index ade19b5..ea73f86 100644 --- a/lib/src/Features/features_view_model.dart +++ b/lib/src/Features/features_view_model.dart @@ -136,6 +136,9 @@ class FeatureViewModel { if (data.encryptedFeatures != null) { handleEncryptedFeatures(data.encryptedFeatures!); } + if (data.encryptedSavedGroups != null) { + handleEncryptedSavedGroups(data.encryptedSavedGroups!); + } } } @@ -179,6 +182,46 @@ class FeatureViewModel { } } + void handleEncryptedSavedGroups(String encryptedSavedGroups) { + if (encryptedSavedGroups.isEmpty) { + logError("Failed to parse encrypted data."); + return; + } + + if (encryptionKey.isEmpty) { + logError("Encryption key is missing."); + return; + } + + try { + final crypto = Crypto(); + final extractedSavedGroups = crypto.getSavedGroupsFromEncryptedFeatures( + encryptedSavedGroups, + encryptionKey, + ); + + if (extractedSavedGroups != null) { + delegate.savedGroupsFetchedSuccessfully(savedGroups: extractedSavedGroups, isRemote: false); + final savedGroupsData = utf8Encoder.convert(jsonEncode(extractedSavedGroups)); + final savedGroupsDataOnUint8List = Uint8List.fromList(savedGroupsData); + manager.putData( + fileName: Constant.savedGroupsCache, + content: savedGroupsDataOnUint8List, + ); + } else { + logError("Failed to extract savedGroups from encrypted string."); + } + } catch (e, s) { + delegate.savedGroupsFetchFailed( + error: GBError( + error: e, + stackTrace: s.toString(), + ), + isRemote: false, + ); + } + } + void handleException(dynamic e, dynamic s) { delegate.featuresFetchFailed( error: GBError( diff --git a/lib/src/Model/context.dart b/lib/src/Model/context.dart index 9750c64..7faade0 100644 --- a/lib/src/Model/context.dart +++ b/lib/src/Model/context.dart @@ -20,6 +20,7 @@ class GBContext { this.featureUsageCallback, this.features = const {}, this.backgroundSync = false, + this.savedGroups, }); /// Registered API key for GrowthBook SDK. @@ -64,6 +65,8 @@ class GBContext { ///Disable background streaming connection bool backgroundSync; + SavedGroupsValues? savedGroups; + String? getFeaturesURL() { if (hostURL != null && apiKey != null) { return '${hostURL}api/features/$apiKey'; diff --git a/lib/src/Model/features_model.dart b/lib/src/Model/features_model.dart index 02110e8..c8a575b 100644 --- a/lib/src/Model/features_model.dart +++ b/lib/src/Model/features_model.dart @@ -9,6 +9,8 @@ class FeaturedDataModel { FeaturedDataModel({ required this.features, required this.encryptedFeatures, + this.savedGroups, + this.encryptedSavedGroups, }); @GBFeaturesConverter() @@ -16,6 +18,10 @@ class FeaturedDataModel { final String? encryptedFeatures; + final SavedGroupsValues? savedGroups; + + final String? encryptedSavedGroups; + factory FeaturedDataModel.fromJson(Map json) => _$FeaturedDataModelFromJson(json); Map toJson() => _$FeaturedDataModelToJson(this); diff --git a/lib/src/Model/features_model.g.dart b/lib/src/Model/features_model.g.dart index cf16dbf..1bf3157 100644 --- a/lib/src/Model/features_model.g.dart +++ b/lib/src/Model/features_model.g.dart @@ -12,6 +12,10 @@ FeaturedDataModel _$FeaturedDataModelFromJson(Map json) => _$JsonConverterFromJson, Map>( json['features'], const GBFeaturesConverter().fromJson), encryptedFeatures: json['encryptedFeatures'] as String?, + savedGroups: (json['savedGroups'] as Map?)?.map( + (k, e) => MapEntry(k, e as List), + ), + encryptedSavedGroups: json['encryptedSavedGroups'] as String?, ); Map _$FeaturedDataModelToJson(FeaturedDataModel instance) => @@ -20,6 +24,8 @@ Map _$FeaturedDataModelToJson(FeaturedDataModel instance) => _$JsonConverterToJson, Map>( instance.features, const GBFeaturesConverter().toJson), 'encryptedFeatures': instance.encryptedFeatures, + 'savedGroups': instance.savedGroups, + 'encryptedSavedGroups': instance.encryptedSavedGroups, }; Value? _$JsonConverterFromJson( diff --git a/lib/src/Utils/constant.dart b/lib/src/Utils/constant.dart index 054c5d1..caed860 100644 --- a/lib/src/Utils/constant.dart +++ b/lib/src/Utils/constant.dart @@ -10,6 +10,8 @@ class Constant { /// Identifier for Caching Feature Data in Internal Storage File static String featureCache = 'featureCache'; + static String savedGroupsCache = 'savedGroupCache'; + /// Context Path for Fetching Feature Details - Web Service static String featurePath = 'api/features'; @@ -44,6 +46,8 @@ typedef TrackingCallBack = void Function(GBExperiment, GBExperimentResult); typedef GBFeatureUsageCallback = void Function(String, GBFeatureResult); +typedef SavedGroupsValues = Map; + /// GrowthBook Error Class to handle any error / exception scenario class GBError { diff --git a/lib/src/Utils/crypto.dart b/lib/src/Utils/crypto.dart index 8319254..50ff413 100644 --- a/lib/src/Utils/crypto.dart +++ b/lib/src/Utils/crypto.dart @@ -1,7 +1,7 @@ import 'dart:convert'; import 'dart:developer'; import 'dart:typed_data'; -import 'package:growthbook_sdk_flutter/src/Model/features.dart'; +import 'package:growthbook_sdk_flutter/growthbook_sdk_flutter.dart'; import 'package:pointycastle/export.dart'; // Dart equivalent of CryptoProtocol abstract class @@ -9,6 +9,7 @@ abstract class CryptoProtocol { List encrypt(Uint8List key, Uint8List iv, Uint8List plainText); List decrypt(Uint8List key, Uint8List iv, Uint8List cypherText); Map? getFeaturesFromEncryptedFeatures(String encryptedString, String encryptionKey); + SavedGroupsValues? getSavedGroupsFromEncryptedFeatures(String encryptedString, String encryptionKey); } // Dart equivalent of Crypto class @@ -29,7 +30,7 @@ class Crypto implements CryptoProtocol { ParametersWithIV params = ParametersWithIV(KeyParameter(key), iv); PaddedBlockCipherParameters paddingParams = - PaddedBlockCipherParameters(params, null); + PaddedBlockCipherParameters(params, null); PaddedBlockCipherImpl paddingCipher = PaddedBlockCipherImpl(PKCS7Padding(), cipher); paddingCipher.init(true, paddingParams); @@ -58,7 +59,7 @@ class Crypto implements CryptoProtocol { ParametersWithIV params = ParametersWithIV(KeyParameter(key), iv); PaddedBlockCipherParameters paddingParams = - PaddedBlockCipherParameters(params, null); + PaddedBlockCipherParameters(params, null); PaddedBlockCipherImpl paddingCipher = PaddedBlockCipherImpl(PKCS7Padding(), cipher); paddingCipher.init(false, paddingParams); @@ -69,9 +70,7 @@ class Crypto implements CryptoProtocol { return decryptedBytes; } - @override - Map? getFeaturesFromEncryptedFeatures( - String encryptedString, String encryptionKey) { + Map? _decryptString(String encryptedString, String encryptionKey) { final List arrayEncryptedString = encryptedString.split('.'); final String iv = arrayEncryptedString.first; final String cipherText = arrayEncryptedString.last; @@ -80,18 +79,29 @@ class Crypto implements CryptoProtocol { final Uint8List ivBase64 = base64Decode(iv); final Uint8List cipherTextBase64 = base64Decode(cipherText); try { - final List plainTextBuffer = - decrypt(keyBase64, ivBase64, cipherTextBase64); - final Map decodedMap = - jsonDecode(utf8.decode(plainTextBuffer)); + final List plainTextBuffer = decrypt(keyBase64, ivBase64, cipherTextBase64); + return jsonDecode(utf8.decode(plainTextBuffer)); + } catch (e) { + log('Error decrypting: $e'); + } + return null; + } + + @override + Map? getFeaturesFromEncryptedFeatures(String encryptedString, String encryptionKey) { + final Map? decodedMap = _decryptString(encryptedString, encryptionKey); + if (decodedMap != null) { final Map features = decodedMap.map((key, value) { return MapEntry(key, GBFeature.fromJson(value)); }); return features; - } catch (e) { - log('Error decrypting: $e'); } - return null; + return null; + } + + @override + SavedGroupsValues? getSavedGroupsFromEncryptedFeatures(String encryptedString, String encryptionKey) { + return _decryptString(encryptedString, encryptionKey); } } @@ -100,4 +110,4 @@ class CryptoError implements Exception { CryptoError(this.code); CryptoError.fromString(this.code); -} \ No newline at end of file +} diff --git a/lib/src/growth_book_sdk.dart b/lib/src/growth_book_sdk.dart index 7104787..aa441a7 100644 --- a/lib/src/growth_book_sdk.dart +++ b/lib/src/growth_book_sdk.dart @@ -106,10 +106,12 @@ class GrowthBookSDK extends FeaturesFlowDelegate { BaseClient? client, CacheRefreshHandler? refreshHandler, GBFeatures? gbFeatures, + SavedGroupsValues? savedGroups, }) : _context = context, _onInitializationFailure = onInitializationFailure, _refreshHandler = refreshHandler, _gbFeatures = gbFeatures, + _savedGroups = savedGroups, _baseClient = client ?? DioClient(), _forcedFeatures = [], _attributeOverrides = {}; @@ -124,6 +126,8 @@ class GrowthBookSDK extends FeaturesFlowDelegate { final GBFeatures? _gbFeatures; + final SavedGroupsValues? _savedGroups; + List _forcedFeatures; Map _attributeOverrides; @@ -166,6 +170,9 @@ class GrowthBookSDK extends FeaturesFlowDelegate { if (_gbFeatures != null) { _context.features = _gbFeatures!; } + if (_savedGroups != null) { + _context.savedGroups = _savedGroups!; + } if (_context.backgroundSync) { await featureViewModel.connectBackgroundSync(); } @@ -264,13 +271,30 @@ class GrowthBookSDK extends FeaturesFlowDelegate { ); } - /// The evalFeature method takes a single string argument, which is the unique identifier for the feature and returns a FeatureResult object. + /// The evalFeature method takes a single string argument, which is the unique identifier for the feature and returns a FeatureResult object. GBFeatureResult evalFeature(String id) { - return FeatureEvaluator(context: context, featureKey: id, attributeOverrides: _attributeOverrides).evaluateFeature(); + return FeatureEvaluator(context: context, featureKey: id, attributeOverrides: _attributeOverrides) + .evaluateFeature(); } /// The isOn method takes a single string argument, which is the unique identifier for the feature and returns the feature state on/off bool isOn(String id) { return evalFeature(id).on; } + + @override + void savedGroupsFetchFailed({required GBError? error, required bool isRemote}) { + _onInitializationFailure?.call(error); + if (_refreshHandler != null) { + _refreshHandler!(false); + } + } + + @override + void savedGroupsFetchedSuccessfully({required SavedGroupsValues savedGroups, required bool isRemote}) { + _context.savedGroups = savedGroups; + if (_refreshHandler != null) { + _refreshHandler!(true); + } + } } diff --git a/pubspec.yaml b/pubspec.yaml index a6d7097..d3dd326 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: growthbook_sdk_flutter description: An open-source feature flagging and experimentation platform that makes it simple to alter features and execute A/B testing. -version: 3.9.3 +version: 3.9.4 homepage: https://github.com/alippo-com/GrowthBook-SDK-Flutter repository: https://github.com/growthbook/growthbook-flutter diff --git a/test/Helper/gb_test_helper.dart b/test/Helper/gb_test_helper.dart index a6597f8..0ba93f5 100644 --- a/test/Helper/gb_test_helper.dart +++ b/test/Helper/gb_test_helper.dart @@ -2,6 +2,7 @@ import 'dart:convert'; import 'package:growthbook_sdk_flutter/growthbook_sdk_flutter.dart'; import 'package:growthbook_sdk_flutter/src/Model/sticky_assignments_document.dart'; +import 'package:growthbook_sdk_flutter/src/Utils/utils.dart'; import '../test_cases/test_case.dart'; @@ -69,10 +70,8 @@ class GBFeaturesTest { features: (map['features'] as Map?)?.map( (key, value) => MapEntry(key, GBFeature.fromJson(value)), ), - stickyBucketAssignmentDocs: - (map['stickyBucketAssignmentDocs'] as Map?)?.map( - (key, value) => - MapEntry(key, StickyAssignmentsDocument.fromJson(value)), + stickyBucketAssignmentDocs: (map['stickyBucketAssignmentDocs'] as Map?)?.map( + (key, value) => MapEntry(key, StickyAssignmentsDocument.fromJson(value)), ), ); } @@ -93,15 +92,12 @@ class GBFeatureResultTest { String? source; GBExperimentResultTest? experimentResult; GBExperiment? experiment; - factory GBFeatureResultTest.fromMap(Map map) => - GBFeatureResultTest( + factory GBFeatureResultTest.fromMap(Map map) => GBFeatureResultTest( value: map['value'], on: map['on'], off: map['off'], source: map['source'], - experiment: map['experiment'] != null - ? GBExperiment.fromJson(map['experiment']) - : null, + experiment: map['experiment'] != null ? GBExperiment.fromJson(map['experiment']) : null, experimentResult: map['experimentResult'] != null ? GBExperimentResultTest.fromMap( map['experimentResult'], @@ -117,6 +113,7 @@ class GBContextTest { this.qaMode = false, this.enabled = true, this.forcedVariations, + this.savedGroups, }); dynamic attributes; @@ -124,16 +121,17 @@ class GBContextTest { bool qaMode; bool enabled; Map? forcedVariations; + SavedGroupsValues? savedGroups; factory GBContextTest.fromMap(Map map) => GBContextTest( attributes: map['attributes'], - features: (map['features'] as Map?)?.map( - (key, value) => MapEntry( - key, GBFeature.fromJson(value as Map))) ?? + features: (map['features'] as Map?) + ?.map((key, value) => MapEntry(key, GBFeature.fromJson(value as Map))) ?? {}, qaMode: map['qaMode'] ?? false, enabled: map['enabled'] ?? true, forcedVariations: map['forcedVariations'], + savedGroups: map['savedGroups'], ); } @@ -189,8 +187,7 @@ class GBExperimentResultTest { /// If sticky bucketing was used to assign a variation bool? stickyBucketUsed; - factory GBExperimentResultTest.fromMap(Map map) => - GBExperimentResultTest( + factory GBExperimentResultTest.fromMap(Map map) => GBExperimentResultTest( value: map['value'], inExperiment: map['inExperiment'], variationId: map['variationId'], diff --git a/test/common_test/gb_condition_test.dart b/test/common_test/gb_condition_test.dart index 69a4f6f..65e330f 100644 --- a/test/common_test/gb_condition_test.dart +++ b/test/common_test/gb_condition_test.dart @@ -19,7 +19,7 @@ void main() { final passedScenarios = []; for (final item in evaluateCondition) { final evaluator = GBConditionEvaluator(); - final result = evaluator.isEvalCondition(item[2], item[1]); + final result = evaluator.isEvalCondition(item[2], item[1], item.length == 5 ? item[4] : {}); final status = "${item[0]}\nExpected Result - ${item[3]}\nActual result - $result\n\n"; if (item[3].toString() == result.toString()) { passedScenarios.add(status); @@ -29,8 +29,6 @@ void main() { } index++; } - print(failedScenarios); - // print(passedScenarios.length); expect(failedScenarios.length, 0); customLogger('Passed Test ${passedScenarios.length} out of ${evaluateCondition.length}'); }); @@ -38,23 +36,23 @@ void main() { test('Test valid condition obj', () { final evaluator = GBConditionEvaluator(); - expect(evaluator.isEvalCondition({}, []), false); + expect(evaluator.isEvalCondition({}, [], {}), false); expect(evaluator.isOperatorObject({}), false); expect(evaluator.getPath('test', 'key'), null); - expect(evaluator.isEvalConditionValue({}, null), false); + expect(evaluator.isEvalConditionValue({}, null, {}), false); - expect(evaluator.evalOperatorCondition("\$lte", "abc", "abc"), true); + expect(evaluator.evalOperatorCondition("\$lte", "abc", "abc", {}), true); - expect(evaluator.evalOperatorCondition("\$gte", "abc", "abc"), true); + expect(evaluator.evalOperatorCondition("\$gte", "abc", "abc", {}), true); - expect(evaluator.evalOperatorCondition("\$vlt", "0.9.0", "0.10.0"), true); + expect(evaluator.evalOperatorCondition("\$vlt", "0.9.0", "0.10.0", {}), true); - expect(evaluator.evalOperatorCondition("\$in", "abc", ["abc"]), true); + expect(evaluator.evalOperatorCondition("\$in", "abc", ["abc"], {}), true); - expect(evaluator.evalOperatorCondition("\$nin", "abc", ["abc"]), false); + expect(evaluator.evalOperatorCondition("\$nin", "abc", ["abc"], {}), false); }); }); @@ -71,6 +69,7 @@ void main() { GBConditionEvaluator().isEvalCondition( jsonDecode(attributes), jsonDecode(condition), + {}, ), false, ); @@ -93,6 +92,7 @@ void main() { GBConditionEvaluator().isEvalCondition( jsonDecode(attributes), jsonDecode(condition), + {}, ), false, ); @@ -115,6 +115,7 @@ void main() { GBConditionEvaluator().isEvalCondition( jsonDecode(attributes), jsonDecode(condition), + {}, ), true, ); @@ -137,6 +138,7 @@ void main() { GBConditionEvaluator().isEvalCondition( jsonDecode(attributes), jsonDecode(condition), + {}, ), false, ); diff --git a/test/common_test/gb_experiment_run_test.dart b/test/common_test/gb_experiment_run_test.dart index c70abbf..c5ae39a 100644 --- a/test/common_test/gb_experiment_run_test.dart +++ b/test/common_test/gb_experiment_run_test.dart @@ -34,15 +34,14 @@ void main() { trackingCallBack: (_, __) {}, backgroundSync: false, features: testContext.features, + savedGroups: testContext.savedGroups, ); - final result = ExperimentEvaluator(attributeOverrides: {}) - .evaluateExperiment(gbContext, experiment); + final result = ExperimentEvaluator(attributeOverrides: {}).evaluateExperiment(gbContext, experiment); final status = "${item[0]}\nExpected Result - ${item[3]} & ${item[4]}\nActual result - ${result.value} & ${result.inExperiment}\n\n"; - if (item[3].toString() == result.value.toString() && - item[4] == result.inExperiment) { + if (item[3].toString() == result.value.toString() && item[4] == result.inExperiment) { passedScenarios.add(status); } else { failedScenarios.add(status); @@ -51,8 +50,7 @@ void main() { failingIndex++; } } - customLogger( - 'Passed Test ${passedScenarios.length} out of ${evaluateCondition.length}'); + customLogger('Passed Test ${passedScenarios.length} out of ${evaluateCondition.length}'); expect(failedScenarios.length, 0); }); }); diff --git a/test/common_test/gb_feature_value_test.dart b/test/common_test/gb_feature_value_test.dart index 3aa2a64..c764f56 100644 --- a/test/common_test/gb_feature_value_test.dart +++ b/test/common_test/gb_feature_value_test.dart @@ -18,6 +18,7 @@ void main() { final passedScenarios = []; for (final item in evaluateCondition) { + final testContext = GBContextTest.fromMap(item[1]); final testData = GBFeaturesTest.fromMap(item[1]); final gbContext = GBContext( @@ -28,6 +29,7 @@ void main() { forcedVariation: testData.forcedVariations, trackingCallBack: (_, __) {}, backgroundSync: false, + savedGroups: testContext.savedGroups, ); if (testData.features != null) { gbContext.features = testData.features!; diff --git a/test/mocks/network_view_model_mock.dart b/test/mocks/network_view_model_mock.dart index 096a62c..19b43a3 100644 --- a/test/mocks/network_view_model_mock.dart +++ b/test/mocks/network_view_model_mock.dart @@ -23,4 +23,16 @@ class DataSourceMock extends FeaturesFlowDelegate { _isError = true; _isSuccess = false; } + + @override + void savedGroupsFetchFailed({required GBError? error, required bool isRemote}) { + _isError = true; + _isSuccess = false; + } + + @override + void savedGroupsFetchedSuccessfully({required SavedGroupsValues savedGroups, required bool isRemote}) { + _isSuccess = true; + _isError = false; + } } diff --git a/test/test_cases/test_case.dart b/test/test_cases/test_case.dart index 9914f1a..c52b53f 100644 --- a/test/test_cases/test_case.dart +++ b/test/test_cases/test_case.dart @@ -3055,7 +3055,79 @@ const String gbTestCases = r''' "empty": 1 }, true - ] + ], + [ + "$inGroup passes for member of known group id", + { + "id": { "$inGroup": "group_id" } + }, + { "id": 1 }, + true, + { "group_id": [1, 2, 3] } + ], + [ + "$inGroup fails for non-member of known group id", + { + "id": { "$inGroup": "group_id" } + }, + { "id": 5 }, + false, + { "group_id": [1, 2, 3] } + ], + [ + "$inGroup fails for unknown group id", + { + "id": { "$inGroup": "unknowngroup_id" } + }, + { "id": 1 }, + false, + { "group_id": [1, 2, 3] } + ], + [ + "$notInGroup fails for member of known group id", + { + "id": { "$notInGroup": "group_id" } + }, + { "id": 1 }, + false, + { "group_id": [1, 2, 3] } + ], + [ + "$notInGroup passes for non-member of known group id", + { + "id": { "$notInGroup": "group_id" } + }, + { "id": 5 }, + true, + { "group_id": [1, 2, 3] } + ], + [ + "$notInGroup passes for unknown group id", + { + "id": { "$notInGroup": "unknowngroup_id" } + }, + { "id": 1 }, + true, + { "group_id": [1, 2, 3] } + ], + [ + "$inGroup passes for properly typed data", + { + "id": { "$inGroup": "group_id" } + }, + { "id": "2" }, + true, + { "group_id": [1, "2", 3] } + ], + [ + "$inGroup fails for improperly typed data", + { + "id": { "$inGroup": "group_id" } + }, + { "id": "3" }, + false, + { "group_id": [1, "2", 3] } + ] ], "hash": [ [ @@ -5173,6 +5245,87 @@ const String gbTestCases = r''' "off": true, "source": "cyclicPrerequisite" } + ], + [ + "SavedGroups correctly pulled from context for force rule", + { + "attributes": { + "id": 123 + }, + "features": { + "inGroup_force_rule": { + "defaultValue": false, + "rules": [ + { + "force": true, + "condition": { "id": { "$inGroup": "group_id" } } + } + ] + } + }, + "savedGroups": { + "group_id": [123, 456] + } + }, + "inGroup_force_rule", + { "value": true, "on": true, "off": false, "source": "force" } + ], + [ + "SavedGroups correctly pulled from context for experiment rule", + { + "attributes": { + "id": 123 + }, + "features": { + "inGroup_experiment_rule": { + "defaultValue": 0, + "rules": [ + { + "key": "experiment", + "condition": { "id": { "$inGroup": "group_id" } }, + "hashVersion": 2, + "variations": [1, 2], + "ranges": [ + [0, 0.5], + [0.5, 1.0] + ] + } + ] + } + }, + "savedGroups": { + "group_id": [123, 456] + } + }, + "inGroup_experiment_rule", + { + "value": 1, + "on": true, + "off": false, + "source": "experiment", + "experiment": { + "hashVersion": 2, + "condition": { "id": { "$inGroup": "group_id" } }, + "variations": [1, 2], + "ranges": [ + [0, 0.5], + [0.5, 1.0] + ], + "key": "experiment" + }, + "experimentResult": { + "featureId": "inGroup_experiment_rule", + "hashAttribute": "id", + "hashUsed": true, + "hashValue": 123, + "inExperiment": true, + "key": "0", + "value": 1, + "variationId": 0, + "bucket": 0.1736, + "stickyBucketUsed": false + } + } ] ], "run": [ @@ -6673,6 +6826,23 @@ const String gbTestCases = r''' 0, false, false + ], + [ + "SavedGroups correctly pulled from context for experiment", + { + "attributes": { "id": "4" }, + "savedGroups": { "group_id": ["4", "5", "6"] } + }, + { + "key": "group-filtered-test", + "condition": { + "id": { "$inGroup": "group_id" } + }, + "variations": [0, 1, 2] + }, + 0, + true, + true ] ], "chooseVariation": [