Skip to content

Implement custom signal support for server side remote config #1108

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 1 commit into
base: ssrc
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@

/*
* Copyright 2025 Google LLC
*
Expand Down Expand Up @@ -61,4 +60,3 @@ AndConditionResponse toAndConditionResponse() {
.collect(Collectors.toList()));
}
}

266 changes: 266 additions & 0 deletions src/main/java/com/google/firebase/remoteconfig/ConditionEvaluator.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,266 @@
/*
* 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.internal.Nullable;

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.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);

/**
* 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<String, Boolean> evaluateConditions(
@NonNull List<ServerCondition> conditions,
@Nullable KeysAndValues context) {
checkNotNull(conditions, "List of conditions must not be null.");
checkArgument(!conditions.isEmpty(), "List of conditions must not be empty.");
KeysAndValues evaluationContext = context != null
? context
: new KeysAndValues.Builder().build();

Map<String, Boolean> evaluatedConditions = conditions.stream()
.collect(Collectors.toMap(
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);
}
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<String> 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) -> Pattern.compile(targetSignal)
.matcher(customSignal).matches());

// 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 compareStrings(ImmutableList<String> targetValues, String customSignal,
BiPredicate<String, String> compareFunction) {
return targetValues.stream().anyMatch(targetValue ->
compareFunction.test(customSignal, targetValue));
}

private boolean compareNumbers(ImmutableList<String> 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<String> 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<Integer> targetVersion = parseSemanticVersion(targetValueString);
List<Integer> 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<Integer> version1, List<Integer> 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<Integer> parseSemanticVersion(String versionString) {
return Arrays.stream(versionString.split("\\."))
.map(Integer::parseInt)
.collect(Collectors.toList());
}

private boolean validateSemanticVersion(String version) {
Pattern pattern = Pattern.compile("^[0-9]+(?:\\.[0-9]+){0,4}$");
return pattern.matcher(version).matches();
}
}
Loading