Skip to content

Commit 6ad97cd

Browse files
authored
Implement percent evaluation for server side RC (#1114)
* [feat] Implement percent evaluation for server side RC
1 parent e8199c2 commit 6ad97cd

11 files changed

+843
-24
lines changed

src/main/java/com/google/firebase/remoteconfig/ConditionEvaluator.java

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,10 @@
2323
import com.google.firebase.internal.NonNull;
2424
import com.google.firebase.internal.Nullable;
2525

26+
import java.math.BigInteger;
27+
import java.nio.charset.StandardCharsets;
28+
import java.security.MessageDigest;
29+
import java.security.NoSuchAlgorithmException;
2630
import java.util.Arrays;
2731
import java.util.List;
2832
import java.util.Map;
@@ -38,6 +42,7 @@
3842
final class ConditionEvaluator {
3943
private static final int MAX_CONDITION_RECURSION_DEPTH = 10;
4044
private static final Logger logger = LoggerFactory.getLogger(ConditionEvaluator.class);
45+
private static final BigInteger MICRO_PERCENT_MODULO = BigInteger.valueOf(100_000_000L);
4146

4247
/**
4348
* Evaluates server conditions and assigns a boolean value to each condition.
@@ -83,6 +88,8 @@ private boolean evaluateCondition(OneOfCondition condition, KeysAndValues contex
8388
return false;
8489
} else if (condition.getCustomSignal() != null) {
8590
return evaluateCustomSignalCondition(condition.getCustomSignal(), context);
91+
} else if (condition.getPercent() != null) {
92+
return evaluatePercentCondition(condition.getPercent(), context);
8693
}
8794
logger.atWarn().log("Received invalid condition for evaluation.");
8895
return false;
@@ -179,6 +186,66 @@ private boolean evaluateCustomSignalCondition(CustomSignalCondition condition,
179186
}
180187
}
181188

189+
private boolean evaluatePercentCondition(PercentCondition condition,
190+
KeysAndValues context) {
191+
if (!context.containsKey("randomizationId")) {
192+
logger.warn("Percentage operation must not be performed without randomizationId");
193+
return false;
194+
}
195+
196+
PercentConditionOperator operator = condition.getPercentConditionOperator();
197+
198+
// The micro-percent interval to be used with the BETWEEN operator.
199+
MicroPercentRange microPercentRange = condition.getMicroPercentRange();
200+
int microPercentUpperBound = microPercentRange != null
201+
? microPercentRange.getMicroPercentUpperBound()
202+
: 0;
203+
int microPercentLowerBound = microPercentRange != null
204+
? microPercentRange.getMicroPercentLowerBound()
205+
: 0;
206+
// The limit of percentiles to target in micro-percents when using the
207+
// LESS_OR_EQUAL and GREATER_THAN operators. The value must be in the range [0
208+
// and 100000000].
209+
int microPercent = condition.getMicroPercent();
210+
BigInteger microPercentile = getMicroPercentile(condition.getSeed(),
211+
context.get("randomizationId"));
212+
switch (operator) {
213+
case LESS_OR_EQUAL:
214+
return microPercentile.compareTo(BigInteger.valueOf(microPercent)) <= 0;
215+
case GREATER_THAN:
216+
return microPercentile.compareTo(BigInteger.valueOf(microPercent)) > 0;
217+
case BETWEEN:
218+
return microPercentile.compareTo(BigInteger.valueOf(microPercentLowerBound)) > 0
219+
&& microPercentile.compareTo(BigInteger.valueOf(microPercentUpperBound)) <= 0;
220+
case UNSPECIFIED:
221+
default:
222+
return false;
223+
}
224+
}
225+
226+
private BigInteger getMicroPercentile(String seed, String randomizationId) {
227+
String seedPrefix = seed != null && !seed.isEmpty() ? seed + "." : "";
228+
String stringToHash = seedPrefix + randomizationId;
229+
BigInteger hash = hashSeededRandomizationId(stringToHash);
230+
BigInteger microPercentile = hash.mod(MICRO_PERCENT_MODULO);
231+
232+
return microPercentile;
233+
}
234+
235+
private BigInteger hashSeededRandomizationId(String seededRandomizationId) {
236+
try {
237+
// Create a SHA-256 hash.
238+
MessageDigest digest = MessageDigest.getInstance("SHA-256");
239+
byte[] hashBytes = digest.digest(seededRandomizationId.getBytes(StandardCharsets.UTF_8));
240+
241+
// Convert the hash bytes to a BigInteger
242+
return new BigInteger(1, hashBytes);
243+
} catch (NoSuchAlgorithmException e) {
244+
logger.error("SHA-256 algorithm not found", e);
245+
throw new RuntimeException("SHA-256 algorithm not found", e);
246+
}
247+
}
248+
182249
private boolean compareStrings(ImmutableList<String> targetValues, String customSignal,
183250
BiPredicate<String, String> compareFunction) {
184251
return targetValues.stream().anyMatch(targetValue ->
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
/*
2+
* Copyright 2025 Google LLC
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.google.firebase.remoteconfig;
18+
19+
import com.google.firebase.internal.Nullable;
20+
import com.google.firebase.remoteconfig.internal.ServerTemplateResponse.MicroPercentRangeResponse;
21+
22+
class MicroPercentRange {
23+
private final int microPercentLowerBound;
24+
private final int microPercentUpperBound;
25+
26+
public MicroPercentRange(@Nullable Integer microPercentLowerBound,
27+
@Nullable Integer microPercentUpperBound) {
28+
this.microPercentLowerBound = microPercentLowerBound != null ? microPercentLowerBound : 0;
29+
this.microPercentUpperBound = microPercentUpperBound != null ? microPercentUpperBound : 0;
30+
}
31+
32+
int getMicroPercentLowerBound() {
33+
return microPercentLowerBound;
34+
}
35+
36+
int getMicroPercentUpperBound() {
37+
return microPercentUpperBound;
38+
}
39+
40+
MicroPercentRangeResponse toMicroPercentRangeResponse() {
41+
MicroPercentRangeResponse microPercentRangeResponse = new MicroPercentRangeResponse();
42+
microPercentRangeResponse.setMicroPercentLowerBound(this.microPercentLowerBound);
43+
microPercentRangeResponse.setMicroPercentUpperBound(this.microPercentUpperBound);
44+
return microPercentRangeResponse;
45+
}
46+
}

src/main/java/com/google/firebase/remoteconfig/OneOfCondition.java

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
class OneOfCondition {
2727
private OrCondition orCondition;
2828
private AndCondition andCondition;
29+
private PercentCondition percent;
2930
private CustomSignalCondition customSignal;
3031
private String trueValue;
3132
private String falseValue;
@@ -37,6 +38,9 @@ class OneOfCondition {
3738
if (oneOfconditionResponse.getAndCondition() != null) {
3839
this.andCondition = new AndCondition(oneOfconditionResponse.getAndCondition());
3940
}
41+
if (oneOfconditionResponse.getPercentCondition() != null) {
42+
this.percent = new PercentCondition(oneOfconditionResponse.getPercentCondition());
43+
}
4044
if (oneOfconditionResponse.getCustomSignalCondition() != null) {
4145
this.customSignal =
4246
new CustomSignalCondition(oneOfconditionResponse.getCustomSignalCondition());
@@ -47,6 +51,7 @@ class OneOfCondition {
4751
OneOfCondition() {
4852
this.orCondition = null;
4953
this.andCondition = null;
54+
this.percent = null;
5055
this.trueValue = null;
5156
this.falseValue = null;
5257
}
@@ -71,6 +76,11 @@ String isFalse() {
7176
return falseValue;
7277
}
7378

79+
@Nullable
80+
PercentCondition getPercent() {
81+
return percent;
82+
}
83+
7484
@Nullable
7585
CustomSignalCondition getCustomSignal() {
7686
return customSignal;
@@ -88,6 +98,12 @@ OneOfCondition setAndCondition(@NonNull AndCondition andCondition) {
8898
return this;
8999
}
90100

101+
OneOfCondition setPercent(@NonNull PercentCondition percent) {
102+
checkNotNull(percent, "`Percent` condition cannot be set to null.");
103+
this.percent = percent;
104+
return this;
105+
}
106+
91107
OneOfCondition setCustomSignal(@NonNull CustomSignalCondition customSignal) {
92108
checkNotNull(customSignal, "`Custom signal` condition cannot be set to null.");
93109
this.customSignal = customSignal;
@@ -115,6 +131,9 @@ OneOfConditionResponse toOneOfConditionResponse() {
115131
if (this.customSignal != null) {
116132
oneOfConditionResponse.setCustomSignalCondition(this.customSignal.toCustomConditonResponse());
117133
}
134+
if (this.percent != null) {
135+
oneOfConditionResponse.setPercentCondition(this.percent.toPercentConditionResponse());
136+
}
118137
return oneOfConditionResponse;
119138
}
120139
}
Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
/*
2+
* Copyright 2025 Google LLC
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.google.firebase.remoteconfig;
18+
19+
import static com.google.common.base.Preconditions.checkArgument;
20+
import static com.google.common.base.Preconditions.checkNotNull;
21+
22+
import com.google.common.base.Strings;
23+
import com.google.firebase.internal.NonNull;
24+
import com.google.firebase.internal.Nullable;
25+
import com.google.firebase.remoteconfig.internal.ServerTemplateResponse.PercentConditionResponse;
26+
27+
/** Represents a condition that compares the instance pseudo-random percentile to a given limit. */
28+
public final class PercentCondition {
29+
private int microPercent;
30+
private MicroPercentRange microPercentRange;
31+
private final PercentConditionOperator percentConditionOperator;
32+
private final String seed;
33+
34+
/**
35+
* Create a percent condition for operator BETWEEN.
36+
*
37+
* @param microPercent The limit of percentiles to target in micro-percents when using the
38+
* LESS_OR_EQUAL and GREATER_THAN operators. The value must be in the range [0 and 100000000].
39+
* @param percentConditionOperator The choice of percent operator to determine how to compare
40+
* targets to percent(s).
41+
* @param seed The seed used when evaluating the hash function to map an instance to a value in
42+
* the hash space. This is a string which can have 0 - 32 characters and can contain ASCII
43+
* characters [-_.0-9a-zA-Z].The string is case-sensitive.
44+
*/
45+
PercentCondition(
46+
@Nullable Integer microPercent,
47+
@NonNull PercentConditionOperator percentConditionOperator,
48+
@NonNull String seed) {
49+
checkNotNull(percentConditionOperator, "Percentage operator must not be null.");
50+
checkArgument(!Strings.isNullOrEmpty(seed), "Seed must not be null or empty.");
51+
this.microPercent = microPercent != null ? microPercent : 0;
52+
this.percentConditionOperator = percentConditionOperator;
53+
this.seed = seed;
54+
}
55+
56+
/**
57+
* Create a percent condition for operators GREATER_THAN and LESS_OR_EQUAL.
58+
*
59+
* @param microPercentRange The micro-percent interval to be used with the BETWEEN operator.
60+
* @param percentConditionOperator The choice of percent operator to determine how to compare
61+
* targets to percent(s).
62+
* @param seed The seed used when evaluating the hash function to map an instance to a value in
63+
* the hash space. This is a string which can have 0 - 32 characters and can contain ASCII
64+
* characters [-_.0-9a-zA-Z].The string is case-sensitive.
65+
*/
66+
PercentCondition(
67+
@NonNull MicroPercentRange microPercentRange,
68+
@NonNull PercentConditionOperator percentConditionOperator,
69+
String seed) {
70+
checkNotNull(microPercentRange, "Percent range must not be null.");
71+
checkNotNull(percentConditionOperator, "Percentage operator must not be null.");
72+
this.microPercentRange = microPercentRange;
73+
this.percentConditionOperator = percentConditionOperator;
74+
this.seed = seed;
75+
}
76+
77+
/**
78+
* Creates a new {@link PercentCondition} from API response.
79+
*
80+
* @param percentCondition the conditions obtained from server call.
81+
*/
82+
PercentCondition(PercentConditionResponse percentCondition) {
83+
checkArgument(
84+
!Strings.isNullOrEmpty(percentCondition.getSeed()), "Seed must not be empty or null");
85+
this.microPercent = percentCondition.getMicroPercent();
86+
this.seed = percentCondition.getSeed();
87+
switch (percentCondition.getPercentOperator()) {
88+
case "BETWEEN":
89+
this.percentConditionOperator = PercentConditionOperator.BETWEEN;
90+
break;
91+
case "GREATER_THAN":
92+
this.percentConditionOperator = PercentConditionOperator.GREATER_THAN;
93+
break;
94+
case "LESS_OR_EQUAL":
95+
this.percentConditionOperator = PercentConditionOperator.LESS_OR_EQUAL;
96+
break;
97+
default:
98+
this.percentConditionOperator = PercentConditionOperator.UNSPECIFIED;
99+
}
100+
checkArgument(
101+
this.percentConditionOperator != PercentConditionOperator.UNSPECIFIED,
102+
"Percentage operator is invalid");
103+
if (percentCondition.getMicroPercentRange() != null) {
104+
this.microPercentRange =
105+
new MicroPercentRange(
106+
percentCondition.getMicroPercentRange().getMicroPercentLowerBound(),
107+
percentCondition.getMicroPercentRange().getMicroPercentUpperBound());
108+
}
109+
}
110+
111+
/**
112+
* Gets the limit of percentiles to target in micro-percents when using the LESS_OR_EQUAL and
113+
* GREATER_THAN operators. The value must be in the range [0 and 100000000].
114+
*
115+
* @return micro percent.
116+
*/
117+
@Nullable
118+
public int getMicroPercent() {
119+
return microPercent;
120+
}
121+
122+
/**
123+
* Gets micro-percent interval to be used with the BETWEEN operator.
124+
*
125+
* @return micro percent range.
126+
*/
127+
@Nullable
128+
public MicroPercentRange getMicroPercentRange() {
129+
return microPercentRange;
130+
}
131+
132+
/**
133+
* Gets choice of percent operator to determine how to compare targets to percent(s).
134+
*
135+
* @return operator.
136+
*/
137+
@NonNull
138+
public PercentConditionOperator getPercentConditionOperator() {
139+
return percentConditionOperator;
140+
}
141+
142+
/**
143+
* The seed used when evaluating the hash function to map an instance to a value in the hash
144+
* space. This is a string which can have 0 - 32 characters and can contain ASCII characters
145+
* [-_.0-9a-zA-Z].The string is case-sensitive.
146+
*
147+
* @return seed.
148+
*/
149+
@NonNull
150+
public String getSeed() {
151+
return seed;
152+
}
153+
154+
PercentConditionResponse toPercentConditionResponse() {
155+
PercentConditionResponse percentConditionResponse = new PercentConditionResponse();
156+
percentConditionResponse.setMicroPercent(this.microPercent);
157+
percentConditionResponse.setMicroPercentRange(
158+
this.microPercentRange.toMicroPercentRangeResponse());
159+
percentConditionResponse.setPercentOperator(this.percentConditionOperator.getOperator());
160+
percentConditionResponse.setSeed(this.seed);
161+
return percentConditionResponse;
162+
}
163+
}

0 commit comments

Comments
 (0)