diff --git a/src/main/java/com/google/firebase/remoteconfig/AndCondition.java b/src/main/java/com/google/firebase/remoteconfig/AndCondition.java new file mode 100644 index 000000000..ec118cef2 --- /dev/null +++ b/src/main/java/com/google/firebase/remoteconfig/AndCondition.java @@ -0,0 +1,61 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.remoteconfig; + +import static com.google.common.base.Preconditions.checkArgument; +import static com.google.common.base.Preconditions.checkNotNull; + +import com.google.common.collect.ImmutableList; +import com.google.firebase.internal.NonNull; +import com.google.firebase.remoteconfig.internal.ServerTemplateResponse.AndConditionResponse; +import com.google.firebase.remoteconfig.internal.ServerTemplateResponse.OneOfConditionResponse; + +import java.util.List; +import java.util.stream.Collectors; + +final class AndCondition { + private final ImmutableList conditions; + + AndCondition(@NonNull List conditions) { + checkNotNull(conditions, "List of conditions for AND operation must not be null."); + checkArgument(!conditions.isEmpty(), + "List of conditions for AND operation must not be empty."); + this.conditions = ImmutableList.copyOf(conditions); + } + + AndCondition(AndConditionResponse andConditionResponse) { + List conditionList = andConditionResponse.getConditions(); + checkNotNull(conditionList, "List of conditions for AND operation must not be null."); + checkArgument(!conditionList.isEmpty(), + "List of conditions for AND operation must not be empty"); + this.conditions = conditionList.stream() + .map(OneOfCondition::new) + .collect(ImmutableList.toImmutableList()); + } + + @NonNull + ImmutableList getConditions() { + return conditions; + } + + AndConditionResponse toAndConditionResponse() { + return new AndConditionResponse() + .setConditions(this.conditions.stream() + .map(OneOfCondition::toOneOfConditionResponse) + .collect(Collectors.toList())); + } +} diff --git a/src/main/java/com/google/firebase/remoteconfig/ConditionEvaluator.java b/src/main/java/com/google/firebase/remoteconfig/ConditionEvaluator.java new file mode 100644 index 000000000..63718b32b --- /dev/null +++ b/src/main/java/com/google/firebase/remoteconfig/ConditionEvaluator.java @@ -0,0 +1,346 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.remoteconfig; + +import static com.google.common.base.Preconditions.checkArgument; +import static com.google.common.base.Preconditions.checkNotNull; +import static com.google.common.collect.ImmutableMap.toImmutableMap; + +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; +import com.google.firebase.internal.NonNull; +import com.google.firebase.internal.Nullable; +import java.math.BigInteger; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.function.BiPredicate; +import java.util.function.IntPredicate; +import java.util.regex.Pattern; +import java.util.regex.PatternSyntaxException; +import java.util.stream.Collectors; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +final class ConditionEvaluator { + private static final int MAX_CONDITION_RECURSION_DEPTH = 10; + private static final Logger logger = LoggerFactory.getLogger(ConditionEvaluator.class); + private static final BigInteger MICRO_PERCENT_MODULO = BigInteger.valueOf(100_000_000L); + private static final Pattern SEMVER_PATTERN = Pattern.compile("^[0-9]+(?:\\.[0-9]+){0,4}$"); + + /** + * Evaluates server conditions and assigns a boolean value to each condition. + * + * @param conditions List of conditions which are to be evaluated. + * @param context A map with additional metadata used during evaluation. + * @return A map of condition to evaluated value. + */ + @NonNull + Map evaluateConditions( + @NonNull List conditions, @Nullable KeysAndValues context) { + checkNotNull(conditions, "List of conditions must not be null."); + checkArgument(!conditions.isEmpty(), "List of conditions must not be empty."); + if (context == null || conditions.isEmpty()) { + return ImmutableMap.of(); + } + KeysAndValues evaluationContext = + context != null ? context : new KeysAndValues.Builder().build(); + + Map evaluatedConditions = + conditions.stream() + .collect( + toImmutableMap( + ServerCondition::getName, + condition -> + evaluateCondition( + condition.getCondition(), evaluationContext, /* nestingLevel= */ 0))); + + return evaluatedConditions; + } + + private boolean evaluateCondition( + OneOfCondition condition, KeysAndValues context, int nestingLevel) { + if (nestingLevel > MAX_CONDITION_RECURSION_DEPTH) { + logger.warn("Maximum condition recursion depth exceeded."); + return false; + } + + if (condition.getOrCondition() != null) { + return evaluateOrCondition(condition.getOrCondition(), context, nestingLevel + 1); + } else if (condition.getAndCondition() != null) { + return evaluateAndCondition(condition.getAndCondition(), context, nestingLevel + 1); + } else if (condition.isTrue() != null) { + return true; + } else if (condition.isFalse() != null) { + return false; + } else if (condition.getCustomSignal() != null) { + return evaluateCustomSignalCondition(condition.getCustomSignal(), context); + } else if (condition.getPercent() != null) { + return evaluatePercentCondition(condition.getPercent(), context); + } + logger.atWarn().log("Received invalid condition for evaluation."); + return false; + } + + private boolean evaluateOrCondition( + OrCondition condition, KeysAndValues context, int nestingLevel) { + return condition.getConditions().stream() + .anyMatch(subCondition -> evaluateCondition(subCondition, context, nestingLevel + 1)); + } + + private boolean evaluateAndCondition( + AndCondition condition, KeysAndValues context, int nestingLevel) { + return condition.getConditions().stream() + .allMatch(subCondition -> evaluateCondition(subCondition, context, nestingLevel + 1)); + } + + private boolean evaluateCustomSignalCondition( + CustomSignalCondition condition, KeysAndValues context) { + CustomSignalOperator customSignalOperator = condition.getCustomSignalOperator(); + String customSignalKey = condition.getCustomSignalKey(); + ImmutableList targetCustomSignalValues = + ImmutableList.copyOf(condition.getTargetCustomSignalValues()); + + if (targetCustomSignalValues.isEmpty()) { + logger.warn( + String.format( + "Values must be assigned to all custom signal fields. Operator:%s, Key:%s, Values:%s", + customSignalOperator, customSignalKey, targetCustomSignalValues)); + return false; + } + + String customSignalValue = context.get(customSignalKey); + if (customSignalValue == null) { + return false; + } + + switch (customSignalOperator) { + // String operations. + case STRING_CONTAINS: + return compareStrings( + targetCustomSignalValues, + customSignalValue, + (customSignal, targetSignal) -> customSignal.contains(targetSignal)); + case STRING_DOES_NOT_CONTAIN: + return !compareStrings( + targetCustomSignalValues, + customSignalValue, + (customSignal, targetSignal) -> customSignal.contains(targetSignal)); + case STRING_EXACTLY_MATCHES: + return compareStrings( + targetCustomSignalValues, + customSignalValue, + (customSignal, targetSignal) -> customSignal.equals(targetSignal)); + case STRING_CONTAINS_REGEX: + return compareStrings( + targetCustomSignalValues, + customSignalValue, + (customSignal, targetSignal) -> compareStringRegex(customSignal, targetSignal)); + + // Numeric operations. + case NUMERIC_LESS_THAN: + return compareNumbers(targetCustomSignalValues, customSignalValue, (result) -> result < 0); + case NUMERIC_LESS_EQUAL: + return compareNumbers(targetCustomSignalValues, customSignalValue, (result) -> result <= 0); + case NUMERIC_EQUAL: + return compareNumbers(targetCustomSignalValues, customSignalValue, (result) -> result == 0); + case NUMERIC_NOT_EQUAL: + return compareNumbers(targetCustomSignalValues, customSignalValue, (result) -> result != 0); + case NUMERIC_GREATER_THAN: + return compareNumbers(targetCustomSignalValues, customSignalValue, (result) -> result > 0); + case NUMERIC_GREATER_EQUAL: + return compareNumbers(targetCustomSignalValues, customSignalValue, (result) -> result >= 0); + + // Semantic operations. + case SEMANTIC_VERSION_EQUAL: + return compareSemanticVersions( + targetCustomSignalValues, customSignalValue, (result) -> result == 0); + case SEMANTIC_VERSION_GREATER_EQUAL: + return compareSemanticVersions( + targetCustomSignalValues, customSignalValue, (result) -> result >= 0); + case SEMANTIC_VERSION_GREATER_THAN: + return compareSemanticVersions( + targetCustomSignalValues, customSignalValue, (result) -> result > 0); + case SEMANTIC_VERSION_LESS_EQUAL: + return compareSemanticVersions( + targetCustomSignalValues, customSignalValue, (result) -> result <= 0); + case SEMANTIC_VERSION_LESS_THAN: + return compareSemanticVersions( + targetCustomSignalValues, customSignalValue, (result) -> result < 0); + case SEMANTIC_VERSION_NOT_EQUAL: + return compareSemanticVersions( + targetCustomSignalValues, customSignalValue, (result) -> result != 0); + default: + return false; + } + } + + private boolean evaluatePercentCondition(PercentCondition condition, KeysAndValues context) { + if (!context.containsKey("randomizationId")) { + logger.warn("Percentage operation must not be performed without randomizationId"); + return false; + } + + PercentConditionOperator operator = condition.getPercentConditionOperator(); + + // The micro-percent interval to be used with the BETWEEN operator. + MicroPercentRange microPercentRange = condition.getMicroPercentRange(); + int microPercentUpperBound = + microPercentRange != null ? microPercentRange.getMicroPercentUpperBound() : 0; + int microPercentLowerBound = + microPercentRange != null ? microPercentRange.getMicroPercentLowerBound() : 0; + // The limit of percentiles to target in micro-percents when using the + // LESS_OR_EQUAL and GREATER_THAN operators. The value must be in the range [0 + // and 100000000]. + int microPercent = condition.getMicroPercent(); + BigInteger microPercentile = + getMicroPercentile(condition.getSeed(), context.get("randomizationId")); + switch (operator) { + case LESS_OR_EQUAL: + return microPercentile.compareTo(BigInteger.valueOf(microPercent)) <= 0; + case GREATER_THAN: + return microPercentile.compareTo(BigInteger.valueOf(microPercent)) > 0; + case BETWEEN: + return microPercentile.compareTo(BigInteger.valueOf(microPercentLowerBound)) > 0 + && microPercentile.compareTo(BigInteger.valueOf(microPercentUpperBound)) <= 0; + case UNSPECIFIED: + default: + return false; + } + } + + private BigInteger getMicroPercentile(String seed, String randomizationId) { + String seedPrefix = seed != null && !seed.isEmpty() ? seed + "." : ""; + String stringToHash = seedPrefix + randomizationId; + BigInteger hash = hashSeededRandomizationId(stringToHash); + BigInteger microPercentile = hash.mod(MICRO_PERCENT_MODULO); + + return microPercentile; + } + + private BigInteger hashSeededRandomizationId(String seededRandomizationId) { + try { + // Create a SHA-256 hash. + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] hashBytes = digest.digest(seededRandomizationId.getBytes(StandardCharsets.UTF_8)); + + // Convert the hash bytes to a BigInteger + return new BigInteger(1, hashBytes); + } catch (NoSuchAlgorithmException e) { + logger.error("SHA-256 algorithm not found", e); + throw new RuntimeException("SHA-256 algorithm not found", e); + } + } + + private boolean compareStrings( + ImmutableList targetValues, + String customSignal, + BiPredicate compareFunction) { + return targetValues.stream() + .anyMatch(targetValue -> compareFunction.test(customSignal, targetValue)); + } + + private boolean compareStringRegex(String customSignal, String targetSignal) { + try { + return Pattern.compile(targetSignal).matcher(customSignal).matches(); + } catch (PatternSyntaxException e) { + return false; + } + } + + private boolean compareNumbers( + ImmutableList targetValues, String customSignal, IntPredicate compareFunction) { + if (targetValues.size() != 1) { + logger.warn( + String.format( + "Target values must contain 1 element for numeric operations. Target Value: %s", + targetValues)); + return false; + } + + try { + double customSignalDouble = Double.parseDouble(customSignal); + double targetValue = Double.parseDouble(targetValues.get(0)); + int comparisonResult = Double.compare(customSignalDouble, targetValue); + return compareFunction.test(comparisonResult); + } catch (NumberFormatException e) { + logger.warn( + "Error parsing numeric values: customSignal=%s, targetValue=%s", + customSignal, targetValues.get(0), e); + return false; + } + } + + private boolean compareSemanticVersions( + ImmutableList targetValues, String customSignal, IntPredicate compareFunction) { + if (targetValues.size() != 1) { + logger.warn(String.format("Target values must contain 1 element for semantic operation.")); + return false; + } + + String targetValueString = targetValues.get(0); + if (!validateSemanticVersion(targetValueString) || !validateSemanticVersion(customSignal)) { + return false; + } + + List targetVersion = parseSemanticVersion(targetValueString); + List customSignalVersion = parseSemanticVersion(customSignal); + + int maxLength = 5; + if (targetVersion.size() > maxLength || customSignalVersion.size() > maxLength) { + logger.warn( + "Semantic version max length(%s) exceeded. Target: %s, Custom Signal: %s", + maxLength, targetValueString, customSignal); + return false; + } + + int comparison = compareSemanticVersions(customSignalVersion, targetVersion); + return compareFunction.test(comparison); + } + + private int compareSemanticVersions(List version1, List version2) { + int maxLength = Math.max(version1.size(), version2.size()); + int version1Size = version1.size(); + int version2Size = version2.size(); + + for (int i = 0; i < maxLength; i++) { + // Default to 0 if segment is missing + int v1 = i < version1Size ? version1.get(i) : 0; + int v2 = i < version2Size ? version2.get(i) : 0; + + int comparison = Integer.compare(v1, v2); + if (comparison != 0) { + return comparison; + } + } + // Versions are equal + return 0; + } + + private List parseSemanticVersion(String versionString) { + return Arrays.stream(versionString.split("\\.")) + .map(Integer::parseInt) + .collect(Collectors.toList()); + } + + private boolean validateSemanticVersion(String version) { + return SEMVER_PATTERN.matcher(version).matches(); + } +} diff --git a/src/main/java/com/google/firebase/remoteconfig/CustomSignalCondition.java b/src/main/java/com/google/firebase/remoteconfig/CustomSignalCondition.java new file mode 100644 index 000000000..a8d96efdf --- /dev/null +++ b/src/main/java/com/google/firebase/remoteconfig/CustomSignalCondition.java @@ -0,0 +1,140 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.remoteconfig; + +import static com.google.common.base.Preconditions.checkArgument; +import static com.google.common.base.Preconditions.checkNotNull; + +import com.google.common.base.Strings; +import com.google.common.collect.ImmutableList; +import com.google.firebase.internal.NonNull; +import com.google.firebase.remoteconfig.internal.ServerTemplateResponse.CustomSignalConditionResponse; + +import java.util.ArrayList; +import java.util.List; + +final class CustomSignalCondition { + private final String customSignalKey; + private final CustomSignalOperator customSignalOperator; + private final ImmutableList targetCustomSignalValues; + + public CustomSignalCondition( + @NonNull String customSignalKey, + @NonNull CustomSignalOperator customSignalOperator, + @NonNull List targetCustomSignalValues) { + checkArgument( + !Strings.isNullOrEmpty(customSignalKey), "Custom signal key must not be null or empty."); + checkNotNull(customSignalOperator); + checkNotNull(targetCustomSignalValues); + checkArgument( + !targetCustomSignalValues.isEmpty(), "Target custom signal values must not be empty."); + this.customSignalKey = customSignalKey.trim(); + this.customSignalOperator = customSignalOperator; + this.targetCustomSignalValues = ImmutableList.copyOf(targetCustomSignalValues); + } + + CustomSignalCondition(CustomSignalConditionResponse customSignalCondition) { + checkArgument( + !Strings.isNullOrEmpty(customSignalCondition.getKey()), + "Custom signal key must not be null or empty."); + checkArgument( + !customSignalCondition.getTargetValues().isEmpty(), + "Target custom signal values must not be empty."); + this.customSignalKey = customSignalCondition.getKey().trim(); + List targetCustomSignalValuesList = customSignalCondition.getTargetValues(); + this.targetCustomSignalValues = ImmutableList.copyOf(targetCustomSignalValuesList); + switch (customSignalCondition.getOperator()) { + case "NUMERIC_EQUAL": + this.customSignalOperator = CustomSignalOperator.NUMERIC_EQUAL; + break; + case "NUMERIC_GREATER_EQUAL": + this.customSignalOperator = CustomSignalOperator.NUMERIC_GREATER_EQUAL; + break; + case "NUMERIC_GREATER_THAN": + this.customSignalOperator = CustomSignalOperator.NUMERIC_GREATER_THAN; + break; + case "NUMERIC_LESS_EQUAL": + this.customSignalOperator = CustomSignalOperator.NUMERIC_LESS_EQUAL; + break; + case "NUMERIC_LESS_THAN": + this.customSignalOperator = CustomSignalOperator.NUMERIC_LESS_THAN; + break; + case "NUMERIC_NOT_EQUAL": + this.customSignalOperator = CustomSignalOperator.NUMERIC_NOT_EQUAL; + break; + case "SEMANTIC_VERSION_EQUAL": + this.customSignalOperator = CustomSignalOperator.SEMANTIC_VERSION_EQUAL; + break; + case "SEMANTIC_VERSION_GREATER_EQUAL": + this.customSignalOperator = CustomSignalOperator.SEMANTIC_VERSION_GREATER_EQUAL; + break; + case "SEMANTIC_VERSION_GREATER_THAN": + this.customSignalOperator = CustomSignalOperator.SEMANTIC_VERSION_GREATER_THAN; + break; + case "SEMANTIC_VERSION_LESS_EQUAL": + this.customSignalOperator = CustomSignalOperator.SEMANTIC_VERSION_LESS_EQUAL; + break; + case "SEMANTIC_VERSION_LESS_THAN": + this.customSignalOperator = CustomSignalOperator.SEMANTIC_VERSION_LESS_THAN; + break; + case "SEMANTIC_VERSION_NOT_EQUAL": + this.customSignalOperator = CustomSignalOperator.SEMANTIC_VERSION_NOT_EQUAL; + break; + case "STRING_CONTAINS": + this.customSignalOperator = CustomSignalOperator.STRING_CONTAINS; + break; + case "STRING_CONTAINS_REGEX": + this.customSignalOperator = CustomSignalOperator.STRING_CONTAINS_REGEX; + break; + case "STRING_DOES_NOT_CONTAIN": + this.customSignalOperator = CustomSignalOperator.STRING_DOES_NOT_CONTAIN; + break; + case "STRING_EXACTLY_MATCHES": + this.customSignalOperator = CustomSignalOperator.STRING_EXACTLY_MATCHES; + break; + default: + this.customSignalOperator = CustomSignalOperator.UNSPECIFIED; + } + checkArgument( + this.customSignalOperator != CustomSignalOperator.UNSPECIFIED, + "Custom signal operator passed is invalid"); + } + + @NonNull + String getCustomSignalKey() { + return customSignalKey; + } + + @NonNull + CustomSignalOperator getCustomSignalOperator() { + return customSignalOperator; + } + + @NonNull + List getTargetCustomSignalValues() { + return new ArrayList<>(targetCustomSignalValues); + } + + CustomSignalConditionResponse toCustomConditonResponse() { + CustomSignalConditionResponse customSignalConditionResponse = + new CustomSignalConditionResponse(); + customSignalConditionResponse.setKey(this.customSignalKey); + customSignalConditionResponse.setOperator(this.customSignalOperator.getOperator()); + customSignalConditionResponse.setTargetValues(this.targetCustomSignalValues); + return customSignalConditionResponse; + } +} diff --git a/src/main/java/com/google/firebase/remoteconfig/CustomSignalOperator.java b/src/main/java/com/google/firebase/remoteconfig/CustomSignalOperator.java new file mode 100644 index 000000000..0c0924d62 --- /dev/null +++ b/src/main/java/com/google/firebase/remoteconfig/CustomSignalOperator.java @@ -0,0 +1,54 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.remoteconfig; + +import static com.google.common.base.Preconditions.checkArgument; + +import com.google.common.base.Strings; +import com.google.firebase.internal.NonNull; + +enum CustomSignalOperator { + NUMERIC_EQUAL("NUMERIC_EQUAL"), + NUMERIC_GREATER_EQUAL("NUMERIC_GREATER_EQUAL"), + NUMERIC_GREATER_THAN("NUMERIC_GREATER_THAN"), + NUMERIC_LESS_EQUAL("NUMERIC_LESS_EQUAL"), + NUMERIC_LESS_THAN("NUMERIC_LESS_THAN"), + NUMERIC_NOT_EQUAL("NUMERIC_NOT_EQUAL"), + SEMANTIC_VERSION_EQUAL("SEMANTIC_VERSION_EQUAL"), + SEMANTIC_VERSION_GREATER_EQUAL("SEMANTIC_VERSION_GREATER_EQUAL"), + SEMANTIC_VERSION_GREATER_THAN("SEMANTIC_VERSION_GREATER_THAN"), + SEMANTIC_VERSION_LESS_EQUAL("SEMANTIC_VERSION_LESS_EQUAL"), + SEMANTIC_VERSION_LESS_THAN("SEMANTIC_VERSION_LESS_THAN"), + SEMANTIC_VERSION_NOT_EQUAL("SEMANTIC_VERSION_NOT_EQUAL"), + STRING_CONTAINS("STRING_CONTAINS"), + STRING_CONTAINS_REGEX("STRING_CONTAINS_REGEX"), + STRING_DOES_NOT_CONTAIN("STRING_DOES_NOT_CONTAIN"), + STRING_EXACTLY_MATCHES("STRING_EXACTLY_MATCHES"), + UNSPECIFIED("CUSTOM_SIGNAL_OPERATOR_UNSPECIFIED"); + + private final String operator; + + CustomSignalOperator(@NonNull String operator) { + checkArgument(!Strings.isNullOrEmpty(operator), "Operator must not be null or empty."); + this.operator = operator; + } + + @NonNull + String getOperator() { + return operator; + } +} diff --git a/src/main/java/com/google/firebase/remoteconfig/FirebaseRemoteConfig.java b/src/main/java/com/google/firebase/remoteconfig/FirebaseRemoteConfig.java index 41a0afbe4..e3edce4e4 100644 --- a/src/main/java/com/google/firebase/remoteconfig/FirebaseRemoteConfig.java +++ b/src/main/java/com/google/firebase/remoteconfig/FirebaseRemoteConfig.java @@ -101,6 +101,73 @@ protected Template execute() throws FirebaseRemoteConfigException { }; } + /** + * Alternative to {@link #getServerTemplate} where developers can initialize with a pre-cached + * template or config. + */ + public ServerTemplateImpl.Builder serverTemplateBuilder() { + return new ServerTemplateImpl.Builder(this.remoteConfigClient); + } + + /** + * Initializes a template instance and loads the latest template data. + * + * @param defaultConfig Default parameter values to use if a getter references a parameter not + * found in the template. + * @return A {@link Template} instance with the latest template data. + */ + public ServerTemplate getServerTemplate(KeysAndValues defaultConfig) + throws FirebaseRemoteConfigException { + return getServerTemplateOp(defaultConfig).call(); + } + + /** + * Initializes a template instance without any defaults and loads the latest template data. + * + * @return A {@link Template} instance with the latest template data. + */ + public ServerTemplate getServerTemplate() throws FirebaseRemoteConfigException { + return getServerTemplate(null); + } + + /** + * Initializes a template instance and asynchronously loads the latest template data. + * + * @param defaultConfig Default parameter values to use if a getter references a parameter not + * found in the template. + * @return A {@link Template} instance with the latest template data. + */ + public ApiFuture getServerTemplateAsync(KeysAndValues defaultConfig) { + return getServerTemplateOp(defaultConfig).callAsync(app); + } + + /** + * Initializes a template instance without any defaults and asynchronously loads the latest + * template data. + * + * @return A {@link Template} instance with the latest template data. + */ + public ApiFuture getServerTemplateAsync() { + return getServerTemplateAsync(null); + } + + private CallableOperation getServerTemplateOp( + KeysAndValues defaultConfig) { + return new CallableOperation() { + @Override + protected ServerTemplate execute() throws FirebaseRemoteConfigException { + String serverTemplateData = remoteConfigClient.getServerTemplate(); + ServerTemplate template = + serverTemplateBuilder() + .defaultConfig(defaultConfig) + .cachedTemplate(serverTemplateData) + .build(); + + return template; + } + }; + } + /** * Gets the requested version of the of the Remote Config template. * @@ -413,3 +480,4 @@ public void destroy() { } } } + diff --git a/src/main/java/com/google/firebase/remoteconfig/FirebaseRemoteConfigClient.java b/src/main/java/com/google/firebase/remoteconfig/FirebaseRemoteConfigClient.java index 9fdb596d6..2143d07d1 100644 --- a/src/main/java/com/google/firebase/remoteconfig/FirebaseRemoteConfigClient.java +++ b/src/main/java/com/google/firebase/remoteconfig/FirebaseRemoteConfigClient.java @@ -40,4 +40,7 @@ Template publishTemplate(Template template, boolean validateOnly, ListVersionsResponse listVersions( ListVersionsOptions options) throws FirebaseRemoteConfigException; + + String getServerTemplate() throws FirebaseRemoteConfigException; } + diff --git a/src/main/java/com/google/firebase/remoteconfig/FirebaseRemoteConfigClientImpl.java b/src/main/java/com/google/firebase/remoteconfig/FirebaseRemoteConfigClientImpl.java index 7425673fb..d84abae84 100644 --- a/src/main/java/com/google/firebase/remoteconfig/FirebaseRemoteConfigClientImpl.java +++ b/src/main/java/com/google/firebase/remoteconfig/FirebaseRemoteConfigClientImpl.java @@ -38,6 +38,7 @@ import com.google.firebase.internal.NonNull; import com.google.firebase.internal.SdkUtils; import com.google.firebase.remoteconfig.internal.RemoteConfigServiceErrorResponse; +import com.google.firebase.remoteconfig.internal.ServerTemplateResponse; import com.google.firebase.remoteconfig.internal.TemplateResponse; import java.io.IOException; @@ -51,6 +52,9 @@ final class FirebaseRemoteConfigClientImpl implements FirebaseRemoteConfigClient private static final String REMOTE_CONFIG_URL = "https://firebaseremoteconfig.googleapis.com/v1/projects/%s/remoteConfig"; + private static final String SERVER_REMOTE_CONFIG_URL = + "https://firebaseremoteconfig.googleapis.com/v1/projects/%s/namespaces/firebase-server/serverRemoteConfig"; + private static final Map COMMON_HEADERS = ImmutableMap.of( "X-Firebase-Client", "fire-admin-java/" + SdkUtils.getVersion(), @@ -62,6 +66,7 @@ final class FirebaseRemoteConfigClientImpl implements FirebaseRemoteConfigClient ); private final String remoteConfigUrl; + private final String serverRemoteConfigUrl; private final HttpRequestFactory requestFactory; private final JsonFactory jsonFactory; private final ErrorHandlingHttpClient httpClient; @@ -69,6 +74,7 @@ final class FirebaseRemoteConfigClientImpl implements FirebaseRemoteConfigClient private FirebaseRemoteConfigClientImpl(Builder builder) { checkArgument(!Strings.isNullOrEmpty(builder.projectId)); this.remoteConfigUrl = String.format(REMOTE_CONFIG_URL, builder.projectId); + this.serverRemoteConfigUrl = String.format(SERVER_REMOTE_CONFIG_URL, builder.projectId); this.requestFactory = checkNotNull(builder.requestFactory); this.jsonFactory = checkNotNull(builder.jsonFactory); HttpResponseInterceptor responseInterceptor = builder.responseInterceptor; @@ -82,6 +88,11 @@ String getRemoteConfigUrl() { return remoteConfigUrl; } + @VisibleForTesting + String getServerRemoteConfigUrl() { + return serverRemoteConfigUrl; + } + @VisibleForTesting HttpRequestFactory getRequestFactory() { return requestFactory; @@ -102,6 +113,18 @@ public Template getTemplate() throws FirebaseRemoteConfigException { return template.setETag(getETag(response)); } + @Override + public String getServerTemplate() throws FirebaseRemoteConfigException { + HttpRequestInfo request = + HttpRequestInfo.buildGetRequest(serverRemoteConfigUrl).addAllHeaders(COMMON_HEADERS); + IncomingHttpResponse response = httpClient.send(request); + ServerTemplateResponse templateResponse = httpClient.parse(response, + ServerTemplateResponse.class); + ServerTemplateData serverTemplateData = new ServerTemplateData(templateResponse); + serverTemplateData.setETag(getETag(response)); + return serverTemplateData.toJSON(); + } + @Override public Template getTemplateAtVersion( @NonNull String versionNumber) throws FirebaseRemoteConfigException { @@ -267,3 +290,4 @@ private RemoteConfigServiceErrorResponse safeParse(String response) { } } } + diff --git a/src/main/java/com/google/firebase/remoteconfig/KeysAndValues.java b/src/main/java/com/google/firebase/remoteconfig/KeysAndValues.java new file mode 100644 index 000000000..47b159bf7 --- /dev/null +++ b/src/main/java/com/google/firebase/remoteconfig/KeysAndValues.java @@ -0,0 +1,135 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.remoteconfig; + +import static com.google.common.base.Preconditions.checkArgument; + +import com.google.common.base.Strings; +import com.google.common.collect.ImmutableMap; +import com.google.firebase.internal.NonNull; + +import java.util.HashMap; +import java.util.Map; + +/** + * Represents data stored in context passed to server-side Remote Config. + */ +public class KeysAndValues { + final ImmutableMap keysAndValues; + + private KeysAndValues(@NonNull Builder builder) { + keysAndValues = ImmutableMap.builder().putAll(builder.keysAndValues).build(); + } + + /** + * Checks whether a key is present in the context. + * + * @param key The key for data stored in context. + * @return Boolean representing whether the key passed is present in context. + */ + public boolean containsKey(String key) { + return keysAndValues.containsKey(key); + } + + /** + * Gets the value of the data stored in context. + * + * @param key The key for data stored in context. + * @return Value assigned to the key in context. + */ + public String get(String key) { + return keysAndValues.get(key); + } + + /** + * Builder class for KeysAndValues using which values will be assigned to + * private variables. + */ + public static class Builder { + // Holds the converted pairs of custom keys and values. + private final Map keysAndValues = new HashMap<>(); + + /** + * Adds a context data with string value. + * + * @param key Identifies the value in context. + * @param value Value assigned to the context. + * @return Reference to class itself so that more data can be added. + */ + @NonNull + public Builder put(@NonNull String key, @NonNull String value) { + checkArgument(!Strings.isNullOrEmpty(key), "Context key must not be null or empty."); + checkArgument(!Strings.isNullOrEmpty(value), "Context key must not be null or empty."); + keysAndValues.put(key, value); + return this; + } + + /** + * Adds a context data with boolean value. + * + * @param key Identifies the value in context. + * @param value Value assigned to the context. + * @return Reference to class itself so that more data can be added. + */ + @NonNull + public Builder put(@NonNull String key, boolean value) { + checkArgument(!Strings.isNullOrEmpty(key), "Context key must not be null or empty."); + keysAndValues.put(key, Boolean.toString(value)); + return this; + } + + /** + * Adds a context data with double value. + * + * @param key Identifies the value in context. + * @param value Value assigned to the context. + * @return Reference to class itself so that more data can be added. + */ + @NonNull + public Builder put(@NonNull String key, double value) { + checkArgument(!Strings.isNullOrEmpty(key), "Context key must not be null or empty."); + keysAndValues.put(key, Double.toString(value)); + return this; + } + + /** + * Adds a context data with long value. + * + * @param key Identifies the value in context. + * @param value Value assigned to the context. + * @return Reference to class itself so that more data can be added. + */ + @NonNull + public Builder put(@NonNull String key, long value) { + checkArgument(!Strings.isNullOrEmpty(key), "Context key must not be null or empty."); + keysAndValues.put(key, Long.toString(value)); + return this; + } + + /** + * Creates an instance of KeysAndValues with the values assigned through + * builder. + * + * @return instance of KeysAndValues + */ + @NonNull + public KeysAndValues build() { + return new KeysAndValues(this); + } + } +} + diff --git a/src/main/java/com/google/firebase/remoteconfig/MicroPercentRange.java b/src/main/java/com/google/firebase/remoteconfig/MicroPercentRange.java new file mode 100644 index 000000000..abd5711c6 --- /dev/null +++ b/src/main/java/com/google/firebase/remoteconfig/MicroPercentRange.java @@ -0,0 +1,46 @@ +/* +* Copyright 2025 Google LLC +* +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. +*/ + +package com.google.firebase.remoteconfig; + +import com.google.firebase.internal.Nullable; +import com.google.firebase.remoteconfig.internal.ServerTemplateResponse.MicroPercentRangeResponse; + +class MicroPercentRange { + private final int microPercentLowerBound; + private final int microPercentUpperBound; + + public MicroPercentRange(@Nullable Integer microPercentLowerBound, + @Nullable Integer microPercentUpperBound) { + this.microPercentLowerBound = microPercentLowerBound != null ? microPercentLowerBound : 0; + this.microPercentUpperBound = microPercentUpperBound != null ? microPercentUpperBound : 0; + } + + int getMicroPercentLowerBound() { + return microPercentLowerBound; + } + + int getMicroPercentUpperBound() { + return microPercentUpperBound; + } + + MicroPercentRangeResponse toMicroPercentRangeResponse() { + MicroPercentRangeResponse microPercentRangeResponse = new MicroPercentRangeResponse(); + microPercentRangeResponse.setMicroPercentLowerBound(this.microPercentLowerBound); + microPercentRangeResponse.setMicroPercentUpperBound(this.microPercentUpperBound); + return microPercentRangeResponse; + } +} diff --git a/src/main/java/com/google/firebase/remoteconfig/OneOfCondition.java b/src/main/java/com/google/firebase/remoteconfig/OneOfCondition.java new file mode 100644 index 000000000..be3f5fd3e --- /dev/null +++ b/src/main/java/com/google/firebase/remoteconfig/OneOfCondition.java @@ -0,0 +1,140 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.remoteconfig; + +import static com.google.common.base.Preconditions.checkNotNull; + +import com.google.common.annotations.VisibleForTesting; +import com.google.firebase.internal.NonNull; +import com.google.firebase.internal.Nullable; +import com.google.firebase.remoteconfig.internal.ServerTemplateResponse.OneOfConditionResponse; + +class OneOfCondition { + private OrCondition orCondition; + private AndCondition andCondition; + private PercentCondition percent; + private CustomSignalCondition customSignal; + private String trueValue; + private String falseValue; + + OneOfCondition(OneOfConditionResponse oneOfconditionResponse) { + if (oneOfconditionResponse.getOrCondition() != null) { + this.orCondition = new OrCondition(oneOfconditionResponse.getOrCondition()); + } + if (oneOfconditionResponse.getAndCondition() != null) { + this.andCondition = new AndCondition(oneOfconditionResponse.getAndCondition()); + } + if (oneOfconditionResponse.getPercentCondition() != null) { + this.percent = new PercentCondition(oneOfconditionResponse.getPercentCondition()); + } + if (oneOfconditionResponse.getCustomSignalCondition() != null) { + this.customSignal = + new CustomSignalCondition(oneOfconditionResponse.getCustomSignalCondition()); + } + } + + @VisibleForTesting + OneOfCondition() { + this.orCondition = null; + this.andCondition = null; + this.percent = null; + this.trueValue = null; + this.falseValue = null; + } + + @Nullable + OrCondition getOrCondition() { + return orCondition; + } + + @Nullable + AndCondition getAndCondition() { + return andCondition; + } + + @Nullable + String isTrue() { + return trueValue; + } + + @Nullable + String isFalse() { + return falseValue; + } + + @Nullable + PercentCondition getPercent() { + return percent; + } + + @Nullable + CustomSignalCondition getCustomSignal() { + return customSignal; + } + + OneOfCondition setOrCondition(@NonNull OrCondition orCondition) { + checkNotNull(orCondition, "`Or` condition cannot be set to null."); + this.orCondition = orCondition; + return this; + } + + OneOfCondition setAndCondition(@NonNull AndCondition andCondition) { + checkNotNull(andCondition, "`And` condition cannot be set to null."); + this.andCondition = andCondition; + return this; + } + + OneOfCondition setPercent(@NonNull PercentCondition percent) { + checkNotNull(percent, "`Percent` condition cannot be set to null."); + this.percent = percent; + return this; + } + + OneOfCondition setCustomSignal(@NonNull CustomSignalCondition customSignal) { + checkNotNull(customSignal, "`Custom signal` condition cannot be set to null."); + this.customSignal = customSignal; + return this; + } + + OneOfCondition setTrue() { + this.trueValue = "true"; + return this; + } + + OneOfCondition setFalse() { + this.falseValue = "false"; + return this; + } + + OneOfConditionResponse toOneOfConditionResponse() { + OneOfConditionResponse oneOfConditionResponse = new OneOfConditionResponse(); + if (this.andCondition != null) { + oneOfConditionResponse.setAndCondition(this.andCondition.toAndConditionResponse()); + } + if (this.orCondition != null) { + oneOfConditionResponse.setOrCondition(this.orCondition.toOrConditionResponse()); + } + if (this.customSignal != null) { + oneOfConditionResponse.setCustomSignalCondition(this.customSignal.toCustomConditonResponse()); + } + if (this.percent != null) { + oneOfConditionResponse.setPercentCondition(this.percent.toPercentConditionResponse()); + } + return oneOfConditionResponse; + } +} + diff --git a/src/main/java/com/google/firebase/remoteconfig/OrCondition.java b/src/main/java/com/google/firebase/remoteconfig/OrCondition.java new file mode 100644 index 000000000..36a1c682a --- /dev/null +++ b/src/main/java/com/google/firebase/remoteconfig/OrCondition.java @@ -0,0 +1,58 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.remoteconfig; + +import static com.google.common.base.Preconditions.checkArgument; +import static com.google.common.base.Preconditions.checkNotNull; + +import com.google.common.collect.ImmutableList; +import com.google.firebase.internal.NonNull; +import com.google.firebase.remoteconfig.internal.ServerTemplateResponse.OneOfConditionResponse; +import com.google.firebase.remoteconfig.internal.ServerTemplateResponse.OrConditionResponse; +import java.util.List; +import java.util.stream.Collectors; + +final class OrCondition { + private final ImmutableList conditions; + + public OrCondition(@NonNull List conditions) { + checkNotNull(conditions, "List of conditions for OR operation must not be null."); + checkArgument(!conditions.isEmpty(), "List of conditions for OR operation must not be empty."); + this.conditions = ImmutableList.copyOf(conditions); + } + + OrCondition(OrConditionResponse orConditionResponse) { + List conditionList = orConditionResponse.getConditions(); + checkNotNull(conditionList, "List of conditions for AND operation cannot be null."); + checkArgument(!conditionList.isEmpty(), "List of conditions for AND operation cannot be empty"); + this.conditions = conditionList.stream() + .map(OneOfCondition::new) + .collect(ImmutableList.toImmutableList()); + } + + @NonNull + ImmutableList getConditions() { + return conditions; + } + + OrConditionResponse toOrConditionResponse() { + return new OrConditionResponse() + .setConditions(this.conditions.stream() + .map(OneOfCondition::toOneOfConditionResponse) + .collect(Collectors.toList())); + } +} diff --git a/src/main/java/com/google/firebase/remoteconfig/PercentCondition.java b/src/main/java/com/google/firebase/remoteconfig/PercentCondition.java new file mode 100644 index 000000000..c5763200b --- /dev/null +++ b/src/main/java/com/google/firebase/remoteconfig/PercentCondition.java @@ -0,0 +1,163 @@ +/* +* Copyright 2025 Google LLC +* +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. +*/ + +package com.google.firebase.remoteconfig; + +import static com.google.common.base.Preconditions.checkArgument; +import static com.google.common.base.Preconditions.checkNotNull; + +import com.google.common.base.Strings; +import com.google.firebase.internal.NonNull; +import com.google.firebase.internal.Nullable; +import com.google.firebase.remoteconfig.internal.ServerTemplateResponse.PercentConditionResponse; + +/** Represents a condition that compares the instance pseudo-random percentile to a given limit. */ +public final class PercentCondition { + private int microPercent; + private MicroPercentRange microPercentRange; + private final PercentConditionOperator percentConditionOperator; + private final String seed; + + /** + * Create a percent condition for operator BETWEEN. + * + * @param microPercent The limit of percentiles to target in micro-percents when using the + * LESS_OR_EQUAL and GREATER_THAN operators. The value must be in the range [0 and 100000000]. + * @param percentConditionOperator The choice of percent operator to determine how to compare + * targets to percent(s). + * @param seed The seed used when evaluating the hash function to map an instance to a value in + * the hash space. This is a string which can have 0 - 32 characters and can contain ASCII + * characters [-_.0-9a-zA-Z].The string is case-sensitive. + */ + PercentCondition( + @Nullable Integer microPercent, + @NonNull PercentConditionOperator percentConditionOperator, + @NonNull String seed) { + checkNotNull(percentConditionOperator, "Percentage operator must not be null."); + checkArgument(!Strings.isNullOrEmpty(seed), "Seed must not be null or empty."); + this.microPercent = microPercent != null ? microPercent : 0; + this.percentConditionOperator = percentConditionOperator; + this.seed = seed; + } + + /** + * Create a percent condition for operators GREATER_THAN and LESS_OR_EQUAL. + * + * @param microPercentRange The micro-percent interval to be used with the BETWEEN operator. + * @param percentConditionOperator The choice of percent operator to determine how to compare + * targets to percent(s). + * @param seed The seed used when evaluating the hash function to map an instance to a value in + * the hash space. This is a string which can have 0 - 32 characters and can contain ASCII + * characters [-_.0-9a-zA-Z].The string is case-sensitive. + */ + PercentCondition( + @NonNull MicroPercentRange microPercentRange, + @NonNull PercentConditionOperator percentConditionOperator, + String seed) { + checkNotNull(microPercentRange, "Percent range must not be null."); + checkNotNull(percentConditionOperator, "Percentage operator must not be null."); + this.microPercentRange = microPercentRange; + this.percentConditionOperator = percentConditionOperator; + this.seed = seed; + } + + /** + * Creates a new {@link PercentCondition} from API response. + * + * @param percentCondition the conditions obtained from server call. + */ + PercentCondition(PercentConditionResponse percentCondition) { + checkArgument( + !Strings.isNullOrEmpty(percentCondition.getSeed()), "Seed must not be empty or null"); + this.microPercent = percentCondition.getMicroPercent(); + this.seed = percentCondition.getSeed(); + switch (percentCondition.getPercentOperator()) { + case "BETWEEN": + this.percentConditionOperator = PercentConditionOperator.BETWEEN; + break; + case "GREATER_THAN": + this.percentConditionOperator = PercentConditionOperator.GREATER_THAN; + break; + case "LESS_OR_EQUAL": + this.percentConditionOperator = PercentConditionOperator.LESS_OR_EQUAL; + break; + default: + this.percentConditionOperator = PercentConditionOperator.UNSPECIFIED; + } + checkArgument( + this.percentConditionOperator != PercentConditionOperator.UNSPECIFIED, + "Percentage operator is invalid"); + if (percentCondition.getMicroPercentRange() != null) { + this.microPercentRange = + new MicroPercentRange( + percentCondition.getMicroPercentRange().getMicroPercentLowerBound(), + percentCondition.getMicroPercentRange().getMicroPercentUpperBound()); + } + } + + /** + * Gets the limit of percentiles to target in micro-percents when using the LESS_OR_EQUAL and + * GREATER_THAN operators. The value must be in the range [0 and 100000000]. + * + * @return micro percent. + */ + @Nullable + public int getMicroPercent() { + return microPercent; + } + + /** + * Gets micro-percent interval to be used with the BETWEEN operator. + * + * @return micro percent range. + */ + @Nullable + public MicroPercentRange getMicroPercentRange() { + return microPercentRange; + } + + /** + * Gets choice of percent operator to determine how to compare targets to percent(s). + * + * @return operator. + */ + @NonNull + public PercentConditionOperator getPercentConditionOperator() { + return percentConditionOperator; + } + + /** + * The seed used when evaluating the hash function to map an instance to a value in the hash + * space. This is a string which can have 0 - 32 characters and can contain ASCII characters + * [-_.0-9a-zA-Z].The string is case-sensitive. + * + * @return seed. + */ + @NonNull + public String getSeed() { + return seed; + } + + PercentConditionResponse toPercentConditionResponse() { + PercentConditionResponse percentConditionResponse = new PercentConditionResponse(); + percentConditionResponse.setMicroPercent(this.microPercent); + percentConditionResponse.setMicroPercentRange( + this.microPercentRange.toMicroPercentRangeResponse()); + percentConditionResponse.setPercentOperator(this.percentConditionOperator.getOperator()); + percentConditionResponse.setSeed(this.seed); + return percentConditionResponse; + } +} diff --git a/src/main/java/com/google/firebase/remoteconfig/PercentConditionOperator.java b/src/main/java/com/google/firebase/remoteconfig/PercentConditionOperator.java new file mode 100644 index 000000000..478f13e4e --- /dev/null +++ b/src/main/java/com/google/firebase/remoteconfig/PercentConditionOperator.java @@ -0,0 +1,55 @@ +/* +* Copyright 2025 Google LLC +* +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. +*/ + +package com.google.firebase.remoteconfig; + +import static com.google.common.base.Preconditions.checkArgument; + +import com.google.common.base.Strings; +import com.google.firebase.internal.NonNull; + +/** +* Defines supported operators for percent conditions. +*/ +public enum PercentConditionOperator { + BETWEEN("BETWEEN"), + GREATER_THAN("GREATER_THAN"), + LESS_OR_EQUAL("LESS_OR_EQUAL"), + UNSPECIFIED("PERCENT_OPERATOR_UNSPECIFIED"); + + private final String operator; + + /** + * Creates percent condition operator. + * + * @param operator The choice of percent operator to determine how to compare targets to + * percent(s). + */ + PercentConditionOperator(@NonNull String operator) { + checkArgument(!Strings.isNullOrEmpty(operator), "Operator must not be null or empty."); + this.operator = operator; + } + + /** + * Gets percent condition operator. + * + * @return operator. + */ + @NonNull + public String getOperator() { + return operator; + } +} diff --git a/src/main/java/com/google/firebase/remoteconfig/ServerCondition.java b/src/main/java/com/google/firebase/remoteconfig/ServerCondition.java new file mode 100644 index 000000000..f16aeffc8 --- /dev/null +++ b/src/main/java/com/google/firebase/remoteconfig/ServerCondition.java @@ -0,0 +1,90 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.remoteconfig; + +import static com.google.common.base.Preconditions.checkArgument; +import static com.google.common.base.Preconditions.checkNotNull; + +import com.google.common.base.Strings; +import com.google.firebase.internal.NonNull; +import com.google.firebase.remoteconfig.internal.ServerTemplateResponse.ServerConditionResponse; + +import java.util.Objects; + +final class ServerCondition { + + private String name; + private OneOfCondition serverCondition; + + ServerCondition(@NonNull String name, @NonNull OneOfCondition condition) { + checkArgument(!Strings.isNullOrEmpty(name), "condition name must not be null or empty"); + this.name = name; + this.serverCondition = condition; + } + + ServerCondition(@NonNull ServerConditionResponse serverConditionResponse) { + checkNotNull(serverConditionResponse); + this.name = serverConditionResponse.getName(); + this.serverCondition = new OneOfCondition(serverConditionResponse.getServerCondition()); + } + + @NonNull + String getName() { + return name; + } + + @NonNull + OneOfCondition getCondition() { + return serverCondition; + } + + ServerCondition setName(@NonNull String name) { + checkArgument(!Strings.isNullOrEmpty(name), "condition name must not be null or empty"); + this.name = name; + return this; + } + + ServerCondition setServerCondition(@NonNull OneOfCondition condition) { + checkNotNull(condition, "condition must not be null or empty"); + this.serverCondition = condition; + return this; + } + + ServerConditionResponse toServerConditionResponse() { + return new ServerConditionResponse().setName(this.name) + .setServerCondition(this.serverCondition.toOneOfConditionResponse()); + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + ServerCondition condition = (ServerCondition) o; + return Objects.equals(name, condition.name) + && Objects.equals(serverCondition, condition.serverCondition); + } + + @Override + public int hashCode() { + return Objects.hash(name, serverCondition); + } +} + diff --git a/src/main/java/com/google/firebase/remoteconfig/ServerConfig.java b/src/main/java/com/google/firebase/remoteconfig/ServerConfig.java new file mode 100644 index 000000000..8540578b0 --- /dev/null +++ b/src/main/java/com/google/firebase/remoteconfig/ServerConfig.java @@ -0,0 +1,102 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.remoteconfig; + +import static com.google.common.base.Preconditions.checkArgument; + +import com.google.common.base.Strings; +import com.google.firebase.internal.NonNull; + +import java.util.Map; + +/** + * Represents the configuration produced by evaluating a server template. + */ +public final class ServerConfig { + private final Map configValues; + + ServerConfig(Map configValues) { + this.configValues = configValues; + } + + /** + * Gets the value for the given key as a string. Convenience method for calling + * serverConfig.getValue(key).asString(). + * + * @param key The name of the parameter. + * @return config value for the given key as string. + */ + @NonNull + public String getString(@NonNull String key) { + return this.getValue(key).asString(); + } + + /** + * Gets the value for the given key as a boolean.Convenience method for calling + * serverConfig.getValue(key).asBoolean(). + * + * @param key The name of the parameter. + * @return config value for the given key as boolean. + */ + @NonNull + public boolean getBoolean(@NonNull String key) { + return this.getValue(key).asBoolean(); + } + + /** + * Gets the value for the given key as long.Convenience method for calling + * serverConfig.getValue(key).asLong(). + * + * @param key The name of the parameter. + * @return config value for the given key as long. + */ + @NonNull + public long getLong(@NonNull String key) { + return this.getValue(key).asLong(); + } + + /** + * Gets the value for the given key as double.Convenience method for calling + * serverConfig.getValue(key).asDouble(). + * + * @param key The name of the parameter. + * @return config value for the given key as double. + */ + @NonNull + public double getDouble(@NonNull String key) { + return this.getValue(key).asDouble(); + } + + /** + * Gets the {@link ValueSource} for the given key. + * + * @param key The name of the parameter. + * @return config value source for the given key. + */ + @NonNull + public ValueSource getValueSource(@NonNull String key) { + return this.getValue(key).getSource(); + } + + private Value getValue(String key) { + checkArgument(!Strings.isNullOrEmpty(key), "Server config key cannot be null or empty."); + if (configValues.containsKey(key)) { + return configValues.get(key); + } + return new Value(ValueSource.STATIC); + } +} diff --git a/src/main/java/com/google/firebase/remoteconfig/ServerTemplate.java b/src/main/java/com/google/firebase/remoteconfig/ServerTemplate.java new file mode 100644 index 000000000..bd1a59940 --- /dev/null +++ b/src/main/java/com/google/firebase/remoteconfig/ServerTemplate.java @@ -0,0 +1,45 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.remoteconfig; + +import com.google.api.core.ApiFuture; + +public interface ServerTemplate { + public interface Builder { + + Builder defaultConfig(KeysAndValues config); + + Builder cachedTemplate(String templateJson); + + ServerTemplate build(); + } + /** + * Process the template data with a condition evaluator + * based on the provided context. + */ + ServerConfig evaluate(KeysAndValues context) throws FirebaseRemoteConfigException; + /** + * Process the template data without context. + */ + ServerConfig evaluate() throws FirebaseRemoteConfigException; + /** + * Fetches and caches the current active version of the project. + */ + ApiFuture load() throws FirebaseRemoteConfigException; + + String toJson(); +} diff --git a/src/main/java/com/google/firebase/remoteconfig/ServerTemplateData.java b/src/main/java/com/google/firebase/remoteconfig/ServerTemplateData.java new file mode 100644 index 000000000..59d51b51a --- /dev/null +++ b/src/main/java/com/google/firebase/remoteconfig/ServerTemplateData.java @@ -0,0 +1,215 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.remoteconfig; + +import static com.google.common.base.Preconditions.checkArgument; +import static com.google.common.base.Preconditions.checkNotNull; + +import com.google.api.client.json.JsonFactory; +import com.google.common.base.Strings; +import com.google.firebase.ErrorCode; +import com.google.firebase.internal.ApiClientUtils; +import com.google.firebase.internal.NonNull; +import com.google.firebase.remoteconfig.internal.ServerTemplateResponse; +import com.google.firebase.remoteconfig.internal.TemplateResponse; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +final class ServerTemplateData { + + private String etag; + private Map parameters; + private List serverConditions; + private Map parameterGroups; + private Version version; + + + ServerTemplateData(String etag) { + this.parameters = new HashMap<>(); + this.serverConditions = new ArrayList<>(); + this.parameterGroups = new HashMap<>(); + this.etag = etag; + } + + ServerTemplateData() { + this((String) null); + } + + ServerTemplateData(@NonNull ServerTemplateResponse serverTemplateResponse) { + checkNotNull(serverTemplateResponse); + this.parameters = new HashMap<>(); + this.serverConditions = new ArrayList<>(); + this.parameterGroups = new HashMap<>(); + if (serverTemplateResponse.getParameters() != null) { + for (Map.Entry entry : + serverTemplateResponse.getParameters().entrySet()) { + this.parameters.put(entry.getKey(), new Parameter(entry.getValue())); + } + } + if (serverTemplateResponse.getServerConditions() != null) { + for (ServerTemplateResponse.ServerConditionResponse conditionResponse : + serverTemplateResponse.getServerConditions()) { + this.serverConditions.add(new ServerCondition(conditionResponse)); + } + } + if (serverTemplateResponse.getParameterGroups() != null) { + for (Map.Entry entry : + serverTemplateResponse.getParameterGroups().entrySet()) { + this.parameterGroups.put(entry.getKey(), new ParameterGroup(entry.getValue())); + } + } + if (serverTemplateResponse.getVersion() != null) { + this.version = new Version(serverTemplateResponse.getVersion()); + } + this.etag = serverTemplateResponse.getEtag(); + } + + + static ServerTemplateData fromJSON(@NonNull String json) + throws FirebaseRemoteConfigException { + checkArgument(!Strings.isNullOrEmpty(json), "JSON String must not be null or empty."); + // using the default json factory as no rpc calls are made here + JsonFactory jsonFactory = ApiClientUtils.getDefaultJsonFactory(); + try { + ServerTemplateResponse serverTemplateResponse = + jsonFactory.createJsonParser(json).parseAndClose(ServerTemplateResponse.class); + return new ServerTemplateData(serverTemplateResponse); + } catch (IOException e) { + throw new FirebaseRemoteConfigException( + ErrorCode.INVALID_ARGUMENT, "Unable to parse JSON string."); + } + } + + + String getETag() { + return this.etag; + } + + + @NonNull + public Map getParameters() { + return this.parameters; + } + + @NonNull + List getServerConditions() { + return serverConditions; + } + + @NonNull + Map getParameterGroups() { + return parameterGroups; + } + + Version getVersion() { + return version; + } + + ServerTemplateData setParameters(@NonNull Map parameters) { + checkNotNull(parameters, "parameters must not be null."); + this.parameters = parameters; + return this; + } + + + ServerTemplateData setServerConditions(@NonNull List conditions) { + checkNotNull(conditions, "conditions must not be null."); + this.serverConditions = conditions; + return this; + } + + ServerTemplateData setParameterGroups( + @NonNull Map parameterGroups) { + checkNotNull(parameterGroups, "parameter groups must not be null."); + this.parameterGroups = parameterGroups; + return this; + } + + ServerTemplateData setVersion(Version version) { + this.version = version; + return this; + } + + String toJSON() { + JsonFactory jsonFactory = ApiClientUtils.getDefaultJsonFactory(); + try { + return jsonFactory.toString(this.toServerTemplateResponse(true)); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + ServerTemplateData setETag(String etag) { + this.etag = etag; + return this; + } + + ServerTemplateResponse toServerTemplateResponse(boolean includeAll) { + Map parameterResponses = new HashMap<>(); + for (Map.Entry entry : this.parameters.entrySet()) { + parameterResponses.put(entry.getKey(), entry.getValue().toParameterResponse()); + } + List serverConditionResponses = + new ArrayList<>(); + for (ServerCondition condition : this.serverConditions) { + serverConditionResponses.add(condition.toServerConditionResponse()); + } + Map parameterGroupResponse = new HashMap<>(); + for (Map.Entry entry : this.parameterGroups.entrySet()) { + parameterGroupResponse.put(entry.getKey(), entry.getValue().toParameterGroupResponse()); + } + TemplateResponse.VersionResponse versionResponse = + (this.version == null) ? null : this.version.toVersionResponse(includeAll); + ServerTemplateResponse serverTemplateResponse = + new ServerTemplateResponse() + .setParameters(parameterResponses) + .setServerConditions(serverConditionResponses) + .setParameterGroups(parameterGroupResponse) + .setVersion(versionResponse); + if (includeAll) { + return serverTemplateResponse.setEtag(this.etag); + } + return serverTemplateResponse; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + ServerTemplateData template = (ServerTemplateData) o; + return Objects.equals(etag, template.etag) + && Objects.equals(parameters, template.parameters) + && Objects.equals(serverConditions, template.serverConditions) + && Objects.equals(parameterGroups, template.parameterGroups) + && Objects.equals(version, template.version); + } + + @Override + public int hashCode() { + return Objects.hash(etag, parameters, serverConditions, parameterGroups, version); + } +} + diff --git a/src/main/java/com/google/firebase/remoteconfig/ServerTemplateImpl.java b/src/main/java/com/google/firebase/remoteconfig/ServerTemplateImpl.java new file mode 100644 index 000000000..742c19803 --- /dev/null +++ b/src/main/java/com/google/firebase/remoteconfig/ServerTemplateImpl.java @@ -0,0 +1,195 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.remoteconfig; + +import com.google.api.core.ApiFuture; +import com.google.api.core.ApiFutures; +import com.google.common.collect.ImmutableMap; +import com.google.firebase.ErrorCode; +import com.google.firebase.internal.Nullable; +import com.google.firebase.remoteconfig.internal.TemplateResponse.ParameterValueResponse; + +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.atomic.AtomicReference; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public final class ServerTemplateImpl implements ServerTemplate { + + private final KeysAndValues defaultConfig; + private FirebaseRemoteConfigClient client; + private AtomicReference cache; + private final AtomicReference cachedTemplate; + private static final Logger logger = LoggerFactory.getLogger(ServerTemplate.class); + + public static class Builder implements ServerTemplate.Builder { + private KeysAndValues defaultConfig; + private String cachedTemplate; + private FirebaseRemoteConfigClient client; + + Builder(FirebaseRemoteConfigClient remoteConfigClient) { + this.client = remoteConfigClient; + } + + @Override + public Builder defaultConfig(KeysAndValues config) { + this.defaultConfig = config; + return this; + } + + @Override + public Builder cachedTemplate(String templateJson) { + this.cachedTemplate = templateJson; + return this; + } + + @Override + public ServerTemplate build() { + return new ServerTemplateImpl(this); + } + } + + private ServerTemplateImpl(Builder builder) { + this.defaultConfig = builder.defaultConfig; + this.cachedTemplate = new AtomicReference<>(builder.cachedTemplate); + this.client = builder.client; + this.cache = new AtomicReference<>(null); + + String initialTemplate = this.cachedTemplate.get(); + try { + this.cache.set(ServerTemplateData.fromJSON(initialTemplate)); + } catch (FirebaseRemoteConfigException e) { + e.printStackTrace(); + } + } + + @Override + public ServerConfig evaluate(@Nullable KeysAndValues context) + throws FirebaseRemoteConfigException { + ServerTemplateData cachedData = this.cache.get(); + if (cachedData == null) { + throw new FirebaseRemoteConfigException(ErrorCode.FAILED_PRECONDITION, + "No Remote Config Server template in cache. Call load() before calling evaluate()."); + } + + Map configValues = new HashMap<>(); + ImmutableMap defaultConfigValues = defaultConfig.keysAndValues; + // Initializes configValue objects with default values. + for (String configName : defaultConfigValues.keySet()) { + configValues.put(configName, new Value(ValueSource.DEFAULT, + defaultConfigValues.get(configName))); + } + + ConditionEvaluator conditionEvaluator = new ConditionEvaluator(); + ImmutableMap evaluatedCondition = ImmutableMap.copyOf( + conditionEvaluator.evaluateConditions(cachedData.getServerConditions(), context)); + ImmutableMap parameters = ImmutableMap.copyOf(cachedData.getParameters()); + mergeDerivedConfigValues(evaluatedCondition, parameters, configValues); + + return new ServerConfig(configValues); + } + + @Override + public ServerConfig evaluate() throws FirebaseRemoteConfigException { + return evaluate(null); + } + + @Override + public ApiFuture load() throws FirebaseRemoteConfigException { + String serverTemplate = client.getServerTemplate(); + this.cachedTemplate.set(serverTemplate); + this.cache.set(ServerTemplateData.fromJSON(serverTemplate)); + return ApiFutures.immediateFuture(null); + } + + // Add getters or other methods as needed + public KeysAndValues getDefaultConfig() { + return defaultConfig; + } + + public String getCachedTemplate() { + return cachedTemplate.get(); + } + + @Override + public String toJson() { + ServerTemplateData currentCache = this.cache.get(); + if (currentCache == null) { + return "{}"; + } + return currentCache.toJSON(); + } + + private void mergeDerivedConfigValues(ImmutableMap evaluatedCondition, + ImmutableMap parameters, Map configValues) { + for (String parameterName : parameters.keySet()) { + Parameter parameter = parameters.get(parameterName); + if (parameter == null) { + logger.warn(String.format("Parameter value is not assigned for %s", parameterName)); + continue; + } + + ImmutableMap conditionalValues = ImmutableMap.copyOf( + parameter.getConditionalValues()); + ParameterValue derivedValue = null; + + // Iterates in order over condition list. If there is a value associated + // with a condition, this checks if the condition is true. + for (String conditionName : evaluatedCondition.keySet()) { + boolean conditionEvaluation = evaluatedCondition.get(conditionName); + if (conditionalValues.containsKey(conditionName) && conditionEvaluation) { + derivedValue = conditionalValues.get(conditionName); + break; + } + } + + if (derivedValue != null && derivedValue.toParameterValueResponse().isUseInAppDefault()) { + logger.warn( + String.format("Derived value found for %s but parameter is set to use in app default.", + parameterName)); + continue; + } + + if (derivedValue != null) { + String parameterValue = derivedValue.toParameterValueResponse().getValue(); + Value value = new Value(ValueSource.REMOTE, parameterValue); + configValues.put(parameterName, value); + continue; + } + + ParameterValue defaultValue = parameter.getDefaultValue(); + if (defaultValue == null) { + logger.warn(String.format("Default parameter value for %s is not set.", + parameterName)); + continue; + } + + ParameterValueResponse defaultValueResponse = defaultValue.toParameterValueResponse(); + if (defaultValueResponse != null && defaultValueResponse.isUseInAppDefault()) { + logger.info(String.format("Default value for %s is set to use in app default.", + parameterName)); + continue; + } + + String parameterDefaultValue = defaultValue.toParameterValueResponse().getValue(); + Value value = new Value(ValueSource.REMOTE, parameterDefaultValue); + configValues.put(parameterName, value); + } + } +} diff --git a/src/main/java/com/google/firebase/remoteconfig/Value.java b/src/main/java/com/google/firebase/remoteconfig/Value.java new file mode 100644 index 000000000..7935e8491 --- /dev/null +++ b/src/main/java/com/google/firebase/remoteconfig/Value.java @@ -0,0 +1,135 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.remoteconfig; + +import static com.google.common.base.Preconditions.checkNotNull; + +import com.google.common.collect.ImmutableList; +import com.google.firebase.internal.NonNull; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Wraps a parameter value with metadata and type-safe getters. Type-safe + * getters insulate application logic from remote changes to parameter names and + * types. + */ +class Value { + private static final Logger logger = LoggerFactory.getLogger(Value.class); + private static final boolean DEFAULT_VALUE_FOR_BOOLEAN = false; + private static final String DEFAULT_VALUE_FOR_STRING = ""; + private static final long DEFAULT_VALUE_FOR_LONG = 0; + private static final double DEFAULT_VALUE_FOR_DOUBLE = 0; + private static final ImmutableList BOOLEAN_TRUTHY_VALUES = ImmutableList.of("1", "true", + "t", "yes", "y", "on"); + + private final ValueSource source; + private final String value; + + /** + * Creates a new {@link Value} object. + * + * @param source Indicates the source of a value. + * @param value Indicates a parameter value. + */ + Value(@NonNull ValueSource source, String value) { + checkNotNull(source, "Value source cannot be null."); + this.source = source; + this.value = value; + } + + /** + * Creates a new {@link Value} object with default value. + * + * @param source Indicates the source of a value. + */ + Value(@NonNull ValueSource source) { + this(source, DEFAULT_VALUE_FOR_STRING); + } + + /** + * Gets the value as a string. + * + * @return value as string + */ + @NonNull + String asString() { + return this.value; + } + + /** + * Gets the value as a boolean.The following values (case + * insensitive) are interpreted as true: "1", "true", "t", "yes", "y", "on". + * Other values are interpreted as false. + * + * @return value as boolean + */ + @NonNull + boolean asBoolean() { + if (source == ValueSource.STATIC) { + return DEFAULT_VALUE_FOR_BOOLEAN; + } + return BOOLEAN_TRUTHY_VALUES.contains(value.toLowerCase()); + } + + /** + * Gets the value as long. Comparable to calling Number(value) || 0. + * + * @return value as long + */ + @NonNull + long asLong() { + if (source == ValueSource.STATIC) { + return DEFAULT_VALUE_FOR_LONG; + } + try { + return Long.parseLong(value); + } catch (NumberFormatException e) { + logger.warn("Unable to convert {} to long type.", value); + return DEFAULT_VALUE_FOR_LONG; + } + } + + /** + * Gets the value as double. Comparable to calling Number(value) || 0. + * + * @return value as double + */ + @NonNull + double asDouble() { + if (source == ValueSource.STATIC) { + return DEFAULT_VALUE_FOR_DOUBLE; + } + try { + return Double.parseDouble(this.value); + } catch (NumberFormatException e) { + logger.warn("Unable to convert {} to double type.", value); + return DEFAULT_VALUE_FOR_DOUBLE; + } + } + + /** + * Gets the {@link ValueSource} for the given key. + * + * @return source. + */ + @NonNull + ValueSource getSource() { + return source; + } +} diff --git a/src/main/java/com/google/firebase/remoteconfig/ValueSource.java b/src/main/java/com/google/firebase/remoteconfig/ValueSource.java new file mode 100644 index 000000000..c870e8514 --- /dev/null +++ b/src/main/java/com/google/firebase/remoteconfig/ValueSource.java @@ -0,0 +1,31 @@ + +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.remoteconfig; + +/** + * Indicates the source of a value. + * "static" indicates the value was defined by a static constant. + * "default" indicates the value was defined by default config. + * "remote" indicates the value was defined by config produced by evaluating a template. + */ +public enum ValueSource { + STATIC, + REMOTE, + DEFAULT +} + diff --git a/src/main/java/com/google/firebase/remoteconfig/internal/ServerTemplateResponse.java b/src/main/java/com/google/firebase/remoteconfig/internal/ServerTemplateResponse.java new file mode 100644 index 000000000..db3785afe --- /dev/null +++ b/src/main/java/com/google/firebase/remoteconfig/internal/ServerTemplateResponse.java @@ -0,0 +1,322 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.remoteconfig.internal; + +import com.google.api.client.util.Key; +import com.google.firebase.remoteconfig.internal.TemplateResponse.ParameterGroupResponse; +import com.google.firebase.remoteconfig.internal.TemplateResponse.ParameterResponse; +import com.google.firebase.remoteconfig.internal.TemplateResponse.VersionResponse; + +import java.util.List; +import java.util.Map; + +/** + * The Data Transfer Object for parsing Remote Config template responses from + * the Remote Config + * service. + */ +public final class ServerTemplateResponse { + @Key("parameters") + private Map parameters; + + @Key("conditions") + private List serverConditions; + + @Key("parameterGroups") + private Map parameterGroups; + + @Key("version") + private VersionResponse version; + + // For local JSON serialization and deserialization purposes only. + // ETag in response type is never set by the HTTP response. + @Key("etag") + private String etag; + + public Map getParameters() { + return parameters; + } + + public List getServerConditions() { + return serverConditions; + } + + public Map getParameterGroups() { + return parameterGroups; + } + + public VersionResponse getVersion() { + return version; + } + + public String getEtag() { + return etag; + } + + public ServerTemplateResponse setParameters(Map parameters) { + this.parameters = parameters; + return this; + } + + public ServerTemplateResponse setServerConditions( + List serverConditions) { + this.serverConditions = serverConditions; + return this; + } + + public ServerTemplateResponse setParameterGroups( + Map parameterGroups) { + this.parameterGroups = parameterGroups; + return this; + } + + public ServerTemplateResponse setVersion(VersionResponse version) { + this.version = version; + return this; + } + + public ServerTemplateResponse setEtag(String etag) { + this.etag = etag; + return this; + } + + /** + * The Data Transfer Object for parsing Remote Config condition responses from + * the Remote Config + * service. + */ + public static final class ServerConditionResponse { + + @Key("name") + private String name; + + @Key("condition") + private OneOfConditionResponse condition; + + public String getName() { + return name; + } + + public OneOfConditionResponse getServerCondition() { + return condition; + } + + public ServerConditionResponse setName(String name) { + this.name = name; + return this; + } + + public ServerConditionResponse setServerCondition(OneOfConditionResponse condition) { + this.condition = condition; + return this; + } + } + + public static final class OneOfConditionResponse { + @Key("orCondition") + private OrConditionResponse orCondition; + + @Key("andCondition") + private AndConditionResponse andCondition; + + @Key("customSignal") + private CustomSignalConditionResponse customSignalCondition; + + @Key("percent") + private PercentConditionResponse percentCondition; + + public OrConditionResponse getOrCondition() { + return orCondition; + } + + public AndConditionResponse getAndCondition() { + return andCondition; + } + + public PercentConditionResponse getPercentCondition() { + return percentCondition; + } + + public CustomSignalConditionResponse getCustomSignalCondition() { + return customSignalCondition; + } + + public OneOfConditionResponse setOrCondition(OrConditionResponse orCondition) { + this.orCondition = orCondition; + return this; + } + + public OneOfConditionResponse setAndCondition(AndConditionResponse andCondition) { + this.andCondition = andCondition; + return this; + } + + public OneOfConditionResponse setCustomSignalCondition( + CustomSignalConditionResponse customSignalCondition) { + this.customSignalCondition = customSignalCondition; + return this; + } + + public OneOfConditionResponse setPercentCondition(PercentConditionResponse percentCondition) { + this.percentCondition = percentCondition; + return this; + } + } + + public static final class OrConditionResponse { + @Key("conditions") + private List conditions; + + public List getConditions() { + return conditions; + } + + public OrConditionResponse setConditions(List conditions) { + this.conditions = conditions; + return this; + } + } + + public static final class AndConditionResponse { + @Key("conditions") + private List conditions; + + public List getConditions() { + return conditions; + } + + public AndConditionResponse setConditions(List conditions) { + this.conditions = conditions; + return this; + } + } + + public static final class CustomSignalConditionResponse { + @Key("customSignalOperator") + private String operator; + + @Key("customSignalKey") + private String key; + + @Key("targetCustomSignalValues") + private List targetValues; + + public String getOperator() { + return operator; + } + + public String getKey() { + return key; + } + + public List getTargetValues() { + return targetValues; + } + + public CustomSignalConditionResponse setOperator(String operator) { + this.operator = operator; + return this; + } + + public CustomSignalConditionResponse setKey(String key) { + this.key = key; + return this; + } + + public CustomSignalConditionResponse setTargetValues(List targetValues) { + this.targetValues = targetValues; + return this; + } + } + + public static final class PercentConditionResponse { + @Key("microPercent") + private int microPercent; + + @Key("microPercentRange") + private MicroPercentRangeResponse microPercentRange; + + @Key("percentOperator") + private String percentOperator; + + @Key("seed") + private String seed; + + public int getMicroPercent() { + return microPercent; + } + + public MicroPercentRangeResponse getMicroPercentRange() { + return microPercentRange; + } + + public String getPercentOperator() { + return percentOperator; + } + + public String getSeed() { + return seed; + } + + public PercentConditionResponse setMicroPercent(int microPercent) { + this.microPercent = microPercent; + return this; + } + + public PercentConditionResponse setMicroPercentRange( + MicroPercentRangeResponse microPercentRange) { + this.microPercentRange = microPercentRange; + return this; + } + + public PercentConditionResponse setPercentOperator(String percentOperator) { + this.percentOperator = percentOperator; + return this; + } + + public PercentConditionResponse setSeed(String seed) { + this.seed = seed; + return this; + } + } + + public static final class MicroPercentRangeResponse { + @Key("microPercentLowerBound") + private int microPercentLowerBound; + + @Key("microPercentUpperBound") + private int microPercentUpperBound; + + public int getMicroPercentLowerBound() { + return microPercentLowerBound; + } + + public int getMicroPercentUpperBound() { + return microPercentUpperBound; + } + + public MicroPercentRangeResponse setMicroPercentLowerBound(int microPercentLowerBound) { + this.microPercentLowerBound = microPercentLowerBound; + return this; + } + + public MicroPercentRangeResponse setMicroPercentUpperBound(int microPercentUpperBound) { + this.microPercentUpperBound = microPercentUpperBound; + return this; + } + } +} diff --git a/src/test/java/com/google/firebase/remoteconfig/ConditionEvaluatorTest.java b/src/test/java/com/google/firebase/remoteconfig/ConditionEvaluatorTest.java new file mode 100644 index 000000000..0cd6e4528 --- /dev/null +++ b/src/test/java/com/google/firebase/remoteconfig/ConditionEvaluatorTest.java @@ -0,0 +1,832 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.remoteconfig; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +import com.google.common.collect.ImmutableList; + +import java.util.Arrays; +import java.util.Map; +import java.util.UUID; + +import org.junit.Test; + +public class ConditionEvaluatorTest { + + private final ConditionEvaluator conditionEvaluator = new ConditionEvaluator(); + + @Test + public void testEvaluateConditionsEmptyOrConditionThrowsException() { + IllegalArgumentException error = assertThrows(IllegalArgumentException.class, + () -> createOneOfOrCondition(null)); + assertEquals("List of conditions for OR operation must not be empty.", error.getMessage()); + } + + @Test + public void testEvaluateConditionsEmptyOrAndConditionThrowsException() { + IllegalArgumentException error = assertThrows(IllegalArgumentException.class, + () -> createOneOfAndCondition(null)); + assertEquals("List of conditions for AND operation must not be empty.", error.getMessage()); + } + + @Test + public void testEvaluateConditionsOrAndTrueToTrue() { + OneOfCondition oneOfConditionTrue = createOneOfTrueCondition(); + OneOfCondition oneOfConditionAnd = createOneOfAndCondition(oneOfConditionTrue); + OneOfCondition oneOfConditionOr = createOneOfOrCondition(oneOfConditionAnd); + ServerCondition condition = new ServerCondition("is_enabled", oneOfConditionOr); + KeysAndValues context = new KeysAndValues.Builder().build(); + + Map result = conditionEvaluator.evaluateConditions(Arrays.asList(condition), + context); + + assertTrue(result.get("is_enabled")); + } + + @Test + public void testEvaluateConditionsOrAndFalseToFalse() { + OneOfCondition oneOfConditionFalse = createOneOfFalseCondition(); + OneOfCondition oneOfConditionAnd = createOneOfAndCondition(oneOfConditionFalse); + OneOfCondition oneOfConditionOr = createOneOfOrCondition(oneOfConditionAnd); + ServerCondition condition = new ServerCondition("is_enabled", oneOfConditionOr); + KeysAndValues context = new KeysAndValues.Builder().build(); + + Map result = conditionEvaluator.evaluateConditions(Arrays.asList(condition), + context); + + assertFalse(result.get("is_enabled")); + } + + @Test + public void testEvaluateConditionsNonOrTopConditionToTrue() { + OneOfCondition oneOfConditionTrue = createOneOfTrueCondition(); + ServerCondition condition = new ServerCondition("is_enabled", oneOfConditionTrue); + KeysAndValues context = new KeysAndValues.Builder().build(); + + Map result = conditionEvaluator.evaluateConditions(Arrays.asList(condition), + context); + + assertTrue(result.get("is_enabled")); + } + + @Test + public void testEvaluateConditionsPercentConditionWithInvalidOperatorToFalse() { + OneOfCondition oneOfConditionPercent = createPercentCondition(0, + PercentConditionOperator.UNSPECIFIED, "seed"); + ServerCondition condition = new ServerCondition("is_enabled", oneOfConditionPercent); + KeysAndValues.Builder contextBuilder = new KeysAndValues.Builder(); + contextBuilder.put("randomizationId", "abc"); + + Map result = conditionEvaluator.evaluateConditions(Arrays.asList(condition), + contextBuilder.build()); + + assertFalse(result.get("is_enabled")); + } + + @Test + public void testEvaluateConditionsPercentConditionLessOrEqualMaxToTrue() { + OneOfCondition oneOfConditionPercent = createPercentCondition(10_000_0000, + PercentConditionOperator.LESS_OR_EQUAL, "seed"); + OneOfCondition oneOfConditionAnd = createOneOfAndCondition(oneOfConditionPercent); + OneOfCondition oneOfConditionOr = createOneOfOrCondition(oneOfConditionAnd); + ServerCondition condition = new ServerCondition("is_enabled", oneOfConditionOr); + KeysAndValues.Builder contextBuilder = new KeysAndValues.Builder(); + contextBuilder.put("randomizationId", "123"); + + Map result = conditionEvaluator.evaluateConditions(Arrays.asList(condition), + contextBuilder.build()); + + assertTrue(result.get("is_enabled")); + } + + @Test + public void testEvaluateConditionsPercentConditionLessOrEqualMinToFalse() { + OneOfCondition oneOfConditionPercent = createPercentCondition(0, + PercentConditionOperator.LESS_OR_EQUAL, "seed"); + OneOfCondition oneOfConditionAnd = createOneOfAndCondition(oneOfConditionPercent); + OneOfCondition oneOfConditionOr = createOneOfOrCondition(oneOfConditionAnd); + ServerCondition condition = new ServerCondition("is_enabled", oneOfConditionOr); + KeysAndValues.Builder contextBuilder = new KeysAndValues.Builder(); + contextBuilder.put("randomizationId", "123"); + + Map result = conditionEvaluator.evaluateConditions(Arrays.asList(condition), + contextBuilder.build()); + + assertFalse(result.get("is_enabled")); + } + + @Test + public void testEvaluateConditionsPercentConditionUndefinedMicroPercentToFalse() { + OneOfCondition oneOfConditionPercent = createPercentCondition(null, + PercentConditionOperator.LESS_OR_EQUAL, "seed"); + OneOfCondition oneOfConditionAnd = createOneOfAndCondition(oneOfConditionPercent); + OneOfCondition oneOfConditionOr = createOneOfOrCondition(oneOfConditionAnd); + ServerCondition condition = new ServerCondition("is_enabled", oneOfConditionOr); + KeysAndValues.Builder contextBuilder = new KeysAndValues.Builder(); + contextBuilder.put("randomizationId", "123"); + + Map result = conditionEvaluator.evaluateConditions(Arrays.asList(condition), + contextBuilder.build()); + + assertFalse(result.get("is_enabled")); + } + + @Test + public void testEvaluateConditionsUseZeroForUndefinedPercentRange() { + OneOfCondition oneOfConditionPercent = createBetweenPercentCondition(null, null, "seed"); + OneOfCondition oneOfConditionAnd = createOneOfAndCondition(oneOfConditionPercent); + OneOfCondition oneOfConditionOr = createOneOfOrCondition(oneOfConditionAnd); + ServerCondition condition = new ServerCondition("is_enabled", oneOfConditionOr); + KeysAndValues.Builder contextBuilder = new KeysAndValues.Builder(); + contextBuilder.put("randomizationId", "123"); + + Map result = conditionEvaluator.evaluateConditions(Arrays.asList(condition), + contextBuilder.build()); + + assertFalse(result.get("is_enabled")); + } + + @Test + public void testEvaluateConditionsUseZeroForUndefinedUpperBound() { + OneOfCondition oneOfConditionPercent = createBetweenPercentCondition(0, null, "seed"); + OneOfCondition oneOfConditionAnd = createOneOfAndCondition(oneOfConditionPercent); + OneOfCondition oneOfConditionOr = createOneOfOrCondition(oneOfConditionAnd); + ServerCondition condition = new ServerCondition("is_enabled", oneOfConditionOr); + KeysAndValues.Builder contextBuilder = new KeysAndValues.Builder(); + contextBuilder.put("randomizationId", "123"); + + Map result = conditionEvaluator.evaluateConditions(Arrays.asList(condition), + contextBuilder.build()); + + assertFalse(result.get("is_enabled")); + } + + @Test + public void testEvaluateConditionsUseZeroForUndefinedLowerBound() { + OneOfCondition oneOfConditionPercent = createBetweenPercentCondition(null, 10_000_0000, "seed"); + OneOfCondition oneOfConditionAnd = createOneOfAndCondition(oneOfConditionPercent); + OneOfCondition oneOfConditionOr = createOneOfOrCondition(oneOfConditionAnd); + ServerCondition condition = new ServerCondition("is_enabled", oneOfConditionOr); + KeysAndValues.Builder contextBuilder = new KeysAndValues.Builder(); + contextBuilder.put("randomizationId", "123"); + + Map result = conditionEvaluator.evaluateConditions(Arrays.asList(condition), + contextBuilder.build()); + + assertTrue(result.get("is_enabled")); + } + + @Test + public void testEvaluatedConditionsGreaterThanMinToTrue() { + OneOfCondition oneOfConditionPercent = createPercentCondition(0, + PercentConditionOperator.GREATER_THAN, "seed"); + OneOfCondition oneOfConditionAnd = createOneOfAndCondition(oneOfConditionPercent); + OneOfCondition oneOfConditionOr = createOneOfOrCondition(oneOfConditionAnd); + ServerCondition condition = new ServerCondition("is_enabled", oneOfConditionOr); + KeysAndValues.Builder contextBuilder = new KeysAndValues.Builder(); + contextBuilder.put("randomizationId", "123"); + + Map result = conditionEvaluator.evaluateConditions(Arrays.asList(condition), + contextBuilder.build()); + + assertTrue(result.get("is_enabled")); + } + + @Test + public void testEvaluatedConditionsGreaterThanMaxToFalse() { + OneOfCondition oneOfConditionPercent = createPercentCondition(10_000_0000, + PercentConditionOperator.GREATER_THAN, "seed"); + OneOfCondition oneOfConditionAnd = createOneOfAndCondition(oneOfConditionPercent); + OneOfCondition oneOfConditionOr = createOneOfOrCondition(oneOfConditionAnd); + ServerCondition condition = new ServerCondition("is_enabled", oneOfConditionOr); + KeysAndValues.Builder contextBuilder = new KeysAndValues.Builder(); + contextBuilder.put("randomizationId", "123"); + + Map result = conditionEvaluator.evaluateConditions(Arrays.asList(condition), + contextBuilder.build()); + + assertFalse(result.get("is_enabled")); + } + + @Test + public void testEvaluatedConditionsBetweenMinAndMaxToTrue() { + OneOfCondition oneOfConditionPercent = createBetweenPercentCondition(0, 10_000_0000, "seed"); + OneOfCondition oneOfConditionAnd = createOneOfAndCondition(oneOfConditionPercent); + OneOfCondition oneOfConditionOr = createOneOfOrCondition(oneOfConditionAnd); + ServerCondition condition = new ServerCondition("is_enabled", oneOfConditionOr); + KeysAndValues.Builder contextBuilder = new KeysAndValues.Builder(); + contextBuilder.put("randomizationId", "123"); + + Map result = conditionEvaluator.evaluateConditions(Arrays.asList(condition), + contextBuilder.build()); + + assertTrue(result.get("is_enabled")); + } + + @Test + public void testEvaluatedConditionsBetweenEqualBoundsToFalse() { + OneOfCondition oneOfConditionPercent = createBetweenPercentCondition(5_000_000, + 5_000_000, "seed"); + OneOfCondition oneOfConditionAnd = createOneOfAndCondition(oneOfConditionPercent); + OneOfCondition oneOfConditionOr = createOneOfOrCondition(oneOfConditionAnd); + ServerCondition condition = new ServerCondition("is_enabled", oneOfConditionOr); + KeysAndValues.Builder contextBuilder = new KeysAndValues.Builder(); + contextBuilder.put("randomizationId", "123"); + + Map result = conditionEvaluator.evaluateConditions(Arrays.asList(condition), + contextBuilder.build()); + + assertFalse(result.get("is_enabled")); + } + + @Test + public void testEvaluateConditionsLessOrEqualToApprox() { + OneOfCondition oneOfConditionPerCondition = createPercentCondition(10_000_000, + PercentConditionOperator.LESS_OR_EQUAL, "seed"); + // 284 is 3 standard deviations for 100k trials with 10% probability. + int tolerance = 284; + + int truthyAssignments = evaluateRandomAssignments(oneOfConditionPerCondition, 100000); + + // Evaluate less than or equal 10% to approx 10% + assertTrue(truthyAssignments >= 10_000 - tolerance); + assertTrue(truthyAssignments <= 10_000 + tolerance); + } + + @Test + public void testEvaluateConditionsBetweenApproximateToTrue() { + // Micropercent range is 40% to 60%. + OneOfCondition oneOfConditionPerCondition = createBetweenPercentCondition(40_000_000, + 60_000_000, "seed"); + // 379 is 3 standard deviations for 100k trials with 20% probability. + int tolerance = 379; + + int truthyAssignments = evaluateRandomAssignments(oneOfConditionPerCondition, 100000); + + // Evaluate between 40% to 60% to approx 20% + assertTrue(truthyAssignments >= 20_000 - tolerance); + assertTrue(truthyAssignments <= 20_000 + tolerance); + } + + @Test + public void testEvaluateConditionsInterquartileToFiftyPercent() { + // Micropercent range is 25% to 75%. + OneOfCondition oneOfConditionPerCondition = createBetweenPercentCondition(25_000_000, + 75_000_000, "seed"); + // 474 is 3 standard deviations for 100k trials with 50% probability. + int tolerance = 474; + + int truthyAssignments = evaluateRandomAssignments(oneOfConditionPerCondition, 100000); + + // Evaluate between 25% to 75 to approx 50% + assertTrue(truthyAssignments >= 50_000 - tolerance); + assertTrue(truthyAssignments <= 50_000 + tolerance); + } + + @Test + public void testEvaluateConditionsCustomSignalNumericLessThanToFalse() { + ServerCondition condition = createCustomSignalServerCondition( + CustomSignalOperator.NUMERIC_LESS_THAN, ImmutableList.of("-50.0")); + KeysAndValues.Builder contextBuilder = new KeysAndValues.Builder(); + contextBuilder.put("signal_key", "-50.0"); + + Map result = conditionEvaluator.evaluateConditions(Arrays.asList(condition), + contextBuilder.build()); + + assertFalse(result.get("signal_key")); + } + + @Test + public void testEvaluateConditionsCustomSignalNumericLessThanToTrue() { + ServerCondition condition = createCustomSignalServerCondition( + CustomSignalOperator.NUMERIC_LESS_THAN, ImmutableList.of("-50.0")); + KeysAndValues.Builder contextBuilder = new KeysAndValues.Builder(); + contextBuilder.put("signal_key", "-50.01"); + + Map result = conditionEvaluator.evaluateConditions(Arrays.asList(condition), + contextBuilder.build()); + + assertTrue(result.get("signal_key")); + } + + @Test + public void testEvaluateConditionsCustomSignalInvalidValueNumericOperationToFalse() { + ServerCondition condition = createCustomSignalServerCondition( + CustomSignalOperator.NUMERIC_LESS_THAN, ImmutableList.of("non-numeric")); + KeysAndValues.Builder contextBuilder = new KeysAndValues.Builder(); + contextBuilder.put("signal_key", "-50.01"); + + Map result = conditionEvaluator.evaluateConditions(Arrays.asList(condition), + contextBuilder.build()); + + assertFalse(result.get("signal_key")); + } + + @Test + public void testEvaluateConditionsCustomSignalNumericLessEqualToFalse() { + ServerCondition condition = createCustomSignalServerCondition( + CustomSignalOperator.NUMERIC_LESS_EQUAL, ImmutableList.of("-50.0")); + KeysAndValues.Builder contextBuilder = new KeysAndValues.Builder(); + contextBuilder.put("signal_key", "-50.0"); + + Map result = conditionEvaluator.evaluateConditions(Arrays.asList(condition), + contextBuilder.build()); + + assertTrue(result.get("signal_key")); + } + + @Test + public void testEvaluateConditionsCustomSignalNumericLessEqualToTrue() { + ServerCondition condition = createCustomSignalServerCondition( + CustomSignalOperator.NUMERIC_LESS_EQUAL, ImmutableList.of("-50.0")); + KeysAndValues.Builder contextBuilder = new KeysAndValues.Builder(); + contextBuilder.put("signal_key", "-49.9"); + + Map result = conditionEvaluator.evaluateConditions(Arrays.asList(condition), + contextBuilder.build()); + + assertFalse(result.get("signal_key")); + } + + @Test + public void testEvaluateConditionsCustomSignalNumericEqualsToTrue() { + ServerCondition condition = createCustomSignalServerCondition( + CustomSignalOperator.NUMERIC_EQUAL, ImmutableList.of("50.0")); + KeysAndValues.Builder contextBuilder = new KeysAndValues.Builder(); + contextBuilder.put("signal_key", "50.0"); + + Map result = conditionEvaluator.evaluateConditions(Arrays.asList(condition), + contextBuilder.build()); + + assertTrue(result.get("signal_key")); + } + + @Test + public void testEvaluateConditionsCustomSignalNumericEqualsToFalse() { + ServerCondition condition = createCustomSignalServerCondition( + CustomSignalOperator.NUMERIC_EQUAL, ImmutableList.of("50.0")); + KeysAndValues.Builder contextBuilder = new KeysAndValues.Builder(); + contextBuilder.put("signal_key", "50.000001"); + + Map result = conditionEvaluator.evaluateConditions(Arrays.asList(condition), + contextBuilder.build()); + + assertFalse(result.get("signal_key")); + } + + @Test + public void testEvaluateConditionsCustomSignalNumericNotEqualsToTrue() { + ServerCondition condition = createCustomSignalServerCondition( + CustomSignalOperator.NUMERIC_NOT_EQUAL, ImmutableList.of("50.0")); + KeysAndValues.Builder contextBuilder = new KeysAndValues.Builder(); + contextBuilder.put("signal_key", "50.0"); + + Map result = conditionEvaluator.evaluateConditions(Arrays.asList(condition), + contextBuilder.build()); + + assertFalse(result.get("signal_key")); + } + + @Test + public void testEvaluateConditionsCustomSignalNumericNotEqualsToFalse() { + ServerCondition condition = createCustomSignalServerCondition( + CustomSignalOperator.NUMERIC_NOT_EQUAL, ImmutableList.of("50.0")); + KeysAndValues.Builder contextBuilder = new KeysAndValues.Builder(); + contextBuilder.put("signal_key", "50.000001"); + + Map result = conditionEvaluator.evaluateConditions(Arrays.asList(condition), + contextBuilder.build()); + + assertTrue(result.get("signal_key")); + } + + @Test + public void testEvaluateConditionsCustomSignalNumericGreaterEqualToFalse() { + ServerCondition condition = createCustomSignalServerCondition( + CustomSignalOperator.NUMERIC_GREATER_EQUAL, ImmutableList.of("-50.0")); + KeysAndValues.Builder contextBuilder = new KeysAndValues.Builder(); + contextBuilder.put("signal_key", "-50.0"); + + Map result = conditionEvaluator.evaluateConditions(Arrays.asList(condition), + contextBuilder.build()); + + assertTrue(result.get("signal_key")); + } + + @Test + public void testEvaluateConditionsCustomSignalNumericGreaterEqualToTrue() { + ServerCondition condition = createCustomSignalServerCondition( + CustomSignalOperator.NUMERIC_GREATER_EQUAL, ImmutableList.of("-50.0")); + KeysAndValues.Builder contextBuilder = new KeysAndValues.Builder(); + contextBuilder.put("signal_key", "-50.01"); + + Map result = conditionEvaluator.evaluateConditions(Arrays.asList(condition), + contextBuilder.build()); + + assertFalse(result.get("signal_key")); + } + + @Test + public void testEvaluateConditionsCustomSignalNumericgreaterThanToFalse() { + ServerCondition condition = createCustomSignalServerCondition( + CustomSignalOperator.NUMERIC_GREATER_THAN, ImmutableList.of("-50.0")); + KeysAndValues.Builder contextBuilder = new KeysAndValues.Builder(); + contextBuilder.put("signal_key", "-50.0"); + + Map result = conditionEvaluator.evaluateConditions(Arrays.asList(condition), + contextBuilder.build()); + + assertFalse(result.get("signal_key")); + } + + @Test + public void testEvaluateConditionsCustomSignalNumericGreaterThanToTrue() { + ServerCondition condition = createCustomSignalServerCondition( + CustomSignalOperator.NUMERIC_GREATER_THAN, ImmutableList.of("-50.0")); + KeysAndValues.Builder contextBuilder = new KeysAndValues.Builder(); + contextBuilder.put("signal_key", "-49.09"); + + Map result = conditionEvaluator.evaluateConditions(Arrays.asList(condition), + contextBuilder.build()); + + assertTrue(result.get("signal_key")); + } + + @Test + public void testEvaluateConditionsCustomSignalStringContainsToTrue() { + ServerCondition condition = createCustomSignalServerCondition( + CustomSignalOperator.STRING_CONTAINS, ImmutableList.of("One", "hundred")); + KeysAndValues.Builder contextBuilder = new KeysAndValues.Builder(); + contextBuilder.put("signal_key", "Two hundred"); + + Map result = conditionEvaluator.evaluateConditions(Arrays.asList(condition), + contextBuilder.build()); + + assertTrue(result.get("signal_key")); + } + + @Test + public void testEvaluateConditionsCustomSignalStringContainsToFalse() { + ServerCondition condition = createCustomSignalServerCondition( + CustomSignalOperator.STRING_CONTAINS, ImmutableList.of("One", "hundred")); + KeysAndValues.Builder contextBuilder = new KeysAndValues.Builder(); + contextBuilder.put("signal_key", "Two hudred"); + + Map result = conditionEvaluator.evaluateConditions(Arrays.asList(condition), + contextBuilder.build()); + + assertFalse(result.get("signal_key")); + } + + @Test + public void testEvaluateConditionsCustomSignalStringDoesNotContainToTrue() { + ServerCondition condition = createCustomSignalServerCondition( + CustomSignalOperator.STRING_DOES_NOT_CONTAIN, ImmutableList.of("One", "hundred")); + KeysAndValues.Builder contextBuilder = new KeysAndValues.Builder(); + contextBuilder.put("signal_key", "Two hudred"); + + Map result = conditionEvaluator.evaluateConditions(Arrays.asList(condition), + contextBuilder.build()); + + assertTrue(result.get("signal_key")); + } + + @Test + public void testEvaluateConditionsCustomSignalStringDoesNotContainToFalse() { + ServerCondition condition = createCustomSignalServerCondition( + CustomSignalOperator.STRING_DOES_NOT_CONTAIN, ImmutableList.of("One", "hundred")); + KeysAndValues.Builder contextBuilder = new KeysAndValues.Builder(); + contextBuilder.put("signal_key", "Two hundred"); + + Map result = conditionEvaluator.evaluateConditions(Arrays.asList(condition), + contextBuilder.build()); + + assertFalse(result.get("signal_key")); + } + + @Test + public void testEvaluateConditionsCustomSignalStringExactlyMatchesToTrue() { + ServerCondition condition = createCustomSignalServerCondition( + CustomSignalOperator.STRING_EXACTLY_MATCHES, ImmutableList.of("hundred")); + KeysAndValues.Builder contextBuilder = new KeysAndValues.Builder(); + contextBuilder.put("signal_key", "hundred"); + + Map result = conditionEvaluator.evaluateConditions(Arrays.asList(condition), + contextBuilder.build()); + + assertTrue(result.get("signal_key")); + } + + @Test + public void testEvaluateConditionsCustomSignalStringExactlyMatchesToFalse() { + ServerCondition condition = createCustomSignalServerCondition( + CustomSignalOperator.STRING_EXACTLY_MATCHES, ImmutableList.of("hundred")); + KeysAndValues.Builder contextBuilder = new KeysAndValues.Builder(); + contextBuilder.put("signal_key", "Two hundred"); + + Map result = conditionEvaluator.evaluateConditions(Arrays.asList(condition), + contextBuilder.build()); + + assertFalse(result.get("signal_key")); + } + + @Test + public void testEvaluateConditionsCustomSignalStringContainsRegexToTrue() { + ServerCondition condition = createCustomSignalServerCondition( + CustomSignalOperator.STRING_CONTAINS_REGEX, ImmutableList.of(".*hund.*")); + KeysAndValues.Builder contextBuilder = new KeysAndValues.Builder(); + contextBuilder.put("signal_key", "hundred"); + + Map result = conditionEvaluator.evaluateConditions(Arrays.asList(condition), + contextBuilder.build()); + + assertTrue(result.get("signal_key")); + } + + @Test + public void testEvaluateConditionsCustomSignalStringContainsRegexToFalse() { + ServerCondition condition = createCustomSignalServerCondition( + CustomSignalOperator.STRING_CONTAINS_REGEX, ImmutableList.of("$hund.*")); + KeysAndValues.Builder contextBuilder = new KeysAndValues.Builder(); + contextBuilder.put("signal_key", "Two ahundred"); + + Map result = conditionEvaluator.evaluateConditions(Arrays.asList(condition), + contextBuilder.build()); + + assertFalse(result.get("signal_key")); + } + + @Test + public void testEvaluateConditionsCustomSignalStringContainsInvalidRegexToFalse() { + ServerCondition condition = createCustomSignalServerCondition( + CustomSignalOperator.STRING_CONTAINS_REGEX, ImmutableList.of("abc)")); + KeysAndValues.Builder contextBuilder = new KeysAndValues.Builder(); + contextBuilder.put("signal_key", "Two ahundred"); + + Map result = conditionEvaluator.evaluateConditions(Arrays.asList(condition), + contextBuilder.build()); + + assertFalse(result.get("signal_key")); + } + + @Test + public void testEvaluateConditionCustomSignalSemanticLessThanToTrue() { + ServerCondition condition = createCustomSignalServerCondition( + CustomSignalOperator.SEMANTIC_VERSION_LESS_THAN, ImmutableList.of("50.0.20")); + KeysAndValues.Builder contextBuilder = new KeysAndValues.Builder(); + contextBuilder.put("signal_key", "50.0.2.0.1"); + + Map result = conditionEvaluator.evaluateConditions(Arrays.asList(condition), + contextBuilder.build()); + + assertTrue(result.get("signal_key")); + } + + @Test + public void testEvaluateConditionCustomSignalSemanticLessThanToFalse() { + ServerCondition condition = createCustomSignalServerCondition( + CustomSignalOperator.SEMANTIC_VERSION_LESS_THAN, ImmutableList.of("50.0.20")); + KeysAndValues.Builder contextBuilder = new KeysAndValues.Builder(); + contextBuilder.put("signal_key", "50.0.20.0.0"); + + Map result = conditionEvaluator.evaluateConditions(Arrays.asList(condition), + contextBuilder.build()); + + assertFalse(result.get("signal_key")); + } + + @Test + public void testEvaluateConditionCustomSignalSemanticLessThanInvalidVersionToFalse() { + ServerCondition condition = createCustomSignalServerCondition( + CustomSignalOperator.SEMANTIC_VERSION_LESS_THAN, ImmutableList.of("50.0.-20")); + KeysAndValues.Builder contextBuilder = new KeysAndValues.Builder(); + contextBuilder.put("signal_key", "50.0.2.0.1"); + + Map result = conditionEvaluator.evaluateConditions(Arrays.asList(condition), + contextBuilder.build()); + + assertFalse(result.get("signal_key")); + } + + @Test + public void testEvaluateConditionCustomSignalSemanticLessEqualToTrue() { + ServerCondition condition = createCustomSignalServerCondition( + CustomSignalOperator.SEMANTIC_VERSION_LESS_EQUAL, ImmutableList.of("50.0.20.0.0")); + KeysAndValues.Builder contextBuilder = new KeysAndValues.Builder(); + contextBuilder.put("signal_key", "50.0.20.0.0"); + + Map result = conditionEvaluator.evaluateConditions(Arrays.asList(condition), + contextBuilder.build()); + + assertTrue(result.get("signal_key")); + } + + @Test + public void testEvaluateConditionCustomSignalSemanticLessEqualToFalse() { + ServerCondition condition = createCustomSignalServerCondition( + CustomSignalOperator.SEMANTIC_VERSION_LESS_EQUAL, ImmutableList.of("50.0.2")); + KeysAndValues.Builder contextBuilder = new KeysAndValues.Builder(); + contextBuilder.put("signal_key", "50.0.2.1.0"); + + Map result = conditionEvaluator.evaluateConditions(Arrays.asList(condition), + contextBuilder.build()); + + assertFalse(result.get("signal_key")); + } + + @Test + public void testEvaluateConditionCustomSignalSemanticGreaterThanToTrue() { + ServerCondition condition = createCustomSignalServerCondition( + CustomSignalOperator.SEMANTIC_VERSION_GREATER_THAN, ImmutableList.of("50.0.2")); + KeysAndValues.Builder contextBuilder = new KeysAndValues.Builder(); + contextBuilder.put("signal_key", "50.0.20.0.1"); + + Map result = conditionEvaluator.evaluateConditions(Arrays.asList(condition), + contextBuilder.build()); + + assertTrue(result.get("signal_key")); + } + + @Test + public void testEvaluateConditionCustomSignalSemanticGreaterThanToFalse() { + ServerCondition condition = createCustomSignalServerCondition( + CustomSignalOperator.SEMANTIC_VERSION_GREATER_THAN, ImmutableList.of("50.0.20")); + KeysAndValues.Builder contextBuilder = new KeysAndValues.Builder(); + contextBuilder.put("signal_key", "50.0.20.0.0"); + + Map result = conditionEvaluator.evaluateConditions(Arrays.asList(condition), + contextBuilder.build()); + + assertFalse(result.get("signal_key")); + } + + @Test + public void testEvaluateConditionCustomSignalSemanticGreaterEqualToTrue() { + ServerCondition condition = createCustomSignalServerCondition( + CustomSignalOperator.SEMANTIC_VERSION_GREATER_EQUAL, ImmutableList.of("50.0.20")); + KeysAndValues.Builder contextBuilder = new KeysAndValues.Builder(); + contextBuilder.put("signal_key", "50.0.20.0.0"); + + Map result = conditionEvaluator.evaluateConditions(Arrays.asList(condition), + contextBuilder.build()); + + assertTrue(result.get("signal_key")); + } + + @Test + public void testEvaluateConditionCustomSignalSemanticGreaterEqualToFalse() { + ServerCondition condition = createCustomSignalServerCondition( + CustomSignalOperator.SEMANTIC_VERSION_GREATER_EQUAL, ImmutableList.of("50.0.20.1")); + KeysAndValues.Builder contextBuilder = new KeysAndValues.Builder(); + contextBuilder.put("signal_key", "50.0.20.0.0"); + + Map result = conditionEvaluator.evaluateConditions(Arrays.asList(condition), + contextBuilder.build()); + + assertFalse(result.get("signal_key")); + } + + @Test + public void testEvaluateConditionCustomSignalSemanticEqualToTrue() { + ServerCondition condition = createCustomSignalServerCondition( + CustomSignalOperator.SEMANTIC_VERSION_EQUAL, ImmutableList.of("50.0.20")); + KeysAndValues.Builder contextBuilder = new KeysAndValues.Builder(); + contextBuilder.put("signal_key", "50.0.20.0.0"); + + Map result = conditionEvaluator.evaluateConditions(Arrays.asList(condition), + contextBuilder.build()); + + assertTrue(result.get("signal_key")); + } + + @Test + public void testEvaluateConditionCustomSignalSemanticEqualToFalse() { + ServerCondition condition = createCustomSignalServerCondition( + CustomSignalOperator.SEMANTIC_VERSION_EQUAL, ImmutableList.of("50.0.20.1")); + KeysAndValues.Builder contextBuilder = new KeysAndValues.Builder(); + contextBuilder.put("signal_key", "50.0.20.0.0"); + + Map result = conditionEvaluator.evaluateConditions(Arrays.asList(condition), + contextBuilder.build()); + + assertFalse(result.get("signal_key")); + } + + @Test + public void testEvaluateConditionCustomSignalSemanticNotEqualToTrue() { + ServerCondition condition = createCustomSignalServerCondition( + CustomSignalOperator.SEMANTIC_VERSION_NOT_EQUAL, ImmutableList.of("50.0.20.1")); + KeysAndValues.Builder contextBuilder = new KeysAndValues.Builder(); + contextBuilder.put("signal_key", "50.0.20.0.0"); + + Map result = conditionEvaluator.evaluateConditions(Arrays.asList(condition), + contextBuilder.build()); + + assertTrue(result.get("signal_key")); + } + + @Test + public void testEvaluateConditionCustomSignalSemanticNotEqualToFalse() { + ServerCondition condition = createCustomSignalServerCondition( + CustomSignalOperator.SEMANTIC_VERSION_NOT_EQUAL, ImmutableList.of("50.0.20")); + KeysAndValues.Builder contextBuilder = new KeysAndValues.Builder(); + contextBuilder.put("signal_key", "50.0.20.0.0"); + + Map result = conditionEvaluator.evaluateConditions(Arrays.asList(condition), + contextBuilder.build()); + + assertFalse(result.get("signal_key")); + } + + private ServerCondition createCustomSignalServerCondition( + CustomSignalOperator operator, + ImmutableList targetCustomSignalValues) { + CustomSignalCondition condition = new CustomSignalCondition("signal_key", operator, + targetCustomSignalValues); + OneOfCondition oneOfConditionCustomSignal = new OneOfCondition(); + oneOfConditionCustomSignal.setCustomSignal(condition); + return new ServerCondition("signal_key", oneOfConditionCustomSignal); + } + + private int evaluateRandomAssignments(OneOfCondition percentCondition, int numOfAssignments) { + int evalTrueCount = 0; + ServerCondition condition = new ServerCondition("is_enabled", percentCondition); + for (int i = 0; i < numOfAssignments; i++) { + UUID randomizationId = UUID.randomUUID(); + KeysAndValues.Builder contextBuilder = new KeysAndValues.Builder(); + contextBuilder.put("randomizationId", randomizationId.toString()); + + Map result = conditionEvaluator.evaluateConditions(Arrays.asList(condition), + contextBuilder.build()); + + if (result.get("is_enabled")) { + evalTrueCount++; + } + } + return evalTrueCount; + } + + private OneOfCondition createPercentCondition(Integer microPercent, + PercentConditionOperator operator, String seed) { + PercentCondition percentCondition = new PercentCondition(microPercent, operator, seed); + OneOfCondition oneOfCondition = new OneOfCondition(); + oneOfCondition.setPercent(percentCondition); + return oneOfCondition; + } + + private OneOfCondition createBetweenPercentCondition(Integer lowerBound, Integer upperBound, + String seed) { + MicroPercentRange microPercentRange = new MicroPercentRange(lowerBound, upperBound); + PercentCondition percentCondition = new PercentCondition(microPercentRange, + PercentConditionOperator.BETWEEN, seed); + OneOfCondition oneOfCondition = new OneOfCondition(); + oneOfCondition.setPercent(percentCondition); + return oneOfCondition; + } + + private OneOfCondition createOneOfOrCondition(OneOfCondition condition) { + OrCondition orCondition = condition != null ? new OrCondition(ImmutableList.of(condition)) + : new OrCondition(ImmutableList.of()); + OneOfCondition oneOfCondition = new OneOfCondition(); + oneOfCondition.setOrCondition(orCondition); + return oneOfCondition; + } + + private OneOfCondition createOneOfAndCondition(OneOfCondition condition) { + AndCondition andCondition = condition != null ? new AndCondition(ImmutableList.of(condition)) + : new AndCondition(ImmutableList.of()); + OneOfCondition oneOfCondition = new OneOfCondition(); + oneOfCondition.setAndCondition(andCondition); + return oneOfCondition; + } + + private OneOfCondition createOneOfTrueCondition() { + OneOfCondition oneOfCondition = new OneOfCondition(); + oneOfCondition.setTrue(); + return oneOfCondition; + } + + private OneOfCondition createOneOfFalseCondition() { + OneOfCondition oneOfCondition = new OneOfCondition(); + oneOfCondition.setFalse(); + return oneOfCondition; + } +} \ No newline at end of file diff --git a/src/test/java/com/google/firebase/remoteconfig/FirebaseRemoteConfigClientImplTest.java b/src/test/java/com/google/firebase/remoteconfig/FirebaseRemoteConfigClientImplTest.java index edc52a19d..78a5c276a 100644 --- a/src/test/java/com/google/firebase/remoteconfig/FirebaseRemoteConfigClientImplTest.java +++ b/src/test/java/com/google/firebase/remoteconfig/FirebaseRemoteConfigClientImplTest.java @@ -25,7 +25,6 @@ import static org.junit.Assert.assertTrue; import static org.junit.Assert.fail; -import com.google.api.client.googleapis.util.Utils; import com.google.api.client.http.GenericUrl; import com.google.api.client.http.HttpHeaders; import com.google.api.client.http.HttpMethods; @@ -48,82 +47,165 @@ import com.google.firebase.remoteconfig.internal.TemplateResponse; import com.google.firebase.testing.TestResponseInterceptor; import com.google.firebase.testing.TestUtils; - +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; import java.io.ByteArrayOutputStream; import java.io.IOException; import java.net.URLDecoder; +import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; - import org.junit.Before; import org.junit.Test; public class FirebaseRemoteConfigClientImplTest { private static final String TEST_REMOTE_CONFIG_URL = - "https://firebaseremoteconfig.googleapis.com/v1/projects/test-project/remoteConfig"; + "https://firebaseremoteconfig.googleapis.com/v1/projects/test-project/remoteConfig"; + private static final String TEST_SERVER_REMOTE_CONFIG_URL = + "https://firebaseremoteconfig.googleapis.com/v1/projects/test-project/namespaces/firebase-server/serverRemoteConfig"; private static final List HTTP_STATUS_CODES = ImmutableList.of(401, 404, 500); - private static final Map HTTP_STATUS_TO_ERROR_CODE = ImmutableMap.of( + private static final Map HTTP_STATUS_TO_ERROR_CODE = + ImmutableMap.of( 401, ErrorCode.UNAUTHENTICATED, 404, ErrorCode.NOT_FOUND, 500, ErrorCode.INTERNAL); - private static final String MOCK_TEMPLATE_RESPONSE = TestUtils - .loadResource("getRemoteConfig.json"); + private static final String MOCK_TEMPLATE_RESPONSE = + TestUtils.loadResource("getRemoteConfig.json"); + + private static final String MOCK_SERVER_TEMPLATE_RESPONSE = + TestUtils.loadResource("getServerRemoteConfig.json"); - private static final String MOCK_LIST_VERSIONS_RESPONSE = TestUtils - .loadResource("listRemoteConfigVersions.json"); + private static final String MOCK_LIST_VERSIONS_RESPONSE = + TestUtils.loadResource("listRemoteConfigVersions.json"); private static final String TEST_ETAG = "etag-123456789012-1"; - private static final Map EXPECTED_PARAMETERS = ImmutableMap.of( - "welcome_message_text", new Parameter() - .setDefaultValue(ParameterValue.of("welcome to app")) - .setConditionalValues(ImmutableMap.of( - "ios_en", ParameterValue.of("welcome to app en") - )) - .setDescription("text for welcome message!") - .setValueType(ParameterValueType.STRING), - "header_text", new Parameter() - .setDefaultValue(ParameterValue.inAppDefault()) - .setValueType(ParameterValueType.STRING) - ); - - private static final Map EXPECTED_PARAMETER_GROUPS = ImmutableMap.of( - "new menu", new ParameterGroup() - .setDescription("New Menu") - .setParameters(ImmutableMap.of( - "pumpkin_spice_season", new Parameter() - .setDefaultValue(ParameterValue.of("true")) - .setDescription("Whether it's currently pumpkin spice season.") - .setValueType(ParameterValueType.BOOLEAN) - ) - ) - ); - - private static final List EXPECTED_CONDITIONS = ImmutableList.of( + private static final Map EXPECTED_PARAMETERS = + ImmutableMap.of( + "welcome_message_text", + new Parameter() + .setDefaultValue(ParameterValue.of("welcome to app")) + .setConditionalValues( + ImmutableMap.of( + "ios_en", ParameterValue.of("welcome to app en"))) + .setDescription("text for welcome message!") + .setValueType(ParameterValueType.STRING), + "header_text", + new Parameter() + .setDefaultValue(ParameterValue.inAppDefault()) + .setValueType(ParameterValueType.STRING)); + + private static final Map EXPECTED_PARAMETER_GROUPS = + ImmutableMap.of( + "new menu", + new ParameterGroup() + .setDescription("New Menu") + .setParameters( + ImmutableMap.of( + "pumpkin_spice_season", + new Parameter() + .setDefaultValue(ParameterValue.of("true")) + .setDescription("Whether it's currently pumpkin spice season.") + .setValueType(ParameterValueType.BOOLEAN)))); + + private static final List EXPECTED_CONDITIONS = + ImmutableList.of( new Condition("ios_en", "device.os == 'ios' && device.country in ['us', 'uk']") - .setTagColor(TagColor.INDIGO), - new Condition("android_en", - "device.os == 'android' && device.country in ['us', 'uk']") - ); - - private static final Version EXPECTED_VERSION = new Version(new TemplateResponse.VersionResponse() - .setVersionNumber("17") - .setUpdateOrigin("ADMIN_SDK_NODE") - .setUpdateType("INCREMENTAL_UPDATE") - .setUpdateUser(new TemplateResponse.UserResponse() - .setEmail("firebase-user@account.com") - .setName("dev-admin") - .setImageUrl("http://image.jpg")) - .setUpdateTime("2020-11-15T06:57:26.342763941Z") - .setDescription("promo config") - ); - - private static final Template EXPECTED_TEMPLATE = new Template() + .setTagColor(TagColor.INDIGO), + new Condition("android_en", "device.os == 'android' && device.country in ['us', 'uk']")); + + private static final List EXPECTED_SERVER_CONDITIONS = + ImmutableList.of( + new ServerCondition("custom_signal", null) + .setServerCondition( + new OneOfCondition() + .setOrCondition( + new OrCondition( + ImmutableList.of( + new OneOfCondition() + .setAndCondition( + new AndCondition( + ImmutableList.of( + new OneOfCondition() + .setCustomSignal( + new CustomSignalCondition( + "users", + CustomSignalOperator + .NUMERIC_LESS_THAN, + new ArrayList<>( + ImmutableList.of("100"))))))))))), + new ServerCondition("percent", null) + .setServerCondition( + new OneOfCondition() + .setOrCondition( + new OrCondition( + ImmutableList.of( + new OneOfCondition() + .setAndCondition( + new AndCondition( + ImmutableList.of( + new OneOfCondition() + .setPercent( + new PercentCondition( + new MicroPercentRange( + 12000000, 100000000), + PercentConditionOperator.BETWEEN, + "3maarirs9xzs"))))))))), + new ServerCondition("chained_conditions", null) + .setServerCondition( + new OneOfCondition() + .setOrCondition( + new OrCondition( + ImmutableList.of( + new OneOfCondition() + .setAndCondition( + new AndCondition( + ImmutableList.of( + new OneOfCondition() + .setCustomSignal( + new CustomSignalCondition( + "users", + CustomSignalOperator + .NUMERIC_LESS_THAN, + new ArrayList<>( + ImmutableList.of("100")))), + new OneOfCondition() + .setCustomSignal( + new CustomSignalCondition( + "premium users", + CustomSignalOperator + .NUMERIC_GREATER_THAN, + new ArrayList<>( + ImmutableList.of("20")))), + new OneOfCondition() + .setPercent( + new PercentCondition( + new MicroPercentRange( + 25000000, 100000000), + PercentConditionOperator.BETWEEN, + "cla24qoibb61")))))))))); + + private static final Version EXPECTED_VERSION = + new Version( + new TemplateResponse.VersionResponse() + .setVersionNumber("17") + .setUpdateOrigin("ADMIN_SDK_NODE") + .setUpdateType("INCREMENTAL_UPDATE") + .setUpdateUser( + new TemplateResponse.UserResponse() + .setEmail("firebase-user@account.com") + .setName("dev-admin") + .setImageUrl("http://image.jpg")) + .setUpdateTime("2020-11-15T06:57:26.342763941Z") + .setDescription("promo config")); + + private static final Template EXPECTED_TEMPLATE = + new Template() .setETag(TEST_ETAG) .setParameters(EXPECTED_PARAMETERS) .setConditions(EXPECTED_CONDITIONS) @@ -158,16 +240,19 @@ public void testGetTemplate() throws Exception { @Test public void testGetTemplateWithTimestampUpToNanosecondPrecision() throws Exception { - List timestamps = ImmutableList.of( + List timestamps = + ImmutableList.of( "2020-11-15T06:57:26.342Z", "2020-11-15T06:57:26.342763Z", - "2020-11-15T06:57:26.342763941Z" - ); + "2020-11-15T06:57:26.342763941Z"); for (String timestamp : timestamps) { response.addHeader("etag", TEST_ETAG); - String templateResponse = "{\"version\": {" + String templateResponse = + "{\"version\": {" + " \"versionNumber\": \"17\"," - + " \"updateTime\": \"" + timestamp + "\"" + + " \"updateTime\": \"" + + timestamp + + "\"" + " }}"; response.setContent(templateResponse); @@ -221,8 +306,12 @@ public void testGetTemplateHttpError() { client.getTemplate(); fail("No error thrown for HTTP error"); } catch (FirebaseRemoteConfigException error) { - checkExceptionFromHttpResponse(error, HTTP_STATUS_TO_ERROR_CODE.get(code), null, - "Unexpected HTTP response with status: " + code + "\n{}", HttpMethods.GET); + checkExceptionFromHttpResponse( + error, + HTTP_STATUS_TO_ERROR_CODE.get(code), + null, + "Unexpected HTTP response with status: " + code + "\n{}", + HttpMethods.GET); } checkGetRequestHeader(interceptor.getLastRequest()); } @@ -237,8 +326,8 @@ public void testGetTemplateTransportError() { fail("No error thrown for HTTP error"); } catch (FirebaseRemoteConfigException error) { assertEquals(ErrorCode.UNKNOWN, error.getErrorCode()); - assertEquals("Unknown error while making a remote service call: transport error", - error.getMessage()); + assertEquals( + "Unknown error while making a remote service call: transport error", error.getMessage()); assertTrue(error.getCause() instanceof IOException); assertNull(error.getHttpResponse()); assertNull(error.getRemoteConfigErrorCode()); @@ -271,8 +360,12 @@ public void testGetTemplateErrorWithZeroContentResponse() { client.getTemplate(); fail("No error thrown for HTTP error"); } catch (FirebaseRemoteConfigException error) { - checkExceptionFromHttpResponse(error, HTTP_STATUS_TO_ERROR_CODE.get(code), null, - "Unexpected HTTP response with status: " + code + "\nnull", HttpMethods.GET); + checkExceptionFromHttpResponse( + error, + HTTP_STATUS_TO_ERROR_CODE.get(code), + null, + "Unexpected HTTP response with status: " + code + "\nnull", + HttpMethods.GET); } checkGetRequestHeader(interceptor.getLastRequest()); } @@ -287,8 +380,12 @@ public void testGetTemplateErrorWithMalformedResponse() { client.getTemplate(); fail("No error thrown for HTTP error"); } catch (FirebaseRemoteConfigException error) { - checkExceptionFromHttpResponse(error, HTTP_STATUS_TO_ERROR_CODE.get(code), null, - "Unexpected HTTP response with status: " + code + "\nnot json", HttpMethods.GET); + checkExceptionFromHttpResponse( + error, + HTTP_STATUS_TO_ERROR_CODE.get(code), + null, + "Unexpected HTTP response with status: " + code + "\nnot json", + HttpMethods.GET); } checkGetRequestHeader(interceptor.getLastRequest()); } @@ -297,15 +394,17 @@ public void testGetTemplateErrorWithMalformedResponse() { @Test public void testGetTemplateErrorWithDetails() { for (int code : HTTP_STATUS_CODES) { - response.setStatusCode(code).setContent( + response + .setStatusCode(code) + .setContent( "{\"error\": {\"status\": \"INVALID_ARGUMENT\", \"message\": \"test error\"}}"); try { client.getTemplate(); fail("No error thrown for HTTP error"); } catch (FirebaseRemoteConfigException error) { - checkExceptionFromHttpResponse(error, ErrorCode.INVALID_ARGUMENT, null, "test error", - HttpMethods.GET); + checkExceptionFromHttpResponse( + error, ErrorCode.INVALID_ARGUMENT, null, "test error", HttpMethods.GET); } checkGetRequestHeader(interceptor.getLastRequest()); } @@ -314,17 +413,22 @@ public void testGetTemplateErrorWithDetails() { @Test public void testGetTemplateErrorWithRcError() { for (int code : HTTP_STATUS_CODES) { - response.setStatusCode(code).setContent( + response + .setStatusCode(code) + .setContent( "{\"error\": {\"status\": \"INVALID_ARGUMENT\", " - + "\"message\": \"[INVALID_ARGUMENT]: test error\"}}"); + + "\"message\": \"[INVALID_ARGUMENT]: test error\"}}"); try { client.getTemplate(); fail("No error thrown for HTTP error"); } catch (FirebaseRemoteConfigException error) { - checkExceptionFromHttpResponse(error, ErrorCode.INVALID_ARGUMENT, - RemoteConfigErrorCode.INVALID_ARGUMENT, "[INVALID_ARGUMENT]: test error", - HttpMethods.GET); + checkExceptionFromHttpResponse( + error, + ErrorCode.INVALID_ARGUMENT, + RemoteConfigErrorCode.INVALID_ARGUMENT, + "[INVALID_ARGUMENT]: test error", + HttpMethods.GET); } checkGetRequestHeader(interceptor.getLastRequest()); } @@ -339,8 +443,9 @@ public void testGetTemplateAtVersionWithNullString() throws Exception { @Test public void testGetTemplateAtVersionWithInvalidString() throws Exception { - List invalidVersionStrings = ImmutableList - .of("", " ", "abc", "t123", "123t", "t123t", "12t3", "#$*&^", "-123", "+123", "123.4"); + List invalidVersionStrings = + ImmutableList.of( + "", " ", "abc", "t123", "123t", "t123t", "12t3", "#$*&^", "-123", "+123", "123.4"); for (String version : invalidVersionStrings) { try { @@ -407,8 +512,12 @@ public void testGetTemplateAtVersionHttpError() { client.getTemplateAtVersion("24"); fail("No error thrown for HTTP error"); } catch (FirebaseRemoteConfigException error) { - checkExceptionFromHttpResponse(error, HTTP_STATUS_TO_ERROR_CODE.get(code), null, - "Unexpected HTTP response with status: " + code + "\n{}", HttpMethods.GET); + checkExceptionFromHttpResponse( + error, + HTTP_STATUS_TO_ERROR_CODE.get(code), + null, + "Unexpected HTTP response with status: " + code + "\n{}", + HttpMethods.GET); } checkGetRequestHeader(interceptor.getLastRequest(), "?versionNumber=24"); } @@ -423,8 +532,8 @@ public void testGetTemplateAtVersionTransportError() { fail("No error thrown for HTTP error"); } catch (FirebaseRemoteConfigException error) { assertEquals(ErrorCode.UNKNOWN, error.getErrorCode()); - assertEquals("Unknown error while making a remote service call: transport error", - error.getMessage()); + assertEquals( + "Unknown error while making a remote service call: transport error", error.getMessage()); assertTrue(error.getCause() instanceof IOException); assertNull(error.getHttpResponse()); assertNull(error.getRemoteConfigErrorCode()); @@ -457,8 +566,12 @@ public void testGetTemplateAtVersionErrorWithZeroContentResponse() { client.getTemplateAtVersion("24"); fail("No error thrown for HTTP error"); } catch (FirebaseRemoteConfigException error) { - checkExceptionFromHttpResponse(error, HTTP_STATUS_TO_ERROR_CODE.get(code), null, - "Unexpected HTTP response with status: " + code + "\nnull", HttpMethods.GET); + checkExceptionFromHttpResponse( + error, + HTTP_STATUS_TO_ERROR_CODE.get(code), + null, + "Unexpected HTTP response with status: " + code + "\nnull", + HttpMethods.GET); } checkGetRequestHeader(interceptor.getLastRequest(), "?versionNumber=24"); } @@ -473,8 +586,12 @@ public void testGetTemplateAtVersionErrorWithMalformedResponse() { client.getTemplateAtVersion("24"); fail("No error thrown for HTTP error"); } catch (FirebaseRemoteConfigException error) { - checkExceptionFromHttpResponse(error, HTTP_STATUS_TO_ERROR_CODE.get(code), null, - "Unexpected HTTP response with status: " + code + "\nnot json", HttpMethods.GET); + checkExceptionFromHttpResponse( + error, + HTTP_STATUS_TO_ERROR_CODE.get(code), + null, + "Unexpected HTTP response with status: " + code + "\nnot json", + HttpMethods.GET); } checkGetRequestHeader(interceptor.getLastRequest(), "?versionNumber=24"); } @@ -483,15 +600,17 @@ public void testGetTemplateAtVersionErrorWithMalformedResponse() { @Test public void testGetTemplateAtVersionErrorWithDetails() { for (int code : HTTP_STATUS_CODES) { - response.setStatusCode(code).setContent( + response + .setStatusCode(code) + .setContent( "{\"error\": {\"status\": \"INVALID_ARGUMENT\", \"message\": \"test error\"}}"); try { client.getTemplateAtVersion("24"); fail("No error thrown for HTTP error"); } catch (FirebaseRemoteConfigException error) { - checkExceptionFromHttpResponse(error, ErrorCode.INVALID_ARGUMENT, null, "test error", - HttpMethods.GET); + checkExceptionFromHttpResponse( + error, ErrorCode.INVALID_ARGUMENT, null, "test error", HttpMethods.GET); } checkGetRequestHeader(interceptor.getLastRequest(), "?versionNumber=24"); } @@ -500,17 +619,22 @@ public void testGetTemplateAtVersionErrorWithDetails() { @Test public void testGetTemplateAtVersionErrorWithRcError() { for (int code : HTTP_STATUS_CODES) { - response.setStatusCode(code).setContent( + response + .setStatusCode(code) + .setContent( "{\"error\": {\"status\": \"INVALID_ARGUMENT\", " - + "\"message\": \"[INVALID_ARGUMENT]: test error\"}}"); + + "\"message\": \"[INVALID_ARGUMENT]: test error\"}}"); try { client.getTemplateAtVersion("24"); fail("No error thrown for HTTP error"); } catch (FirebaseRemoteConfigException error) { - checkExceptionFromHttpResponse(error, ErrorCode.INVALID_ARGUMENT, - RemoteConfigErrorCode.INVALID_ARGUMENT, "[INVALID_ARGUMENT]: test error", - HttpMethods.GET); + checkExceptionFromHttpResponse( + error, + ErrorCode.INVALID_ARGUMENT, + RemoteConfigErrorCode.INVALID_ARGUMENT, + "[INVALID_ARGUMENT]: test error", + HttpMethods.GET); } checkGetRequestHeader(interceptor.getLastRequest(), "?versionNumber=24"); } @@ -553,7 +677,8 @@ public void testPublishTemplateWithValidTemplateAndForceTrue() throws Exception public void testPublishTemplateWithValidTemplateAndValidateOnlyTrue() throws Exception { response.addHeader("etag", TEST_ETAG); response.setContent(MOCK_TEMPLATE_RESPONSE); - Template expectedTemplate = new Template() + Template expectedTemplate = + new Template() .setETag("etag-123456789012-45") .setParameters(EXPECTED_PARAMETERS) .setConditions(EXPECTED_CONDITIONS) @@ -562,12 +687,13 @@ public void testPublishTemplateWithValidTemplateAndValidateOnlyTrue() throws Exc Template validatedTemplate = client.publishTemplate(expectedTemplate, true, false); - // check if the etag matches the input template's etag and not the etag from the server response + // check if the etag matches the input template's etag and not the etag from the + // server response assertNotEquals(TEST_ETAG, validatedTemplate.getETag()); assertEquals("etag-123456789012-45", validatedTemplate.getETag()); assertEquals(expectedTemplate, validatedTemplate); - checkPutRequestHeader(interceptor.getLastRequest(), "?validateOnly=true", - "etag-123456789012-45"); + checkPutRequestHeader( + interceptor.getLastRequest(), "?validateOnly=true", "etag-123456789012-45"); } @Test @@ -611,8 +737,12 @@ public void testPublishTemplateHttpError() { client.publishTemplate(new Template().setETag(TEST_ETAG), false, false); fail("No error thrown for HTTP error"); } catch (FirebaseRemoteConfigException error) { - checkExceptionFromHttpResponse(error, HTTP_STATUS_TO_ERROR_CODE.get(code), null, - "Unexpected HTTP response with status: " + code + "\n{}", HttpMethods.PUT); + checkExceptionFromHttpResponse( + error, + HTTP_STATUS_TO_ERROR_CODE.get(code), + null, + "Unexpected HTTP response with status: " + code + "\n{}", + HttpMethods.PUT); } checkPutRequestHeader(interceptor.getLastRequest()); } @@ -627,8 +757,8 @@ public void testPublishTemplateTransportError() { fail("No error thrown for HTTP error"); } catch (FirebaseRemoteConfigException error) { assertEquals(ErrorCode.UNKNOWN, error.getErrorCode()); - assertEquals("Unknown error while making a remote service call: transport error", - error.getMessage()); + assertEquals( + "Unknown error while making a remote service call: transport error", error.getMessage()); assertTrue(error.getCause() instanceof IOException); assertNull(error.getHttpResponse()); assertNull(error.getRemoteConfigErrorCode()); @@ -661,8 +791,12 @@ public void testPublishTemplateErrorWithZeroContentResponse() { client.publishTemplate(new Template().setETag(TEST_ETAG), false, false); fail("No error thrown for HTTP error"); } catch (FirebaseRemoteConfigException error) { - checkExceptionFromHttpResponse(error, HTTP_STATUS_TO_ERROR_CODE.get(code), null, - "Unexpected HTTP response with status: " + code + "\nnull", HttpMethods.PUT); + checkExceptionFromHttpResponse( + error, + HTTP_STATUS_TO_ERROR_CODE.get(code), + null, + "Unexpected HTTP response with status: " + code + "\nnull", + HttpMethods.PUT); } checkPutRequestHeader(interceptor.getLastRequest()); } @@ -677,8 +811,12 @@ public void testPublishTemplateErrorWithMalformedResponse() { client.publishTemplate(new Template().setETag(TEST_ETAG), false, false); fail("No error thrown for HTTP error"); } catch (FirebaseRemoteConfigException error) { - checkExceptionFromHttpResponse(error, HTTP_STATUS_TO_ERROR_CODE.get(code), null, - "Unexpected HTTP response with status: " + code + "\nnot json", HttpMethods.PUT); + checkExceptionFromHttpResponse( + error, + HTTP_STATUS_TO_ERROR_CODE.get(code), + null, + "Unexpected HTTP response with status: " + code + "\nnot json", + HttpMethods.PUT); } checkPutRequestHeader(interceptor.getLastRequest()); } @@ -687,15 +825,17 @@ public void testPublishTemplateErrorWithMalformedResponse() { @Test public void testPublishTemplateErrorWithDetails() { for (int code : HTTP_STATUS_CODES) { - response.setStatusCode(code).setContent( + response + .setStatusCode(code) + .setContent( "{\"error\": {\"status\": \"INVALID_ARGUMENT\", \"message\": \"test error\"}}"); try { client.publishTemplate(new Template().setETag(TEST_ETAG), false, false); fail("No error thrown for HTTP error"); } catch (FirebaseRemoteConfigException error) { - checkExceptionFromHttpResponse(error, ErrorCode.INVALID_ARGUMENT, null, "test error", - HttpMethods.PUT); + checkExceptionFromHttpResponse( + error, ErrorCode.INVALID_ARGUMENT, null, "test error", HttpMethods.PUT); } checkPutRequestHeader(interceptor.getLastRequest()); } @@ -704,17 +844,22 @@ public void testPublishTemplateErrorWithDetails() { @Test public void testPublishTemplateErrorWithRcError() { for (int code : HTTP_STATUS_CODES) { - response.setStatusCode(code).setContent( + response + .setStatusCode(code) + .setContent( "{\"error\": {\"status\": \"INVALID_ARGUMENT\", " - + "\"message\": \"[INVALID_ARGUMENT]: test error\"}}"); + + "\"message\": \"[INVALID_ARGUMENT]: test error\"}}"); try { client.publishTemplate(new Template().setETag(TEST_ETAG), false, false); fail("No error thrown for HTTP error"); } catch (FirebaseRemoteConfigException error) { - checkExceptionFromHttpResponse(error, ErrorCode.INVALID_ARGUMENT, - RemoteConfigErrorCode.INVALID_ARGUMENT, "[INVALID_ARGUMENT]: test error", - HttpMethods.PUT); + checkExceptionFromHttpResponse( + error, + ErrorCode.INVALID_ARGUMENT, + RemoteConfigErrorCode.INVALID_ARGUMENT, + "[INVALID_ARGUMENT]: test error", + HttpMethods.PUT); } checkPutRequestHeader(interceptor.getLastRequest()); } @@ -729,8 +874,9 @@ public void testRollbackWithNullString() throws Exception { @Test public void testRollbackWithInvalidString() throws Exception { - List invalidVersionStrings = ImmutableList - .of("", " ", "abc", "t123", "123t", "t123t", "12t3", "#$*&^", "-123", "+123", "123.4"); + List invalidVersionStrings = + ImmutableList.of( + "", " ", "abc", "t123", "123t", "t123t", "12t3", "#$*&^", "-123", "+123", "123.4"); for (String version : invalidVersionStrings) { try { @@ -754,8 +900,8 @@ public void testRollbackWithValidString() throws Exception { assertEquals(EXPECTED_TEMPLATE, rolledBackTemplate); assertEquals(1605423446000L, rolledBackTemplate.getVersion().getUpdateTime()); checkPostRequestHeader(interceptor.getLastRequest(), ":rollback"); - checkRequestContent(interceptor.getLastRequest(), - ImmutableMap.of("versionNumber", "24")); + checkRequestContent( + interceptor.getLastRequest(), ImmutableMap.of("versionNumber", "24")); } @Test @@ -771,8 +917,8 @@ public void testRollbackWithEmptyTemplateResponse() throws Exception { assertEquals(0, template.getParameterGroups().size()); assertNull(template.getVersion()); checkPostRequestHeader(interceptor.getLastRequest(), ":rollback"); - checkRequestContent(interceptor.getLastRequest(), - ImmutableMap.of("versionNumber", "24")); + checkRequestContent( + interceptor.getLastRequest(), ImmutableMap.of("versionNumber", "24")); } @Test(expected = IllegalStateException.class) @@ -801,12 +947,16 @@ public void testRollbackHttpError() throws IOException { client.rollback("24"); fail("No error thrown for HTTP error"); } catch (FirebaseRemoteConfigException error) { - checkExceptionFromHttpResponse(error, HTTP_STATUS_TO_ERROR_CODE.get(code), null, - "Unexpected HTTP response with status: " + code + "\n{}", HttpMethods.POST); + checkExceptionFromHttpResponse( + error, + HTTP_STATUS_TO_ERROR_CODE.get(code), + null, + "Unexpected HTTP response with status: " + code + "\n{}", + HttpMethods.POST); } checkPostRequestHeader(interceptor.getLastRequest(), ":rollback"); - checkRequestContent(interceptor.getLastRequest(), - ImmutableMap.of("versionNumber", "24")); + checkRequestContent( + interceptor.getLastRequest(), ImmutableMap.of("versionNumber", "24")); } } @@ -819,8 +969,8 @@ public void testRollbackTransportError() { fail("No error thrown for HTTP error"); } catch (FirebaseRemoteConfigException error) { assertEquals(ErrorCode.UNKNOWN, error.getErrorCode()); - assertEquals("Unknown error while making a remote service call: transport error", - error.getMessage()); + assertEquals( + "Unknown error while making a remote service call: transport error", error.getMessage()); assertTrue(error.getCause() instanceof IOException); assertNull(error.getHttpResponse()); assertNull(error.getRemoteConfigErrorCode()); @@ -842,8 +992,8 @@ public void testRollbackSuccessResponseWithUnexpectedPayload() throws IOExceptio assertNull(error.getRemoteConfigErrorCode()); } checkPostRequestHeader(interceptor.getLastRequest(), ":rollback"); - checkRequestContent(interceptor.getLastRequest(), - ImmutableMap.of("versionNumber", "24")); + checkRequestContent( + interceptor.getLastRequest(), ImmutableMap.of("versionNumber", "24")); } @Test @@ -855,12 +1005,16 @@ public void testRollbackErrorWithZeroContentResponse() throws IOException { client.rollback("24"); fail("No error thrown for HTTP error"); } catch (FirebaseRemoteConfigException error) { - checkExceptionFromHttpResponse(error, HTTP_STATUS_TO_ERROR_CODE.get(code), null, - "Unexpected HTTP response with status: " + code + "\nnull", HttpMethods.POST); + checkExceptionFromHttpResponse( + error, + HTTP_STATUS_TO_ERROR_CODE.get(code), + null, + "Unexpected HTTP response with status: " + code + "\nnull", + HttpMethods.POST); } checkPostRequestHeader(interceptor.getLastRequest(), ":rollback"); - checkRequestContent(interceptor.getLastRequest(), - ImmutableMap.of("versionNumber", "24")); + checkRequestContent( + interceptor.getLastRequest(), ImmutableMap.of("versionNumber", "24")); } } @@ -873,52 +1027,63 @@ public void testRollbackErrorWithMalformedResponse() throws IOException { client.rollback("24"); fail("No error thrown for HTTP error"); } catch (FirebaseRemoteConfigException error) { - checkExceptionFromHttpResponse(error, HTTP_STATUS_TO_ERROR_CODE.get(code), null, - "Unexpected HTTP response with status: " + code + "\nnot json", HttpMethods.POST); + checkExceptionFromHttpResponse( + error, + HTTP_STATUS_TO_ERROR_CODE.get(code), + null, + "Unexpected HTTP response with status: " + code + "\nnot json", + HttpMethods.POST); } checkPostRequestHeader(interceptor.getLastRequest(), ":rollback"); - checkRequestContent(interceptor.getLastRequest(), - ImmutableMap.of("versionNumber", "24")); + checkRequestContent( + interceptor.getLastRequest(), ImmutableMap.of("versionNumber", "24")); } } @Test public void testRollbackErrorWithDetails() throws IOException { for (int code : HTTP_STATUS_CODES) { - response.setStatusCode(code).setContent( + response + .setStatusCode(code) + .setContent( "{\"error\": {\"status\": \"INVALID_ARGUMENT\", \"message\": \"test error\"}}"); try { client.rollback("24"); fail("No error thrown for HTTP error"); } catch (FirebaseRemoteConfigException error) { - checkExceptionFromHttpResponse(error, ErrorCode.INVALID_ARGUMENT, null, "test error", - HttpMethods.POST); + checkExceptionFromHttpResponse( + error, ErrorCode.INVALID_ARGUMENT, null, "test error", HttpMethods.POST); } checkPostRequestHeader(interceptor.getLastRequest(), ":rollback"); - checkRequestContent(interceptor.getLastRequest(), - ImmutableMap.of("versionNumber", "24")); + checkRequestContent( + interceptor.getLastRequest(), ImmutableMap.of("versionNumber", "24")); } } @Test public void testRollbackErrorWithRcError() throws IOException { for (int code : HTTP_STATUS_CODES) { - response.setStatusCode(code).setContent( + response + .setStatusCode(code) + .setContent( "{\"error\": {\"status\": \"INVALID_ARGUMENT\", " - + "\"message\": \"[INVALID_ARGUMENT]: test error\"}}"); + + "\"message\": \"[INVALID_ARGUMENT]: test error\"}}"); try { client.rollback("24"); fail("No error thrown for HTTP error"); } catch (FirebaseRemoteConfigException error) { - checkExceptionFromHttpResponse(error, ErrorCode.INVALID_ARGUMENT, - RemoteConfigErrorCode.INVALID_ARGUMENT, "[INVALID_ARGUMENT]: test error", - HttpMethods.POST); + checkExceptionFromHttpResponse( + error, + ErrorCode.INVALID_ARGUMENT, + RemoteConfigErrorCode.INVALID_ARGUMENT, + "[INVALID_ARGUMENT]: test error", + HttpMethods.POST); } checkPostRequestHeader(interceptor.getLastRequest(), ":rollback"); - checkRequestContent(interceptor.getLastRequest(), - ImmutableMap.of("versionNumber", "24")); + checkRequestContent( + interceptor.getLastRequest(), ImmutableMap.of("versionNumber", "24")); } } @@ -940,33 +1105,36 @@ public void testListVersionsWithNullOptions() throws Exception { public void testListVersionsWithOptions() throws Exception { response.setContent(MOCK_LIST_VERSIONS_RESPONSE); - TemplateResponse.ListVersionsResponse versionsList = client.listVersions( + TemplateResponse.ListVersionsResponse versionsList = + client.listVersions( ListVersionsOptions.builder() - .setPageSize(10) - .setPageToken("token") - .setStartTimeMillis(1605219122000L) - .setEndTimeMillis(1606245035000L) - .setEndVersionNumber("29").build()); + .setPageSize(10) + .setPageToken("token") + .setStartTimeMillis(1605219122000L) + .setEndTimeMillis(1606245035000L) + .setEndVersionNumber("29") + .build()); assertTrue(versionsList.hasVersions()); HttpRequest request = interceptor.getLastRequest(); - String urlWithoutParameters = request.getUrl().toString() - .substring(0, request.getUrl().toString().lastIndexOf('?')); - final Map expectedQuery = ImmutableMap.of( + String urlWithoutParameters = + request.getUrl().toString().substring(0, request.getUrl().toString().lastIndexOf('?')); + final Map expectedQuery = + ImmutableMap.of( "endVersionNumber", "29", "pageSize", "10", "pageToken", "token", "startTime", "2020-11-12T22:12:02.000000000Z", - "endTime", "2020-11-24T19:10:35.000000000Z" - ); + "endTime", "2020-11-24T19:10:35.000000000Z"); Map actualQuery = new HashMap<>(); String query = request.getUrl().toURI().getQuery(); String[] pairs = query.split("&"); for (String pair : pairs) { int idx = pair.indexOf("="); - actualQuery.put(URLDecoder.decode(pair.substring(0, idx), "UTF-8"), - URLDecoder.decode(pair.substring(idx + 1), "UTF-8")); + actualQuery.put( + URLDecoder.decode(pair.substring(0, idx), "UTF-8"), + URLDecoder.decode(pair.substring(idx + 1), "UTF-8")); } assertEquals("GET", request.getRequestMethod()); @@ -998,8 +1166,12 @@ public void testListVersionsHttpError() { client.listVersions(null); fail("No error thrown for HTTP error"); } catch (FirebaseRemoteConfigException error) { - checkExceptionFromHttpResponse(error, HTTP_STATUS_TO_ERROR_CODE.get(code), null, - "Unexpected HTTP response with status: " + code + "\n{}", HttpMethods.GET); + checkExceptionFromHttpResponse( + error, + HTTP_STATUS_TO_ERROR_CODE.get(code), + null, + "Unexpected HTTP response with status: " + code + "\n{}", + HttpMethods.GET); } checkGetRequestHeader(interceptor.getLastRequest(), ":listVersions"); } @@ -1014,8 +1186,8 @@ public void testListVersionsTransportError() { fail("No error thrown for HTTP error"); } catch (FirebaseRemoteConfigException error) { assertEquals(ErrorCode.UNKNOWN, error.getErrorCode()); - assertEquals("Unknown error while making a remote service call: transport error", - error.getMessage()); + assertEquals( + "Unknown error while making a remote service call: transport error", error.getMessage()); assertTrue(error.getCause() instanceof IOException); assertNull(error.getHttpResponse()); assertNull(error.getRemoteConfigErrorCode()); @@ -1048,8 +1220,12 @@ public void testListVersionsErrorWithZeroContentResponse() { client.listVersions(null); fail("No error thrown for HTTP error"); } catch (FirebaseRemoteConfigException error) { - checkExceptionFromHttpResponse(error, HTTP_STATUS_TO_ERROR_CODE.get(code), null, - "Unexpected HTTP response with status: " + code + "\nnull", HttpMethods.GET); + checkExceptionFromHttpResponse( + error, + HTTP_STATUS_TO_ERROR_CODE.get(code), + null, + "Unexpected HTTP response with status: " + code + "\nnull", + HttpMethods.GET); } checkGetRequestHeader(interceptor.getLastRequest(), ":listVersions"); } @@ -1064,8 +1240,12 @@ public void testListVersionsErrorWithMalformedResponse() { client.listVersions(null); fail("No error thrown for HTTP error"); } catch (FirebaseRemoteConfigException error) { - checkExceptionFromHttpResponse(error, HTTP_STATUS_TO_ERROR_CODE.get(code), null, - "Unexpected HTTP response with status: " + code + "\nnot json", HttpMethods.GET); + checkExceptionFromHttpResponse( + error, + HTTP_STATUS_TO_ERROR_CODE.get(code), + null, + "Unexpected HTTP response with status: " + code + "\nnot json", + HttpMethods.GET); } checkGetRequestHeader(interceptor.getLastRequest(), ":listVersions"); } @@ -1074,15 +1254,17 @@ public void testListVersionsErrorWithMalformedResponse() { @Test public void testListVersionsErrorWithDetails() { for (int code : HTTP_STATUS_CODES) { - response.setStatusCode(code).setContent( + response + .setStatusCode(code) + .setContent( "{\"error\": {\"status\": \"INVALID_ARGUMENT\", \"message\": \"test error\"}}"); try { client.listVersions(null); fail("No error thrown for HTTP error"); } catch (FirebaseRemoteConfigException error) { - checkExceptionFromHttpResponse(error, ErrorCode.INVALID_ARGUMENT, null, "test error", - HttpMethods.GET); + checkExceptionFromHttpResponse( + error, ErrorCode.INVALID_ARGUMENT, null, "test error", HttpMethods.GET); } checkGetRequestHeader(interceptor.getLastRequest(), ":listVersions"); } @@ -1091,17 +1273,22 @@ public void testListVersionsErrorWithDetails() { @Test public void testListVersionsErrorWithRcError() { for (int code : HTTP_STATUS_CODES) { - response.setStatusCode(code).setContent( + response + .setStatusCode(code) + .setContent( "{\"error\": {\"status\": \"INVALID_ARGUMENT\", " - + "\"message\": \"[INVALID_ARGUMENT]: test error\"}}"); + + "\"message\": \"[INVALID_ARGUMENT]: test error\"}}"); try { client.listVersions(null); fail("No error thrown for HTTP error"); } catch (FirebaseRemoteConfigException error) { - checkExceptionFromHttpResponse(error, ErrorCode.INVALID_ARGUMENT, - RemoteConfigErrorCode.INVALID_ARGUMENT, "[INVALID_ARGUMENT]: test error", - HttpMethods.GET); + checkExceptionFromHttpResponse( + error, + ErrorCode.INVALID_ARGUMENT, + RemoteConfigErrorCode.INVALID_ARGUMENT, + "[INVALID_ARGUMENT]: test error", + HttpMethods.GET); } checkGetRequestHeader(interceptor.getLastRequest(), ":listVersions"); } @@ -1126,7 +1313,8 @@ public void testBuilderNullRequestFactory() { @Test public void testFromApp() throws IOException { - FirebaseOptions options = FirebaseOptions.builder() + FirebaseOptions options = + FirebaseOptions.builder() .setCredentials(new MockGoogleCredentials("test-token")) .setProjectId("test-project") .build(); @@ -1138,8 +1326,8 @@ public void testFromApp() throws IOException { assertEquals(TEST_REMOTE_CONFIG_URL, client.getRemoteConfigUrl()); assertSame(options.getJsonFactory(), client.getJsonFactory()); - HttpRequest request = client.getRequestFactory().buildGetRequest( - new GenericUrl("https://example.com")); + HttpRequest request = + client.getRequestFactory().buildGetRequest(new GenericUrl("https://example.com")); assertEquals("Bearer test-token", request.getHeaders().getAuthorization()); } finally { app.delete(); @@ -1147,33 +1335,32 @@ public void testFromApp() throws IOException { } private FirebaseRemoteConfigClientImpl initRemoteConfigClient( - MockLowLevelHttpResponse mockResponse, HttpResponseInterceptor interceptor) { - MockHttpTransport transport = new MockHttpTransport.Builder() - .setLowLevelHttpResponse(mockResponse) - .build(); + MockLowLevelHttpResponse mockResponse, HttpResponseInterceptor interceptor) { + MockHttpTransport transport = + new MockHttpTransport.Builder().setLowLevelHttpResponse(mockResponse).build(); return FirebaseRemoteConfigClientImpl.builder() - .setProjectId("test-project") - .setJsonFactory(ApiClientUtils.getDefaultJsonFactory()) - .setRequestFactory(transport.createRequestFactory()) - .setResponseInterceptor(interceptor) - .build(); + .setProjectId("test-project") + .setJsonFactory(ApiClientUtils.getDefaultJsonFactory()) + .setRequestFactory(transport.createRequestFactory()) + .setResponseInterceptor(interceptor) + .build(); } private FirebaseRemoteConfigClientImpl initClientWithFaultyTransport() { HttpTransport transport = TestUtils.createFaultyHttpTransport(); return FirebaseRemoteConfigClientImpl.builder() - .setProjectId("test-project") - .setJsonFactory(ApiClientUtils.getDefaultJsonFactory()) - .setRequestFactory(transport.createRequestFactory()) - .build(); + .setProjectId("test-project") + .setJsonFactory(ApiClientUtils.getDefaultJsonFactory()) + .setRequestFactory(transport.createRequestFactory()) + .build(); } private FirebaseRemoteConfigClientImpl.Builder fullyPopulatedBuilder() { return FirebaseRemoteConfigClientImpl.builder() - .setProjectId("test-project") - .setJsonFactory(ApiClientUtils.getDefaultJsonFactory()) - .setRequestFactory(ApiClientUtils.getDefaultTransport().createRequestFactory()); + .setProjectId("test-project") + .setJsonFactory(ApiClientUtils.getDefaultJsonFactory()) + .setRequestFactory(ApiClientUtils.getDefaultTransport().createRequestFactory()); } private void checkGetRequestHeader(HttpRequest request) { @@ -1189,6 +1376,19 @@ private void checkGetRequestHeader(HttpRequest request, String urlSuffix) { assertEquals("gzip", headers.getAcceptEncoding()); } + private void checkGetRequestHeaderForServer(HttpRequest request) { + checkGetRequestHeaderForServer(request, ""); + } + + private void checkGetRequestHeaderForServer(HttpRequest request, String urlSuffix) { + assertEquals("GET", request.getRequestMethod()); + assertEquals(TEST_SERVER_REMOTE_CONFIG_URL + urlSuffix, request.getUrl().toString()); + HttpHeaders headers = request.getHeaders(); + assertEquals("fire-admin-java/" + SdkUtils.getVersion(), headers.get("X-Firebase-Client")); + assertEquals(SdkUtils.getMetricsHeader(), request.getHeaders().get("X-Goog-Api-Client")); + assertEquals("gzip", headers.getAcceptEncoding()); + } + private void checkPutRequestHeader(HttpRequest request) { checkPutRequestHeader(request, "", TEST_ETAG); } @@ -1212,8 +1412,8 @@ private void checkPostRequestHeader(HttpRequest request, String urlSuffix) { assertEquals("gzip", headers.getAcceptEncoding()); } - private void checkRequestContent( - HttpRequest request, Map expected) throws IOException { + private void checkRequestContent(HttpRequest request, Map expected) + throws IOException { ByteArrayOutputStream out = new ByteArrayOutputStream(); request.getContent().writeTo(out); JsonParser parser = ApiClientUtils.getDefaultJsonFactory().createJsonParser(out.toString()); @@ -1223,11 +1423,11 @@ private void checkRequestContent( } private void checkExceptionFromHttpResponse( - FirebaseRemoteConfigException error, - ErrorCode expectedCode, - RemoteConfigErrorCode expectedRemoteConfigCode, - String expectedMessage, - String httpMethod) { + FirebaseRemoteConfigException error, + ErrorCode expectedCode, + RemoteConfigErrorCode expectedRemoteConfigCode, + String expectedMessage, + String httpMethod) { assertEquals(expectedCode, error.getErrorCode()); assertEquals(expectedMessage, error.getMessage()); assertTrue(error.getCause() instanceof HttpResponseException); @@ -1238,4 +1438,225 @@ private void checkExceptionFromHttpResponse( assertEquals(httpMethod, request.getMethod()); assertTrue(request.getUrl().startsWith("https://firebaseremoteconfig.googleapis.com")); } + + // Get server template tests + + @Test + public void testGetServerTemplate() throws Exception { + response.addHeader("etag", TEST_ETAG); + response.setContent(MOCK_SERVER_TEMPLATE_RESPONSE); + + String receivedTemplate = client.getServerTemplate(); + ServerTemplateData serverTemplateData = ServerTemplateData.fromJSON(receivedTemplate); + + assertEquals(EXPECTED_PARAMETERS, serverTemplateData.getParameters()); + assertEquals(TEST_ETAG, serverTemplateData.getETag()); + assertEquals( + convertObjectToString(EXPECTED_SERVER_CONDITIONS), + convertObjectToString(serverTemplateData.getServerConditions())); + assertEquals(1605423446000L, serverTemplateData.getVersion().getUpdateTime()); + checkGetRequestHeaderForServer(interceptor.getLastRequest()); + } + + @Test + public void testGetServerTemplateWithTimestampUpToNanosecondPrecision() throws Exception { + List timestamps = + ImmutableList.of( + "2020-11-15T06:57:26.342Z", + "2020-11-15T06:57:26.342763Z", + "2020-11-15T06:57:26.342763941Z"); + for (String timestamp : timestamps) { + response.addHeader("etag", TEST_ETAG); + String templateResponse = + "{\"version\": {" + + " \"versionNumber\": \"17\"," + + " \"updateTime\": \"" + + timestamp + + "\"" + + " }}"; + response.setContent(templateResponse); + + String receivedTemplate = client.getServerTemplate(); + ServerTemplateData serverTemplateData = ServerTemplateData.fromJSON(receivedTemplate); + assertEquals(TEST_ETAG, serverTemplateData.getETag()); + assertEquals("17", serverTemplateData.getVersion().getVersionNumber()); + assertEquals(1605423446000L, serverTemplateData.getVersion().getUpdateTime()); + checkGetRequestHeaderForServer(interceptor.getLastRequest()); + } + } + + @Test + public void testGetServerTemplateWithEmptyTemplateResponse() throws Exception { + response.addHeader("etag", TEST_ETAG); + response.setContent("{}"); + + String receivedTemplate = client.getServerTemplate(); + ServerTemplateData serverTemplateData = ServerTemplateData.fromJSON(receivedTemplate); + + assertEquals(TEST_ETAG, serverTemplateData.getETag()); + assertEquals(0, serverTemplateData.getParameters().size()); + assertEquals(0, serverTemplateData.getServerConditions().size()); + assertEquals(0, serverTemplateData.getParameterGroups().size()); + assertNull(serverTemplateData.getVersion()); + checkGetRequestHeaderForServer(interceptor.getLastRequest()); + } + + @Test(expected = IllegalStateException.class) + public void testGetServerTemplateWithNoEtag() throws FirebaseRemoteConfigException { + // ETag does not exist + response.setContent(MOCK_SERVER_TEMPLATE_RESPONSE); + + client.getServerTemplate(); + } + + @Test(expected = IllegalStateException.class) + public void testGetServerTemplateWithEmptyEtag() throws FirebaseRemoteConfigException { + // Empty ETag + response.addHeader("etag", ""); + response.setContent(MOCK_SERVER_TEMPLATE_RESPONSE); + + client.getServerTemplate(); + } + + @Test + public void testGetServerTemplateHttpError() { + for (int code : HTTP_STATUS_CODES) { + response.setStatusCode(code).setContent("{}"); + + try { + client.getServerTemplate(); + fail("No error thrown for HTTP error"); + } catch (FirebaseRemoteConfigException error) { + checkExceptionFromHttpResponse( + error, + HTTP_STATUS_TO_ERROR_CODE.get(code), + null, + "Unexpected HTTP response with status: " + code + "\n{}", + HttpMethods.GET); + } + checkGetRequestHeaderForServer(interceptor.getLastRequest()); + } + } + + @Test + public void testGetServerTemplateTransportError() { + client = initClientWithFaultyTransport(); + + try { + client.getServerTemplate(); + fail("No error thrown for HTTP error"); + } catch (FirebaseRemoteConfigException error) { + assertEquals(ErrorCode.UNKNOWN, error.getErrorCode()); + assertEquals( + "Unknown error while making a remote service call: transport error", error.getMessage()); + assertTrue(error.getCause() instanceof IOException); + assertNull(error.getHttpResponse()); + assertNull(error.getRemoteConfigErrorCode()); + } + } + + @Test + public void testGetServerTemplateSuccessResponseWithUnexpectedPayload() { + response.setContent("not valid json"); + + try { + client.getServerTemplate(); + fail("No error thrown for malformed response"); + } catch (FirebaseRemoteConfigException error) { + assertEquals(ErrorCode.UNKNOWN, error.getErrorCode()); + assertTrue(error.getMessage().startsWith("Error while parsing HTTP response: ")); + assertNotNull(error.getCause()); + assertNotNull(error.getHttpResponse()); + assertNull(error.getRemoteConfigErrorCode()); + } + checkGetRequestHeaderForServer(interceptor.getLastRequest()); + } + + @Test + public void testGetServerTemplateErrorWithZeroContentResponse() { + for (int code : HTTP_STATUS_CODES) { + response.setStatusCode(code).setZeroContent(); + + try { + client.getServerTemplate(); + fail("No error thrown for HTTP error"); + } catch (FirebaseRemoteConfigException error) { + checkExceptionFromHttpResponse( + error, + HTTP_STATUS_TO_ERROR_CODE.get(code), + null, + "Unexpected HTTP response with status: " + code + "\nnull", + HttpMethods.GET); + } + checkGetRequestHeaderForServer(interceptor.getLastRequest()); + } + } + + @Test + public void testGetServerTemplateErrorWithMalformedResponse() { + for (int code : HTTP_STATUS_CODES) { + response.setStatusCode(code).setContent("not json"); + + try { + client.getServerTemplate(); + fail("No error thrown for HTTP error"); + } catch (FirebaseRemoteConfigException error) { + checkExceptionFromHttpResponse( + error, + HTTP_STATUS_TO_ERROR_CODE.get(code), + null, + "Unexpected HTTP response with status: " + code + "\nnot json", + HttpMethods.GET); + } + checkGetRequestHeaderForServer(interceptor.getLastRequest()); + } + } + + @Test + public void testGetServerTemplateErrorWithDetails() { + for (int code : HTTP_STATUS_CODES) { + response + .setStatusCode(code) + .setContent( + "{\"error\": {\"status\": \"INVALID_ARGUMENT\", \"message\": \"test error\"}}"); + + try { + client.getServerTemplate(); + fail("No error thrown for HTTP error"); + } catch (FirebaseRemoteConfigException error) { + checkExceptionFromHttpResponse( + error, ErrorCode.INVALID_ARGUMENT, null, "test error", HttpMethods.GET); + } + checkGetRequestHeaderForServer(interceptor.getLastRequest()); + } + } + + @Test + public void testGetServerTemplateErrorWithRcError() { + for (int code : HTTP_STATUS_CODES) { + response + .setStatusCode(code) + .setContent( + "{\"error\": {\"status\": \"INVALID_ARGUMENT\", " + + "\"message\": \"[INVALID_ARGUMENT]: test error\"}}"); + + try { + client.getServerTemplate(); + fail("No error thrown for HTTP error"); + } catch (FirebaseRemoteConfigException error) { + checkExceptionFromHttpResponse( + error, + ErrorCode.INVALID_ARGUMENT, + RemoteConfigErrorCode.INVALID_ARGUMENT, + "[INVALID_ARGUMENT]: test error", + HttpMethods.GET); + } + checkGetRequestHeaderForServer(interceptor.getLastRequest()); + } + } + + public static String convertObjectToString(Object object) { + Gson gson = new GsonBuilder().setPrettyPrinting().create(); // Optional: pretty printing + return gson.toJson(object); + } } diff --git a/src/test/java/com/google/firebase/remoteconfig/FirebaseRemoteConfigTest.java b/src/test/java/com/google/firebase/remoteconfig/FirebaseRemoteConfigTest.java index d3e7fbff2..8b416b67b 100644 --- a/src/test/java/com/google/firebase/remoteconfig/FirebaseRemoteConfigTest.java +++ b/src/test/java/com/google/firebase/remoteconfig/FirebaseRemoteConfigTest.java @@ -27,22 +27,26 @@ import com.google.firebase.FirebaseOptions; import com.google.firebase.TestOnlyImplFirebaseTrampolines; import com.google.firebase.auth.MockGoogleCredentials; - import com.google.firebase.remoteconfig.internal.TemplateResponse; - +import com.google.gson.JsonElement; +import com.google.gson.JsonParser; import java.util.concurrent.ExecutionException; - import org.junit.After; import org.junit.Test; + +/** Tests +* for {@link FirebaseRemoteConfig}. +* */ public class FirebaseRemoteConfigTest { - private static final FirebaseOptions TEST_OPTIONS = FirebaseOptions.builder() + private static final FirebaseOptions TEST_OPTIONS = + FirebaseOptions.builder() .setCredentials(new MockGoogleCredentials("test-token")) .setProjectId("test-project") .build(); private static final FirebaseRemoteConfigException TEST_EXCEPTION = - new FirebaseRemoteConfigException(ErrorCode.INTERNAL, "Test error message"); + new FirebaseRemoteConfigException(ErrorCode.INTERNAL, "Test error message"); @After public void tearDown() { @@ -76,10 +80,25 @@ public void testDefaultRemoteConfigClient() { assertTrue(client instanceof FirebaseRemoteConfigClientImpl); assertSame(client, remoteConfig.getRemoteConfigClient()); - String expectedUrl = "https://firebaseremoteconfig.googleapis.com/v1/projects/test-project/remoteConfig"; + String expectedUrl = + "https://firebaseremoteconfig.googleapis.com/v1/projects/test-project/remoteConfig"; assertEquals(expectedUrl, ((FirebaseRemoteConfigClientImpl) client).getRemoteConfigUrl()); } + @Test + public void testDefaultServerRemoteConfigClient() { + FirebaseApp app = FirebaseApp.initializeApp(TEST_OPTIONS, "custom-app"); + FirebaseRemoteConfig remoteConfig = FirebaseRemoteConfig.getInstance(app); + + FirebaseRemoteConfigClient client = remoteConfig.getRemoteConfigClient(); + + assertTrue(client instanceof FirebaseRemoteConfigClientImpl); + assertSame(client, remoteConfig.getRemoteConfigClient()); + String expectedUrl = + "https://firebaseremoteconfig.googleapis.com/v1/projects/test-project/namespaces/firebase-server/serverRemoteConfig"; + assertEquals(expectedUrl, ((FirebaseRemoteConfigClientImpl) client).getServerRemoteConfigUrl()); + } + @Test public void testAppDelete() { FirebaseApp app = FirebaseApp.initializeApp(TEST_OPTIONS, "custom-app"); @@ -98,16 +117,16 @@ public void testAppDelete() { @Test public void testRemoteConfigClientWithoutProjectId() { - FirebaseOptions options = FirebaseOptions.builder() - .setCredentials(new MockGoogleCredentials("test-token")) - .build(); + FirebaseOptions options = + FirebaseOptions.builder().setCredentials(new MockGoogleCredentials("test-token")).build(); FirebaseApp.initializeApp(options); try { FirebaseRemoteConfig.getInstance(); fail("No error thrown for missing project ID"); } catch (IllegalArgumentException expected) { - String message = "Project ID is required to access Remote Config service. Use a service " + String message = + "Project ID is required to access Remote Config service. Use a service " + "account credential or set the project ID explicitly via FirebaseOptions. " + "Alternatively you can also set the project ID via the GOOGLE_CLOUD_PROJECT " + "environment variable."; @@ -121,8 +140,8 @@ public void testRemoteConfigClientWithoutProjectId() { @Test public void testGetTemplate() throws FirebaseRemoteConfigException { - MockRemoteConfigClient client = MockRemoteConfigClient.fromTemplate( - new Template().setETag(TEST_ETAG)); + MockRemoteConfigClient client = + MockRemoteConfigClient.fromTemplate(new Template().setETag(TEST_ETAG)); FirebaseRemoteConfig remoteConfig = getRemoteConfig(client); Template template = remoteConfig.getTemplate(); @@ -144,8 +163,8 @@ public void testGetTemplateFailure() { @Test public void testGetTemplateAsync() throws Exception { - MockRemoteConfigClient client = MockRemoteConfigClient.fromTemplate( - new Template().setETag(TEST_ETAG)); + MockRemoteConfigClient client = + MockRemoteConfigClient.fromTemplate(new Template().setETag(TEST_ETAG)); FirebaseRemoteConfig remoteConfig = getRemoteConfig(client); Template template = remoteConfig.getTemplateAsync().get(); @@ -169,8 +188,8 @@ public void testGetTemplateAsyncFailure() throws InterruptedException { @Test public void testGetTemplateAtVersionWithStringValue() throws FirebaseRemoteConfigException { - MockRemoteConfigClient client = MockRemoteConfigClient.fromTemplate( - new Template().setETag(TEST_ETAG)); + MockRemoteConfigClient client = + MockRemoteConfigClient.fromTemplate(new Template().setETag(TEST_ETAG)); FirebaseRemoteConfig remoteConfig = getRemoteConfig(client); Template template = remoteConfig.getTemplateAtVersion("64"); @@ -192,8 +211,8 @@ public void testGetTemplateAtVersionWithStringValueFailure() { @Test public void testGetTemplateAtVersionAsyncWithStringValue() throws Exception { - MockRemoteConfigClient client = MockRemoteConfigClient.fromTemplate( - new Template().setETag(TEST_ETAG)); + MockRemoteConfigClient client = + MockRemoteConfigClient.fromTemplate(new Template().setETag(TEST_ETAG)); FirebaseRemoteConfig remoteConfig = getRemoteConfig(client); Template template = remoteConfig.getTemplateAtVersionAsync("55").get(); @@ -215,8 +234,8 @@ public void testGetTemplateAtVersionAsyncWithStringValueFailure() throws Interru @Test public void testGetTemplateAtVersionWithLongValue() throws FirebaseRemoteConfigException { - MockRemoteConfigClient client = MockRemoteConfigClient.fromTemplate( - new Template().setETag(TEST_ETAG)); + MockRemoteConfigClient client = + MockRemoteConfigClient.fromTemplate(new Template().setETag(TEST_ETAG)); FirebaseRemoteConfig remoteConfig = getRemoteConfig(client); Template template = remoteConfig.getTemplateAtVersion(64L); @@ -238,8 +257,8 @@ public void testGetTemplateAtVersionWithLongValueFailure() { @Test public void testGetTemplateAtVersionAsyncWithLongValue() throws Exception { - MockRemoteConfigClient client = MockRemoteConfigClient.fromTemplate( - new Template().setETag(TEST_ETAG)); + MockRemoteConfigClient client = + MockRemoteConfigClient.fromTemplate(new Template().setETag(TEST_ETAG)); FirebaseRemoteConfig remoteConfig = getRemoteConfig(client); Template template = remoteConfig.getTemplateAtVersionAsync(55L).get(); @@ -407,8 +426,8 @@ public void testForcePublishTemplateAsyncFailure() throws InterruptedException { @Test public void testRollbackWithStringValue() throws FirebaseRemoteConfigException { - MockRemoteConfigClient client = MockRemoteConfigClient.fromTemplate( - new Template().setETag(TEST_ETAG)); + MockRemoteConfigClient client = + MockRemoteConfigClient.fromTemplate(new Template().setETag(TEST_ETAG)); FirebaseRemoteConfig remoteConfig = getRemoteConfig(client); Template template = remoteConfig.rollback("64"); @@ -430,8 +449,8 @@ public void testRollbackWithStringValueFailure() { @Test public void testRollbackAsyncWithStringValue() throws Exception { - MockRemoteConfigClient client = MockRemoteConfigClient.fromTemplate( - new Template().setETag(TEST_ETAG)); + MockRemoteConfigClient client = + MockRemoteConfigClient.fromTemplate(new Template().setETag(TEST_ETAG)); FirebaseRemoteConfig remoteConfig = getRemoteConfig(client); Template template = remoteConfig.rollbackAsync("55").get(); @@ -453,8 +472,8 @@ public void testRollbackAsyncWithStringValueFailure() throws InterruptedExceptio @Test public void testRollbackWithLongValue() throws FirebaseRemoteConfigException { - MockRemoteConfigClient client = MockRemoteConfigClient.fromTemplate( - new Template().setETag(TEST_ETAG)); + MockRemoteConfigClient client = + MockRemoteConfigClient.fromTemplate(new Template().setETag(TEST_ETAG)); FirebaseRemoteConfig remoteConfig = getRemoteConfig(client); Template template = remoteConfig.rollback(64L); @@ -476,8 +495,8 @@ public void testRollbackWithLongValueFailure() { @Test public void testRollbackAsyncWithLongValue() throws Exception { - MockRemoteConfigClient client = MockRemoteConfigClient.fromTemplate( - new Template().setETag(TEST_ETAG)); + MockRemoteConfigClient client = + MockRemoteConfigClient.fromTemplate(new Template().setETag(TEST_ETAG)); FirebaseRemoteConfig remoteConfig = getRemoteConfig(client); Template template = remoteConfig.rollbackAsync(55L).get(); @@ -501,7 +520,8 @@ public void testRollbackAsyncWithLongValueFailure() throws InterruptedException @Test public void testListVersionsWithNoOptions() throws FirebaseRemoteConfigException { - MockRemoteConfigClient client = MockRemoteConfigClient.fromListVersionsResponse( + MockRemoteConfigClient client = + MockRemoteConfigClient.fromListVersionsResponse( new TemplateResponse.ListVersionsResponse().setNextPageToken("token")); FirebaseRemoteConfig remoteConfig = getRemoteConfig(client); @@ -524,7 +544,8 @@ public void testListVersionsWithNoOptionsFailure() { @Test public void testListVersionsAsyncWithNoOptions() throws Exception { - MockRemoteConfigClient client = MockRemoteConfigClient.fromListVersionsResponse( + MockRemoteConfigClient client = + MockRemoteConfigClient.fromListVersionsResponse( new TemplateResponse.ListVersionsResponse().setNextPageToken("token")); FirebaseRemoteConfig remoteConfig = getRemoteConfig(client); @@ -547,12 +568,13 @@ public void testListVersionsAsyncWithNoOptionsFailure() throws InterruptedExcept @Test public void testListVersionsWithOptions() throws FirebaseRemoteConfigException { - MockRemoteConfigClient client = MockRemoteConfigClient.fromListVersionsResponse( + MockRemoteConfigClient client = + MockRemoteConfigClient.fromListVersionsResponse( new TemplateResponse.ListVersionsResponse().setNextPageToken("token")); FirebaseRemoteConfig remoteConfig = getRemoteConfig(client); - ListVersionsPage listVersionsPage = remoteConfig.listVersions( - ListVersionsOptions.builder().build()); + ListVersionsPage listVersionsPage = + remoteConfig.listVersions(ListVersionsOptions.builder().build()); assertEquals("token", listVersionsPage.getNextPageToken()); } @@ -571,12 +593,13 @@ public void testListVersionsWithOptionsFailure() { @Test public void testListVersionsAsyncWithOptions() throws Exception { - MockRemoteConfigClient client = MockRemoteConfigClient.fromListVersionsResponse( + MockRemoteConfigClient client = + MockRemoteConfigClient.fromListVersionsResponse( new TemplateResponse.ListVersionsResponse().setNextPageToken("token")); FirebaseRemoteConfig remoteConfig = getRemoteConfig(client); - ListVersionsPage listVersionsPage = remoteConfig.listVersionsAsync( - ListVersionsOptions.builder().build()).get(); + ListVersionsPage listVersionsPage = + remoteConfig.listVersionsAsync(ListVersionsOptions.builder().build()).get(); assertEquals("token", listVersionsPage.getNextPageToken()); } @@ -597,4 +620,62 @@ private FirebaseRemoteConfig getRemoteConfig(FirebaseRemoteConfigClient client) FirebaseApp app = FirebaseApp.initializeApp(TEST_OPTIONS); return new FirebaseRemoteConfig(app, client); } + + // Get Server template tests + + @Test + public void testGetServerTemplate() throws FirebaseRemoteConfigException { + MockRemoteConfigClient client = + MockRemoteConfigClient.fromServerTemplate( + new ServerTemplateData().setETag(TEST_ETAG).toJSON()); + FirebaseRemoteConfig remoteConfig = getRemoteConfig(client); + + ServerTemplate template = remoteConfig.getServerTemplate(); + String templateData = template.toJson(); + JsonElement expectedJson = + JsonParser.parseString(new ServerTemplateData().setETag(TEST_ETAG).toJSON()); + JsonElement actualJson = JsonParser.parseString(templateData); + + assertEquals(expectedJson, actualJson); + } + + @Test + public void testGetServerTemplateFailure() { + MockRemoteConfigClient client = MockRemoteConfigClient.fromException(TEST_EXCEPTION); + FirebaseRemoteConfig remoteConfig = getRemoteConfig(client); + + try { + remoteConfig.getServerTemplate(); + } catch (FirebaseRemoteConfigException e) { + assertSame(TEST_EXCEPTION, e); + } + } + + @Test + public void testGetServerTemplateAsync() throws Exception { + MockRemoteConfigClient client = + MockRemoteConfigClient.fromServerTemplate( + new ServerTemplateData().setETag(TEST_ETAG).toJSON()); + FirebaseRemoteConfig remoteConfig = getRemoteConfig(client); + + ServerTemplate template = remoteConfig.getServerTemplateAsync().get(); + String templateData = template.toJson(); + JsonElement expectedJson = + JsonParser.parseString(new ServerTemplateData().setETag(TEST_ETAG).toJSON()); + JsonElement actualJson = JsonParser.parseString(templateData); + + assertEquals(expectedJson, actualJson); + } + + @Test + public void testGetServerTemplateAsyncFailure() throws InterruptedException { + MockRemoteConfigClient client = MockRemoteConfigClient.fromException(TEST_EXCEPTION); + FirebaseRemoteConfig remoteConfig = getRemoteConfig(client); + + try { + remoteConfig.getServerTemplateAsync().get(); + } catch (ExecutionException e) { + assertSame(TEST_EXCEPTION, e.getCause()); + } + } } diff --git a/src/test/java/com/google/firebase/remoteconfig/MockRemoteConfigClient.java b/src/test/java/com/google/firebase/remoteconfig/MockRemoteConfigClient.java index 9ca58508d..3ac7f6b1c 100644 --- a/src/test/java/com/google/firebase/remoteconfig/MockRemoteConfigClient.java +++ b/src/test/java/com/google/firebase/remoteconfig/MockRemoteConfigClient.java @@ -21,28 +21,35 @@ public class MockRemoteConfigClient implements FirebaseRemoteConfigClient{ private final Template resultTemplate; + private final String resultServerTemplate; private final FirebaseRemoteConfigException exception; private final ListVersionsResponse listVersionsResponse; private MockRemoteConfigClient(Template resultTemplate, - ListVersionsResponse listVersionsResponse, - FirebaseRemoteConfigException exception) { + String resultServerTemplate, + ListVersionsResponse listVersionsResponse, + FirebaseRemoteConfigException exception) { this.resultTemplate = resultTemplate; + this.resultServerTemplate = resultServerTemplate; this.listVersionsResponse = listVersionsResponse; this.exception = exception; } static MockRemoteConfigClient fromTemplate(Template resultTemplate) { - return new MockRemoteConfigClient(resultTemplate, null, null); + return new MockRemoteConfigClient(resultTemplate,null, null, null); + } + + static MockRemoteConfigClient fromServerTemplate(String resultServerTemplate) { + return new MockRemoteConfigClient(null, resultServerTemplate,null, null); } static MockRemoteConfigClient fromListVersionsResponse( ListVersionsResponse listVersionsResponse) { - return new MockRemoteConfigClient(null, listVersionsResponse, null); + return new MockRemoteConfigClient(null,null, listVersionsResponse, null); } static MockRemoteConfigClient fromException(FirebaseRemoteConfigException exception) { - return new MockRemoteConfigClient(null, null, exception); + return new MockRemoteConfigClient(null,null, null, exception); } @Override @@ -53,6 +60,14 @@ public Template getTemplate() throws FirebaseRemoteConfigException { return resultTemplate; } + @Override + public String getServerTemplate() throws FirebaseRemoteConfigException { + if (exception != null) { + throw exception; + } + return resultServerTemplate; + } + @Override public Template getTemplateAtVersion(String versionNumber) throws FirebaseRemoteConfigException { if (exception != null) { diff --git a/src/test/java/com/google/firebase/remoteconfig/ServerConditionTest.java b/src/test/java/com/google/firebase/remoteconfig/ServerConditionTest.java new file mode 100644 index 000000000..6cbece1c0 --- /dev/null +++ b/src/test/java/com/google/firebase/remoteconfig/ServerConditionTest.java @@ -0,0 +1,218 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.remoteconfig; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThrows; + +import com.google.common.collect.ImmutableList; +import com.google.firebase.remoteconfig.internal.ServerTemplateResponse.CustomSignalConditionResponse; +import com.google.firebase.remoteconfig.internal.ServerTemplateResponse.OneOfConditionResponse; +import com.google.firebase.remoteconfig.internal.ServerTemplateResponse.ServerConditionResponse; +import java.util.ArrayList; +import org.junit.Test; + +/** Tests +* for {@link ServerCondition}. +* */ +public class ServerConditionTest { + + @Test + public void testConstructor() { + OneOfCondition conditions = + new OneOfCondition() + .setOrCondition( + new OrCondition( + ImmutableList.of( + new OneOfCondition() + .setAndCondition( + new AndCondition( + ImmutableList.of( + new OneOfCondition() + .setCustomSignal( + new CustomSignalCondition( + "users", + CustomSignalOperator.NUMERIC_LESS_THAN, + new ArrayList<>( + ImmutableList.of("100")))))))))); + ServerCondition serverCondition = new ServerCondition("ios_en_1", conditions); + + assertEquals("ios_en_1", serverCondition.getName()); + assertEquals(conditions, serverCondition.getCondition()); + } + + @Test + public void testConstructorWithResponse() { + CustomSignalConditionResponse customResponse = + new CustomSignalConditionResponse() + .setKey("test_key") + .setOperator("NUMERIC_EQUAL") + .setTargetValues(ImmutableList.of("1")); + OneOfConditionResponse conditionResponse = + new OneOfConditionResponse().setCustomSignalCondition(customResponse); + ServerConditionResponse response = + new ServerConditionResponse().setName("ios_en_2").setServerCondition(conditionResponse); + ServerCondition serverCondition = new ServerCondition(response); + + assertEquals("ios_en_2", serverCondition.getName()); + assertEquals("test_key", serverCondition.getCondition().getCustomSignal().getCustomSignalKey()); + } + + @Test + public void testIllegalConstructor() { + IllegalArgumentException error = + assertThrows(IllegalArgumentException.class, () -> new ServerCondition(null, null)); + + assertEquals("condition name must not be null or empty", error.getMessage()); + } + + @Test + public void testConstructorWithNullServerConditionResponse() { + assertThrows(NullPointerException.class, () -> new ServerCondition(null)); + } + + @Test + public void testSetNullName() { + OneOfCondition conditions = + new OneOfCondition() + .setOrCondition( + new OrCondition( + ImmutableList.of( + new OneOfCondition() + .setAndCondition( + new AndCondition( + ImmutableList.of( + new OneOfCondition() + .setCustomSignal( + new CustomSignalCondition( + "users", + CustomSignalOperator.NUMERIC_LESS_THAN, + new ArrayList<>( + ImmutableList.of("100")))))))))); + ServerCondition condition = new ServerCondition("ios", conditions); + + IllegalArgumentException error = assertThrows(IllegalArgumentException.class, + () -> condition.setName(null)); + + assertEquals("condition name must not be null or empty", error.getMessage()); + } + + @Test + public void testSetEmptyName() { + OneOfCondition conditions = + new OneOfCondition() + .setOrCondition( + new OrCondition( + ImmutableList.of( + new OneOfCondition() + .setAndCondition( + new AndCondition( + ImmutableList.of( + new OneOfCondition() + .setCustomSignal( + new CustomSignalCondition( + "users", + CustomSignalOperator.NUMERIC_LESS_THAN, + new ArrayList<>( + ImmutableList.of("100")))))))))); + ServerCondition condition = new ServerCondition("ios", conditions); + + IllegalArgumentException error = assertThrows(IllegalArgumentException.class, + () -> condition.setName("")); + + assertEquals("condition name must not be null or empty", error.getMessage()); + } + + @Test + public void testSetNullServerCondition() { + OneOfCondition conditions = + new OneOfCondition() + .setOrCondition( + new OrCondition( + ImmutableList.of( + new OneOfCondition() + .setAndCondition( + new AndCondition( + ImmutableList.of( + new OneOfCondition() + .setCustomSignal( + new CustomSignalCondition( + "users", + CustomSignalOperator.NUMERIC_LESS_THAN, + new ArrayList<>( + ImmutableList.of("100")))))))))); + ServerCondition condition = new ServerCondition("ios", conditions); + + assertThrows(NullPointerException.class, () -> condition.setServerCondition(null)); + } + + @Test + public void testEquality() { + OneOfCondition conditionOne = + new OneOfCondition() + .setOrCondition( + new OrCondition( + ImmutableList.of( + new OneOfCondition() + .setAndCondition( + new AndCondition( + ImmutableList.of( + new OneOfCondition() + .setCustomSignal( + new CustomSignalCondition( + "users", + CustomSignalOperator.NUMERIC_LESS_THAN, + new ArrayList<>( + ImmutableList.of("100")))))))))); + OneOfCondition conditionTwo = + new OneOfCondition() + .setOrCondition( + new OrCondition( + ImmutableList.of( + new OneOfCondition() + .setAndCondition( + new AndCondition( + ImmutableList.of( + new OneOfCondition() + .setCustomSignal( + new CustomSignalCondition( + "users", + CustomSignalOperator.NUMERIC_LESS_THAN, + new ArrayList<>(ImmutableList.of("100")))), + new OneOfCondition() + .setCustomSignal( + new CustomSignalCondition( + "users", + CustomSignalOperator + .NUMERIC_GREATER_THAN, + new ArrayList<>(ImmutableList.of("20")))), + new OneOfCondition() + .setPercent( + new PercentCondition( + new MicroPercentRange(25000000, 100000000), + PercentConditionOperator.BETWEEN, + "cla24qoibb61")))))))); + + final ServerCondition serverConditionOne = new ServerCondition("ios", conditionOne); + final ServerCondition serverConditionTwo = new ServerCondition("ios", conditionOne); + final ServerCondition serverConditionThree = new ServerCondition("android", conditionTwo); + final ServerCondition serverConditionFour = new ServerCondition("android", conditionTwo); + + assertEquals(serverConditionOne, serverConditionTwo); + assertEquals(serverConditionThree, serverConditionFour); + } +} diff --git a/src/test/java/com/google/firebase/remoteconfig/ServerTemplateImplTest.java b/src/test/java/com/google/firebase/remoteconfig/ServerTemplateImplTest.java new file mode 100644 index 000000000..bafe84331 --- /dev/null +++ b/src/test/java/com/google/firebase/remoteconfig/ServerTemplateImplTest.java @@ -0,0 +1,421 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.remoteconfig; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThrows; + +import com.google.api.core.ApiFuture; +import com.google.firebase.FirebaseApp; +import com.google.firebase.FirebaseOptions; +import com.google.firebase.auth.MockGoogleCredentials; +import com.google.firebase.testing.TestUtils; +import com.google.gson.JsonElement; +import com.google.gson.JsonParser; +import org.junit.BeforeClass; +import org.junit.Test; + +/** Tests +* for {@link ServerTemplateImpl}. +* */ +public class ServerTemplateImplTest { + + private static final FirebaseOptions TEST_OPTIONS = + FirebaseOptions.builder() + .setCredentials(new MockGoogleCredentials("test-token")) + .setProjectId("test-project") + .build(); + + private static String cacheTemplate; + + @BeforeClass + public static void setUpClass() { + cacheTemplate = TestUtils.loadResource("getServerTemplateData.json"); + } + + @Test + public void testServerTemplateWithoutCacheValueThrowsException() + throws FirebaseRemoteConfigException { + KeysAndValues defaultConfig = new KeysAndValues.Builder().build(); + + IllegalArgumentException error = + assertThrows( + IllegalArgumentException.class, + () -> new ServerTemplateImpl.Builder(null).defaultConfig(defaultConfig).build()); + + assertEquals("JSON String must not be null or empty.", error.getMessage()); + } + + @Test + public void testEvaluateWithoutContextReturnsDefaultValue() throws FirebaseRemoteConfigException { + KeysAndValues defaultConfig = new KeysAndValues.Builder().build(); + ServerTemplate template = + new ServerTemplateImpl.Builder(null) + .defaultConfig(defaultConfig) + .cachedTemplate(cacheTemplate) + .build(); + + ServerConfig evaluatedConfig = template.evaluate(); + + assertEquals("Default value", evaluatedConfig.getString("Custom")); + } + + @Test + public void testEvaluateCustomSignalReturnsDefaultValue() throws FirebaseRemoteConfigException { + KeysAndValues defaultConfig = new KeysAndValues.Builder().build(); + KeysAndValues context = new KeysAndValues.Builder().put("users", "100").build(); + ServerTemplate template = + new ServerTemplateImpl.Builder(null) + .defaultConfig(defaultConfig) + .cachedTemplate(cacheTemplate) + .build(); + + ServerConfig evaluatedConfig = template.evaluate(context); + + assertEquals("Default value", evaluatedConfig.getString("Custom")); + } + + @Test + public void testEvaluateCustomSignalReturnsConditionalValue() + throws FirebaseRemoteConfigException { + KeysAndValues defaultConfig = new KeysAndValues.Builder().build(); + KeysAndValues context = new KeysAndValues.Builder().put("users", "99").build(); + ServerTemplate template = + new ServerTemplateImpl.Builder(null) + .defaultConfig(defaultConfig) + .cachedTemplate(cacheTemplate) + .build(); + + ServerConfig evaluatedConfig = template.evaluate(context); + + assertEquals("Conditional value", evaluatedConfig.getString("Custom")); + } + + @Test + public void testEvaluateCustomSignalWithoutContextReturnsDefaultValue() + throws FirebaseRemoteConfigException { + KeysAndValues defaultConfig = new KeysAndValues.Builder().build(); + KeysAndValues context = new KeysAndValues.Builder().build(); + ServerTemplate template = + new ServerTemplateImpl.Builder(null) + .defaultConfig(defaultConfig) + .cachedTemplate(cacheTemplate) + .build(); + + ServerConfig evaluatedConfig = template.evaluate(context); + + assertEquals("Default value", evaluatedConfig.getString("Custom")); + } + + @Test + public void testEvaluateCustomSignalWithInvalidContextReturnsDefaultValue() + throws FirebaseRemoteConfigException { + KeysAndValues defaultConfig = new KeysAndValues.Builder().build(); + KeysAndValues context = new KeysAndValues.Builder().put("users", "abc").build(); + ServerTemplate template = + new ServerTemplateImpl.Builder(null) + .defaultConfig(defaultConfig) + .cachedTemplate(cacheTemplate) + .build(); + + ServerConfig evaluatedConfig = template.evaluate(context); + + assertEquals("Default value", evaluatedConfig.getString("Custom")); + } + + @Test + public void testEvaluatePercentWithoutRandomizationIdReturnsDefaultValue() + throws FirebaseRemoteConfigException { + KeysAndValues defaultConfig = new KeysAndValues.Builder().build(); + KeysAndValues context = new KeysAndValues.Builder().build(); + ServerTemplate template = + new ServerTemplateImpl.Builder(null) + .defaultConfig(defaultConfig) + .cachedTemplate(cacheTemplate) + .build(); + + ServerConfig evaluatedConfig = template.evaluate(context); + + assertEquals("Default value", evaluatedConfig.getString("Percent")); + } + + @Test + public void testEvaluatePercentReturnsConditionalValue() throws FirebaseRemoteConfigException { + KeysAndValues defaultConfig = new KeysAndValues.Builder().build(); + KeysAndValues context = new KeysAndValues.Builder().put("randomizationId", "user").build(); + ServerTemplate template = + new ServerTemplateImpl.Builder(null) + .defaultConfig(defaultConfig) + .cachedTemplate(cacheTemplate) + .build(); + + ServerConfig evaluatedConfig = template.evaluate(context); + + assertEquals("Conditional value", evaluatedConfig.getString("Percent")); + } + + @Test + public void testEvaluateWithoutDefaultValueReturnsEmptyString() + throws FirebaseRemoteConfigException { + KeysAndValues defaultConfig = new KeysAndValues.Builder().build(); + KeysAndValues context = new KeysAndValues.Builder().build(); + ServerTemplate template = + new ServerTemplateImpl.Builder(null) + .defaultConfig(defaultConfig) + .cachedTemplate(cacheTemplate) + .build(); + + ServerConfig evaluatedConfig = template.evaluate(context); + + assertEquals("", evaluatedConfig.getString("Unset default value")); + } + + @Test + public void testEvaluateWithInvalidCacheValueThrowsException() + throws FirebaseRemoteConfigException { + KeysAndValues defaultConfig = new KeysAndValues.Builder().build(); + KeysAndValues context = new KeysAndValues.Builder().build(); + String invalidJsonString = "abc"; + ServerTemplate template = + new ServerTemplateImpl.Builder(null) + .defaultConfig(defaultConfig) + .cachedTemplate(invalidJsonString) + .build(); + + FirebaseRemoteConfigException error = + assertThrows(FirebaseRemoteConfigException.class, () -> template.evaluate(context)); + + assertEquals( + "No Remote Config Server template in cache. Call load() before " + "calling evaluate().", + error.getMessage()); + } + + @Test + public void testEvaluateWithInAppDefaultReturnsEmptyString() throws Exception { + KeysAndValues defaultConfig = new KeysAndValues.Builder().build(); + KeysAndValues context = new KeysAndValues.Builder().build(); + ServerTemplate template = + new ServerTemplateImpl.Builder(null) + .defaultConfig(defaultConfig) + .cachedTemplate(cacheTemplate) + .build(); + + ServerConfig evaluatedConfig = template.evaluate(context); + + assertEquals("", evaluatedConfig.getString("In-app default")); + } + + @Test + public void testEvaluateWithDerivedInAppDefaultReturnsDefaultValue() throws Exception { + KeysAndValues defaultConfig = new KeysAndValues.Builder().build(); + KeysAndValues context = new KeysAndValues.Builder().build(); + ServerTemplate template = + new ServerTemplateImpl.Builder(null) + .defaultConfig(defaultConfig) + .cachedTemplate(cacheTemplate) + .build(); + + ServerConfig evaluatedConfig = template.evaluate(context); + + assertEquals("Default value", evaluatedConfig.getString("Derived in-app default")); + } + + @Test + public void testEvaluateWithMultipleConditionReturnsConditionalValue() throws Exception { + KeysAndValues defaultConfig = new KeysAndValues.Builder().build(); + KeysAndValues context = new KeysAndValues.Builder().put("users", "99").build(); + ServerTemplate template = + new ServerTemplateImpl.Builder(null) + .defaultConfig(defaultConfig) + .cachedTemplate(cacheTemplate) + .build(); + + ServerConfig evaluatedConfig = template.evaluate(context); + + assertEquals("Conditional value 1", evaluatedConfig.getString("Multiple conditions")); + } + + @Test + public void testEvaluateWithChainedAndConditionReturnsDefaultValue() throws Exception { + KeysAndValues defaultConfig = new KeysAndValues.Builder().build(); + KeysAndValues context = + new KeysAndValues.Builder() + .put("users", "100") + .put("premium users", 20) + .put("randomizationId", "user") + .build(); + ServerTemplate template = + new ServerTemplateImpl.Builder(null) + .defaultConfig(defaultConfig) + .cachedTemplate(cacheTemplate) + .build(); + + ServerConfig evaluatedConfig = template.evaluate(context); + + assertEquals("Default value", evaluatedConfig.getString("Chained conditions")); + } + + @Test + public void testEvaluateWithChainedAndConditionReturnsConditionalValue() throws Exception { + KeysAndValues defaultConfig = new KeysAndValues.Builder().build(); + KeysAndValues context = + new KeysAndValues.Builder().put("users", "99").put("premium users", "30").build(); + ServerTemplate template = + new ServerTemplateImpl.Builder(null) + .defaultConfig(defaultConfig) + .cachedTemplate(cacheTemplate) + .build(); + + ServerConfig evaluatedConfig = template.evaluate(context); + + assertEquals("Conditional value", evaluatedConfig.getString("Chained conditions")); + } + + @Test + public void testGetEvaluateConfigOnInvalidTypeReturnsDefaultValue() throws Exception { + KeysAndValues defaultConfig = new KeysAndValues.Builder().build(); + KeysAndValues context = new KeysAndValues.Builder().put("randomizationId", "user").build(); + ServerTemplate template = + new ServerTemplateImpl.Builder(null) + .defaultConfig(defaultConfig) + .cachedTemplate(cacheTemplate) + .build(); + + ServerConfig evaluatedConfig = template.evaluate(context); + + assertEquals(0L, evaluatedConfig.getLong("Percent")); + } + + @Test + public void testGetEvaluateConfigInvalidKeyReturnsStaticValueSource() throws Exception { + KeysAndValues defaultConfig = new KeysAndValues.Builder().build(); + KeysAndValues context = new KeysAndValues.Builder().build(); + ServerTemplate template = + new ServerTemplateImpl.Builder(null) + .defaultConfig(defaultConfig) + .cachedTemplate(cacheTemplate) + .build(); + + ServerConfig evaluatedConfig = template.evaluate(context); + + assertEquals(ValueSource.STATIC, evaluatedConfig.getValueSource("invalid")); + } + + @Test + public void testGetEvaluateConfigInAppDefaultConfigReturnsDefaultValueSource() throws Exception { + KeysAndValues defaultConfig = new KeysAndValues.Builder().put("In-app default", "abc").build(); + KeysAndValues context = new KeysAndValues.Builder().build(); + ServerTemplate template = + new ServerTemplateImpl.Builder(null) + .defaultConfig(defaultConfig) + .cachedTemplate(cacheTemplate) + .build(); + + ServerConfig evaluatedConfig = template.evaluate(context); + + assertEquals(ValueSource.DEFAULT, evaluatedConfig.getValueSource("In-app default")); + } + + @Test + public void testGetEvaluateConfigUnsetDefaultConfigReturnsDefaultValueSource() throws Exception { + KeysAndValues defaultConfig = + new KeysAndValues.Builder().put("Unset default config", "abc").build(); + KeysAndValues context = new KeysAndValues.Builder().build(); + ServerTemplate template = + new ServerTemplateImpl.Builder(null) + .defaultConfig(defaultConfig) + .cachedTemplate(cacheTemplate) + .build(); + + ServerConfig evaluatedConfig = template.evaluate(context); + + assertEquals(ValueSource.DEFAULT, evaluatedConfig.getValueSource("Unset default config")); + } + + private static final String TEST_ETAG = "etag-123456789012-1"; + + private FirebaseRemoteConfig getRemoteConfig(FirebaseRemoteConfigClient client) { + FirebaseApp app = FirebaseApp.initializeApp(TEST_OPTIONS, "test-app"); + return new FirebaseRemoteConfig(app, client); + } + + @Test + public void testLoad() throws Exception { + // 1. Define the template data that the mock client will return. + // This is the EXPECTED state after `load()` is called. + final String expectedTemplateJsonAfterLoad = + new ServerTemplateData().setETag(TEST_ETAG).toJSON(); + + // 2. Mock the HTTP client to return the predefined response. + MockRemoteConfigClient client = + MockRemoteConfigClient.fromServerTemplate(expectedTemplateJsonAfterLoad); + FirebaseRemoteConfig remoteConfig = getRemoteConfig(client); + + // 3. Build the template instance. + // It's initialized with a complex `cacheTemplate` to ensure `load()` properly + // overwrites it. + KeysAndValues defaultConfig = + new KeysAndValues.Builder().put("Unset default config", "abc").build(); + ServerTemplate template = + remoteConfig + .serverTemplateBuilder() + .defaultConfig(defaultConfig) + .cachedTemplate(cacheTemplate) // This is the initial state before `load()` + .build(); + + // 4. Call the load method, which fetches the new template from the mock client. + ApiFuture loadFuture = template.load(); + loadFuture.get(); // Wait for the async operation to complete. + + // 5. Get the ACTUAL state of the template after `load()` has executed. + String actualJsonAfterLoad = template.toJson(); + + // 6. Assert that the template's state has been updated to match what the mock + // client returned. + // Parsing to JsonElement performs a deep, order-insensitive comparison. + JsonElement expectedJson = JsonParser.parseString(expectedTemplateJsonAfterLoad); + JsonElement actualJson = JsonParser.parseString(actualJsonAfterLoad); + + assertEquals(expectedJson, actualJson); + } + + @Test + public void testBuilderParsesCachedTemplateCorrectly() throws FirebaseRemoteConfigException { + // Arrange: + // 1. Create a canonical JSON string by parsing the input file and then + // re-serializing it. This gives us the precise expected output format, + // accounting for any formatting or default value differences. + ServerTemplateData canonicalData = ServerTemplateData.fromJSON(cacheTemplate); + String expectedJsonString = canonicalData.toJSON(); + + // Act: + // 2. Build a ServerTemplate instance from the original cached JSON string, + // which triggers the parsing logic we want to test. + ServerTemplate template = + new ServerTemplateImpl.Builder(null).cachedTemplate(cacheTemplate).build(); + + // Assert: + // 3. Compare the JSON from the newly built template against the canonical + // version. + // This verifies that the internal state was parsed and stored correctly. + // Using JsonElement ensures the comparison is not affected by key order. + JsonElement expectedJsonTree = JsonParser.parseString(expectedJsonString); + JsonElement actualJsonTree = JsonParser.parseString(template.toJson()); + + assertEquals(expectedJsonTree, actualJsonTree); + } +} diff --git a/src/test/java/com/google/firebase/remoteconfig/ValueTest.java b/src/test/java/com/google/firebase/remoteconfig/ValueTest.java new file mode 100644 index 000000000..c1822b063 --- /dev/null +++ b/src/test/java/com/google/firebase/remoteconfig/ValueTest.java @@ -0,0 +1,104 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.remoteconfig; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import org.junit.Test; + +public class ValueTest { + @Test + public void testGetSourceReturnsValueSource() { + Value value = new Value(ValueSource.STATIC); + assertEquals(value.getSource(), ValueSource.STATIC); + } + + @Test + public void testAsStringReturnsValueAsString() { + Value value = new Value(ValueSource.STATIC, "sample-string"); + assertEquals(value.asString(), "sample-string"); + } + + @Test + public void testAsStringReturnsDefaultEmptyString() { + Value value = new Value(ValueSource.STATIC); + assertEquals(value.asString(), ""); + } + + @Test + public void testAsLongReturnsDefaultValueForStaticSource() { + Value value = new Value(ValueSource.STATIC); + assertEquals(value.asLong(), 0L); + } + + @Test + public void testAsLongReturnsDefaultValueForInvalidSourceValue() { + Value value = new Value(ValueSource.REMOTE, "sample-string"); + assertEquals(value.asLong(), 0L); + } + + @Test + public void testAsLongReturnsSourceValueAsLong() { + Value value = new Value(ValueSource.REMOTE, "123"); + assertEquals(value.asLong(), 123L); + } + + @Test + public void testAsDoubleReturnsDefaultValueForStaticSource() { + Value value = new Value(ValueSource.STATIC); + assertEquals(value.asDouble(), 0, 0); + } + + @Test + public void testAsDoubleReturnsDefaultValueForInvalidSourceValue() { + Value value = new Value(ValueSource.REMOTE, "sample-string"); + assertEquals(value.asDouble(), 0, 0); + } + + @Test + public void testAsDoubleReturnsSourceValueAsDouble() { + Value value = new Value(ValueSource.REMOTE, "123.34"); + assertEquals(value.asDouble(), 123.34, 0); + } + + @Test + public void testAsBooleanReturnsDefaultValueForStaticSource() { + Value value = new Value(ValueSource.STATIC); + assertFalse(value.asBoolean()); + } + + @Test + public void testAsBooleanReturnsDefaultValueForInvalidSourceValue() { + Value value = new Value(ValueSource.REMOTE, "sample-string"); + assertFalse(value.asBoolean()); + } + + @Test + public void testAsBooleanReturnsSourceValueAsBoolean() { + Value value = new Value(ValueSource.REMOTE, "1"); + assertTrue(value.asBoolean()); + } + + @Test + public void testAsBooleanReturnsSourceValueYesAsBoolean() { + Value value = new Value(ValueSource.REMOTE, "YeS"); + assertTrue(value.asBoolean()); + } +} + \ No newline at end of file diff --git a/src/test/java/com/google/firebase/remoteconfig/VersionTest.java b/src/test/java/com/google/firebase/remoteconfig/VersionTest.java index 515629660..1e524195f 100644 --- a/src/test/java/com/google/firebase/remoteconfig/VersionTest.java +++ b/src/test/java/com/google/firebase/remoteconfig/VersionTest.java @@ -20,6 +20,7 @@ import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNotEquals; import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertThrows; import com.google.firebase.remoteconfig.internal.TemplateResponse; import com.google.firebase.remoteconfig.internal.TemplateResponse.VersionResponse; @@ -27,15 +28,16 @@ public class VersionTest { - @Test(expected = NullPointerException.class) + @Test public void testConstructorWithNullVersionResponse() { - new Version(null); + assertThrows(NullPointerException.class, () -> new Version(null)); } - @Test(expected = IllegalStateException.class) + @Test public void testConstructorWithInvalidUpdateTime() { - new Version(new VersionResponse() - .setUpdateTime("sunday,26th")); + assertThrows(IllegalStateException.class, () -> + new Version(new VersionResponse().setUpdateTime("sunday,26th"))); + } @Test diff --git a/src/test/resources/getServerRemoteConfig.json b/src/test/resources/getServerRemoteConfig.json new file mode 100644 index 000000000..e5cb1a9eb --- /dev/null +++ b/src/test/resources/getServerRemoteConfig.json @@ -0,0 +1,142 @@ +{ + "conditions": [ + { + "name": "custom_signal", + "condition": { + "orCondition": { + "conditions": [ + { + "andCondition": { + "conditions": [ + { + "customSignal": { + "customSignalOperator": "NUMERIC_LESS_THAN", + "customSignalKey": "users", + "targetCustomSignalValues": [ + "100" + ] + } + } + ] + } + } + ] + } + } + }, + { + "name": "percent", + "condition": { + "orCondition": { + "conditions": [ + { + "andCondition": { + "conditions": [ + { + "percent": { + "percentOperator": "BETWEEN", + "seed": "3maarirs9xzs", + "microPercentRange": { + "microPercentLowerBound": 12000000, + "microPercentUpperBound": 100000000 + } + } + } + ] + } + } + ] + } + } + }, + { + "name": "chained_conditions", + "condition": { + "orCondition": { + "conditions": [ + { + "andCondition": { + "conditions": [ + { + "customSignal": { + "customSignalOperator": "NUMERIC_LESS_THAN", + "customSignalKey": "users", + "targetCustomSignalValues": [ + "100" + ] + } + }, + { + "customSignal": { + "customSignalOperator": "NUMERIC_GREATER_THAN", + "customSignalKey": "premium users", + "targetCustomSignalValues": [ + "20" + ] + } + }, + { + "percent": { + "percentOperator": "BETWEEN", + "seed": "cla24qoibb61", + "microPercentRange": { + "microPercentLowerBound": 25000000, + "microPercentUpperBound": 100000000 + } + } + } + ] + } + } + ] + } + } + } + ], + "parameters": { + "welcome_message_text": { + "defaultValue": { + "value": "welcome to app" + }, + "conditionalValues": { + "ios_en": { + "value": "welcome to app en" + } + }, + "description": "text for welcome message!", + "valueType": "STRING" + }, + "header_text": { + "defaultValue": { + "useInAppDefault": true + }, + "valueType": "STRING" + } + }, + "parameterGroups": { + "new menu": { + "description": "New Menu", + "parameters": { + "pumpkin_spice_season": { + "defaultValue": { + "value": "true" + }, + "description": "Whether it's currently pumpkin spice season.", + "valueType": "BOOLEAN" + } + } + } + }, + "version": { + "versionNumber": "17", + "updateOrigin": "ADMIN_SDK_NODE", + "updateType": "INCREMENTAL_UPDATE", + "updateUser": { + "email": "firebase-user@account.com", + "name": "dev-admin", + "imageUrl": "http://image.jpg" + }, + "updateTime": "2020-11-15T06:57:26.342763941Z", + "description": "promo config" + } +} \ No newline at end of file diff --git a/src/test/resources/getServerTemplateData.json b/src/test/resources/getServerTemplateData.json new file mode 100644 index 000000000..c26f74185 --- /dev/null +++ b/src/test/resources/getServerTemplateData.json @@ -0,0 +1,181 @@ +{ + "conditions": [ + { + "name": "custom_signal", + "condition": { + "orCondition": { + "conditions": [ + { + "andCondition": { + "conditions": [ + { + "customSignal": { + "customSignalOperator": "NUMERIC_LESS_THAN", + "customSignalKey": "users", + "targetCustomSignalValues": [ + "100" + ] + } + } + ] + } + } + ] + } + } + }, + { + "name": "percent", + "condition": { + "orCondition": { + "conditions": [ + { + "andCondition": { + "conditions": [ + { + "percent": { + "percentOperator": "BETWEEN", + "seed": "3maarirs9xzs", + "microPercentRange": { + "microPercentLowerBound": 12000000, + "microPercentUpperBound": 100000000 + } + } + } + ] + } + } + ] + } + } + }, + { + "name": "chained_conditions", + "condition": { + "orCondition": { + "conditions": [ + { + "andCondition": { + "conditions": [ + { + "customSignal": { + "customSignalOperator": "NUMERIC_LESS_THAN", + "customSignalKey": "users", + "targetCustomSignalValues": [ + "100" + ] + } + }, + { + "customSignal": { + "customSignalOperator": "NUMERIC_GREATER_THAN", + "customSignalKey": "premium users", + "targetCustomSignalValues": [ + "20" + ] + }, + "percent": { + "percentOperator": "BETWEEN", + "seed": "cla24qoibb61", + "microPercentRange": { + "microPercentLowerBound": 25000000, + "microPercentUpperBound": 100000000 + } + } + } + ] + } + } + ] + } + } + } + ], + "parameters": { + "Percent": { + "defaultValue": { + "value": "Default value" + }, + "conditionalValues": { + "percent": { + "value": "Conditional value" + } + } + }, + "Custom": { + "defaultValue": { + "value": "Default value" + }, + "conditionalValues": { + "custom_signal": { + "value": "Conditional value" + } + } + }, + "Welcome Message": { + "defaultValue": { + "value": "Welcome" + } + }, + "Unset default value": { + "defaultValue": { + "value": "" + } + }, + "In-app default": { + "defaultValue": { + "useInAppDefault": true + }, + "conditionalValues": { + "percent": { + "value": "Conditional value" + } + } + }, + "Derived in-app default": { + "defaultValue": { + "value": "Default value" + }, + "conditionalValues": { + "percent": { + "useInAppDefault": true + } + } + }, + "Multiple conditions": { + "defaultValue": { + "value": "Default value" + }, + "conditionalValues": { + "custom_signal": { + "value": "Conditional value 1" + }, + "percent": { + "value": "Conditional value 2" + } + } + }, + "Chained conditions": { + "defaultValue": { + "value": "Default value" + }, + "conditionalValues": { + "chained_conditions": { + "value": "Conditional value" + } + } + } + }, + "version": { + "versionNumber": "17", + "updateOrigin": "ADMIN_SDK_NODE", + "updateType": "INCREMENTAL_UPDATE", + "updateUser": { + "email": "firebase-user@account.com", + "name": "dev-admin", + "imageUrl": "http://image.jpg" + }, + "updateTime": "2020-11-15T06:57:26.342763941Z", + "description": "promo config" + } +} \ No newline at end of file