Skip to content

Implement percent evaluation for server side RC #1114

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
Expand Up @@ -23,6 +23,10 @@
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;
Expand Down Expand Up @@ -83,6 +87,8 @@ private boolean evaluateCondition(OneOfCondition condition, KeysAndValues contex
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;
Expand Down Expand Up @@ -179,6 +185,78 @@ private boolean evaluateCustomSignalCondition(CustomSignalCondition condition,
}
}

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 modValue = new BigInteger(Integer.toString(100 * 1_000_000));
BigInteger microPercentile = hash.mod(modValue);

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 hexadecimal string.
StringBuilder hexString = new StringBuilder();
for (byte b : hashBytes) {
String hex = Integer.toHexString(0xff & b);
if (hex.length() == 1) {
hexString.append('0');
}
hexString.append(hex);
}

// Convert the hexadecimal string to a BigInteger
return new BigInteger(hexString.toString(), 16);

} catch (NoSuchAlgorithmException e) {
logger.error("SHA-256 algorithm not found", e);
throw new RuntimeException("SHA-256 algorithm not found", e);
}
}

private boolean compareStrings(ImmutableList<String> targetValues, String customSignal,
BiPredicate<String, String> compareFunction) {
return targetValues.stream().anyMatch(targetValue ->
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
/*
* 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.NonNull;
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;
}

@NonNull
int getMicroPercentLowerBound() {
return microPercentLowerBound;
}

@NonNull
int getMicroPercentUpperBound() {
return microPercentUpperBound;
}

MicroPercentRangeResponse toMicroPercentRangeResponse() {
MicroPercentRangeResponse microPercentRangeResponse = new MicroPercentRangeResponse();
microPercentRangeResponse.setMicroPercentLowerBound(this.microPercentLowerBound);
microPercentRangeResponse.setMicroPercentUpperBound(this.microPercentUpperBound);
return microPercentRangeResponse;
}
}
19 changes: 19 additions & 0 deletions src/main/java/com/google/firebase/remoteconfig/OneOfCondition.java
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
class OneOfCondition {
private OrCondition orCondition;
private AndCondition andCondition;
private PercentCondition percent;
private CustomSignalCondition customSignal;
private String trueValue;
private String falseValue;
Expand All @@ -37,6 +38,9 @@ class OneOfCondition {
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());
Expand All @@ -47,6 +51,7 @@ class OneOfCondition {
OneOfCondition() {
this.orCondition = null;
this.andCondition = null;
this.percent = null;
this.trueValue = null;
this.falseValue = null;
}
Expand All @@ -71,6 +76,11 @@ String isFalse() {
return falseValue;
}

@Nullable
PercentCondition getPercent() {
return percent;
}

@Nullable
CustomSignalCondition getCustomSignal() {
return customSignal;
Expand All @@ -88,6 +98,12 @@ OneOfCondition setAndCondition(@NonNull 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;
Expand Down Expand Up @@ -115,6 +131,9 @@ OneOfConditionResponse toOneOfConditionResponse() {
if (this.customSignal != null) {
oneOfConditionResponse.setCustomSignalCondition(this.customSignal.toCustomConditonResponse());
}
if (this.percent != null) {
oneOfConditionResponse.setPercentCondition(this.percent.toPercentConditionResponse());
}
return oneOfConditionResponse;
}
}
Expand Down
163 changes: 163 additions & 0 deletions src/main/java/com/google/firebase/remoteconfig/PercentCondition.java
Original file line number Diff line number Diff line change
@@ -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;
}
}
Loading