diff --git a/pom.xml b/pom.xml index b6098b851..a642c1936 100644 --- a/pom.xml +++ b/pom.xml @@ -19,7 +19,7 @@ com.google.firebase firebase-admin - 9.7.1 + 9.8.0 jar firebase-admin @@ -59,7 +59,7 @@ UTF-8 UTF-8 ${skipTests} - 4.2.9.Final + 4.2.10.Final @@ -263,7 +263,7 @@ maven-compiler-plugin - 3.14.1 + 3.15.0 1.8 1.8 @@ -378,7 +378,7 @@ com.google.cloud libraries-bom - 26.74.0 + 26.76.0 pom import diff --git a/src/main/java/com/google/firebase/internal/ApacheHttp2AsyncEntityConsumer.java b/src/main/java/com/google/firebase/internal/ApacheHttp2AsyncEntityConsumer.java new file mode 100644 index 000000000..ee8733586 --- /dev/null +++ b/src/main/java/com/google/firebase/internal/ApacheHttp2AsyncEntityConsumer.java @@ -0,0 +1,85 @@ +/* + * Copyright 2026 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.internal; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.nio.ByteBuffer; +import java.util.List; +import org.apache.hc.core5.concurrent.FutureCallback; +import org.apache.hc.core5.http.EntityDetails; +import org.apache.hc.core5.http.Header; +import org.apache.hc.core5.http.HttpException; +import org.apache.hc.core5.http.nio.AsyncEntityConsumer; +import org.apache.hc.core5.http.nio.CapacityChannel; + +public class ApacheHttp2AsyncEntityConsumer implements AsyncEntityConsumer { + + private EntityDetails entityDetails; + private FutureCallback resultCallback; + private final ByteArrayOutputStream buffer = new ByteArrayOutputStream(); + + @Override + public void streamStart( + final EntityDetails entityDetails, + final FutureCallback resultCallback) + throws HttpException, IOException { + this.entityDetails = entityDetails; + this.resultCallback = resultCallback; + } + + @Override + public void updateCapacity(CapacityChannel capacityChannel) throws IOException { + capacityChannel.update(Integer.MAX_VALUE); + } + + @Override + public void consume(ByteBuffer src) throws IOException { + if (src.hasArray()) { + buffer.write(src.array(), src.arrayOffset() + src.position(), src.remaining()); + src.position(src.limit()); + } else { + byte[] bytes = new byte[src.remaining()]; + src.get(bytes); + buffer.write(bytes); + } + } + + @Override + public void streamEnd(List trailers) throws HttpException, IOException { + if (resultCallback != null) { + resultCallback.completed(getContent()); + } + } + + @Override + public void failed(Exception cause) { + if (resultCallback != null) { + resultCallback.failed(cause); + } + } + + @Override + public void releaseResources() { + buffer.reset(); + } + + @Override + public ApacheHttp2Entity getContent() { + return new ApacheHttp2Entity(buffer.toByteArray(), entityDetails); + } +} diff --git a/src/main/java/com/google/firebase/internal/ApacheHttp2Entity.java b/src/main/java/com/google/firebase/internal/ApacheHttp2Entity.java new file mode 100644 index 000000000..9769a28a8 --- /dev/null +++ b/src/main/java/com/google/firebase/internal/ApacheHttp2Entity.java @@ -0,0 +1,37 @@ +/* + * Copyright 2026 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.internal; + +import org.apache.hc.core5.http.EntityDetails; + +public class ApacheHttp2Entity { + private final byte[] content; + private final EntityDetails entityDetails; + + public ApacheHttp2Entity(byte[] content, EntityDetails entityDetails) { + this.content = content; + this.entityDetails = entityDetails; + } + + public byte[] getContent() { + return content; + } + + public EntityDetails getEntityDetails() { + return entityDetails; + } +} diff --git a/src/main/java/com/google/firebase/internal/ApacheHttp2Request.java b/src/main/java/com/google/firebase/internal/ApacheHttp2Request.java index a711dc0ac..ceb213c52 100644 --- a/src/main/java/com/google/firebase/internal/ApacheHttp2Request.java +++ b/src/main/java/com/google/firebase/internal/ApacheHttp2Request.java @@ -32,13 +32,14 @@ import org.apache.hc.client5.http.ConnectTimeoutException; import org.apache.hc.client5.http.HttpHostConnectException; import org.apache.hc.client5.http.async.methods.SimpleHttpRequest; -import org.apache.hc.client5.http.async.methods.SimpleHttpResponse; import org.apache.hc.client5.http.async.methods.SimpleRequestBuilder; -import org.apache.hc.client5.http.async.methods.SimpleResponseConsumer; import org.apache.hc.client5.http.config.RequestConfig; import org.apache.hc.client5.http.impl.async.CloseableHttpAsyncClient; import org.apache.hc.core5.concurrent.FutureCallback; +import org.apache.hc.core5.http.HttpResponse; +import org.apache.hc.core5.http.Message; import org.apache.hc.core5.http.nio.support.BasicRequestProducer; +import org.apache.hc.core5.http.nio.support.BasicResponseConsumer; import org.apache.hc.core5.http2.H2StreamResetException; import org.apache.hc.core5.util.Timeout; @@ -49,6 +50,7 @@ final class ApacheHttp2Request extends LowLevelHttpRequest { private final RequestConfig.Builder requestConfig; private int writeTimeout; private ApacheHttp2AsyncEntityProducer entityProducer; + private ApacheHttp2AsyncEntityConsumer entityConsumer; ApacheHttp2Request( CloseableHttpAsyncClient httpAsyncClient, SimpleRequestBuilder requestBuilder) { @@ -85,17 +87,20 @@ public LowLevelHttpResponse execute() throws IOException { // Build request request = requestBuilder.build(); - // Make Producer + // Make Entity Producer CompletableFuture writeFuture = new CompletableFuture<>(); entityProducer = new ApacheHttp2AsyncEntityProducer(this, writeFuture); + // Make Entity Consumer + entityConsumer = new ApacheHttp2AsyncEntityConsumer(); + // Execute - final Future responseFuture = httpAsyncClient.execute( + final Future> responseFuture = httpAsyncClient.execute( new BasicRequestProducer(request, entityProducer), - SimpleResponseConsumer.create(), - new FutureCallback() { + new BasicResponseConsumer(entityConsumer), + new FutureCallback>() { @Override - public void completed(final SimpleHttpResponse response) { + public void completed(final Message response) { } @Override @@ -120,10 +125,10 @@ public void cancelled() { // Wait for response try { - final SimpleHttpResponse response = responseFuture.get(); + final Message response = responseFuture.get(); return new ApacheHttp2Response(response); } catch (ExecutionException e) { - if (e.getCause() instanceof ConnectTimeoutException + if (e.getCause() instanceof ConnectTimeoutException || e.getCause() instanceof SocketTimeoutException) { throw new IOException("Connection Timeout", e.getCause()); } else if (e.getCause() instanceof HttpHostConnectException) { @@ -144,4 +149,9 @@ public void cancelled() { ApacheHttp2AsyncEntityProducer getEntityProducer() { return entityProducer; } + + @VisibleForTesting + ApacheHttp2AsyncEntityConsumer getEntityConsumer() { + return entityConsumer; + } } diff --git a/src/main/java/com/google/firebase/internal/ApacheHttp2Response.java b/src/main/java/com/google/firebase/internal/ApacheHttp2Response.java index 329ec3970..7d277b972 100644 --- a/src/main/java/com/google/firebase/internal/ApacheHttp2Response.java +++ b/src/main/java/com/google/firebase/internal/ApacheHttp2Response.java @@ -23,17 +23,27 @@ import java.io.IOException; import java.io.InputStream; -import org.apache.hc.client5.http.async.methods.SimpleHttpResponse; -import org.apache.hc.core5.http.ContentType; +import org.apache.hc.core5.http.EntityDetails; import org.apache.hc.core5.http.Header; +import org.apache.hc.core5.http.HttpResponse; +import org.apache.hc.core5.http.Message; +import org.apache.hc.core5.http.message.StatusLine; public class ApacheHttp2Response extends LowLevelHttpResponse { - private final SimpleHttpResponse response; + private final Message message; + private final HttpResponse response; private final Header[] allHeaders; + private final EntityDetails entity; + private final byte[] content; - ApacheHttp2Response(SimpleHttpResponse response) { - this.response = response; - allHeaders = response.getHeaders(); + ApacheHttp2Response(Message message) { + this.message = message; + this.response = message.getHead(); + this.allHeaders = response.getHeaders(); + + ApacheHttp2Entity body = message.getBody(); + this.entity = body != null ? body.getEntityDetails() : null; + this.content = body != null ? body.getContent() : null; } @Override @@ -43,25 +53,25 @@ public int getStatusCode() { @Override public InputStream getContent() throws IOException { - return new ByteArrayInputStream(response.getBodyBytes()); + return content == null ? null : new ByteArrayInputStream(content); } @Override public String getContentEncoding() { - Header contentEncodingHeader = response.getFirstHeader("Content-Encoding"); - return contentEncodingHeader == null ? null : contentEncodingHeader.getValue(); + return entity == null ? null : entity.getContentEncoding(); } @Override public long getContentLength() { - String bodyText = response.getBodyText(); - return bodyText == null ? 0 : bodyText.length(); + if (content != null) { + return content.length; + } + return entity == null ? -1 : entity.getContentLength(); } @Override public String getContentType() { - ContentType contentType = response.getContentType(); - return contentType == null ? null : contentType.toString(); + return entity == null ? null : entity.getContentType(); } @Override @@ -71,11 +81,12 @@ public String getReasonPhrase() { @Override public String getStatusLine() { - return response.toString(); + return new StatusLine(response).toString(); } public String getHeaderValue(String name) { - return response.getLastHeader(name).getValue(); + Header header = response.getLastHeader(name); + return header == null ? null : header.getValue(); } @Override @@ -94,7 +105,7 @@ public String getHeaderName(int index) { } @VisibleForTesting - public SimpleHttpResponse getResponse() { - return response; + public Message getMessage() { + return message; } } diff --git a/src/main/java/com/google/firebase/messaging/AndroidConfig.java b/src/main/java/com/google/firebase/messaging/AndroidConfig.java index ac9d8a52f..22f591680 100644 --- a/src/main/java/com/google/firebase/messaging/AndroidConfig.java +++ b/src/main/java/com/google/firebase/messaging/AndroidConfig.java @@ -55,6 +55,12 @@ public class AndroidConfig { @Key("direct_boot_ok") private final Boolean directBootOk; + @Key("bandwidth_constrained_ok") + private final Boolean bandwidthConstrainedOk; + + @Key("restricted_satellite_ok") + private final Boolean restrictedSatelliteOk; + private AndroidConfig(Builder builder) { this.collapseKey = builder.collapseKey; if (builder.priority != null) { @@ -79,6 +85,8 @@ private AndroidConfig(Builder builder) { this.notification = builder.notification; this.fcmOptions = builder.fcmOptions; this.directBootOk = builder.directBootOk; + this.bandwidthConstrainedOk = builder.bandwidthConstrainedOk; + this.restrictedSatelliteOk = builder.restrictedSatelliteOk; } /** @@ -108,6 +116,8 @@ public static class Builder { private AndroidNotification notification; private AndroidFcmOptions fcmOptions; private Boolean directBootOk; + private Boolean bandwidthConstrainedOk; + private Boolean restrictedSatelliteOk; private Builder() {} @@ -218,6 +228,24 @@ public Builder setDirectBootOk(boolean directBootOk) { return this; } + /** + * Sets the {@code bandwidth_constrained_ok} flag. If set to true, messages will be allowed + * to be delivered to the app while the device is on a bandwidth constrained network. + */ + public Builder setBandwidthConstrainedOk(boolean bandwidthConstrainedOk) { + this.bandwidthConstrainedOk = bandwidthConstrainedOk; + return this; + } + + /** + * Sets the {@code restricted_satellite_ok} flag. If set to true, messages will be allowed + * to be delivered to the app while the device is on a restricted satellite network. + */ + public Builder setRestrictedSatelliteOk(boolean restrictedSatelliteOk) { + this.restrictedSatelliteOk = restrictedSatelliteOk; + return this; + } + /** * Creates a new {@link AndroidConfig} instance from the parameters set on this builder. * diff --git a/src/main/java/com/google/firebase/remoteconfig/ParameterValue.java b/src/main/java/com/google/firebase/remoteconfig/ParameterValue.java index 90bf0e5df..1d48bbb03 100644 --- a/src/main/java/com/google/firebase/remoteconfig/ParameterValue.java +++ b/src/main/java/com/google/firebase/remoteconfig/ParameterValue.java @@ -17,15 +17,21 @@ package com.google.firebase.remoteconfig; import static com.google.common.base.Preconditions.checkNotNull; +import static java.util.stream.Collectors.toList; import com.google.firebase.internal.NonNull; +import com.google.firebase.internal.Nullable; +import com.google.firebase.remoteconfig.internal.TemplateResponse.ExperimentValueResponse; +import com.google.firebase.remoteconfig.internal.TemplateResponse.ExperimentVariantValueResponse; import com.google.firebase.remoteconfig.internal.TemplateResponse.ParameterValueResponse; - +import com.google.firebase.remoteconfig.internal.TemplateResponse.PersonalizationValueResponse; +import com.google.firebase.remoteconfig.internal.TemplateResponse.RolloutValueResponse; +import java.util.List; import java.util.Objects; -/** - * Represents a Remote Config parameter value that can be used in a {@link Template}. - */ +/** Represents a Remote Config parameter value + * that can be used in a + * {@link Template}. */ public abstract class ParameterValue { /** @@ -47,20 +53,87 @@ public static InAppDefault inAppDefault() { return new InAppDefault(); } + /** + * Creates a new {@link ParameterValue.RolloutValue} instance. + * + * @param rolloutId The rollout ID. + * @param value The value of the rollout. + * @param percent The percentage of the rollout. + * @return A {@link ParameterValue.RolloutValue} instance. + */ + public static RolloutValue ofRollout(String rolloutId, String value, double percent) { + return new RolloutValue(rolloutId, value, percent); + } + + /** + * Creates a new {@link ParameterValue.PersonalizationValue} instance. + * + * @param personalizationId The personalization ID. + * @return A {@link ParameterValue.PersonalizationValue} instance. + */ + public static PersonalizationValue ofPersonalization(String personalizationId) { + return new PersonalizationValue(personalizationId); + } + + /** + * Creates a new {@link ParameterValue.ExperimentValue} instance. + * + * @param experimentId The experiment ID. + * @param variantValues The list of experiment variant values. + * @param exposurePercent The exposure percentage of the experiment. + * @return A {@link ParameterValue.ExperimentValue} instance. + */ + public static ExperimentValue ofExperiment( + String experimentId, List variantValues, double exposurePercent) { + return new ExperimentValue(experimentId, variantValues, exposurePercent); + } + abstract ParameterValueResponse toParameterValueResponse(); static ParameterValue fromParameterValueResponse( - @NonNull ParameterValueResponse parameterValueResponse) { + @NonNull ParameterValueResponse parameterValueResponse) { checkNotNull(parameterValueResponse); if (parameterValueResponse.isUseInAppDefault()) { return ParameterValue.inAppDefault(); } + if (parameterValueResponse.getRolloutValue() != null) { + RolloutValueResponse rv = parameterValueResponse.getRolloutValue(); + // Protobuf serialization does not set values for fields on the wire when + // they are equal to the default value for the field type. When deserializing, + // can appear as the value not being set. Explicitly handle default value for + // the percent field since 0 is a valid value. + double percent = 0; + if (rv.getPercent() != null) { + percent = rv.getPercent(); + } + return ParameterValue.ofRollout(rv.getRolloutId(), rv.getValue(), percent); + } + if (parameterValueResponse.getPersonalizationValue() != null) { + PersonalizationValueResponse pv = parameterValueResponse.getPersonalizationValue(); + return ParameterValue.ofPersonalization(pv.getPersonalizationId()); + } + if (parameterValueResponse.getExperimentValue() != null) { + ExperimentValueResponse ev = parameterValueResponse.getExperimentValue(); + List variantValues = + ev.getExperimentVariantValues().stream() + .map( + evv -> + new ExperimentVariantValue( + evv.getVariantId(), evv.getValue(), evv.getNoChange())) + .collect(toList()); + // Handle null exposurePercent by defaulting to 0 + double exposurePercent = 0; + if (ev.getExposurePercent() != null) { + exposurePercent = ev.getExposurePercent(); + } + return ParameterValue.ofExperiment( + ev.getExperimentId(), variantValues, exposurePercent); + } return ParameterValue.of(parameterValueResponse.getValue()); } /** - * Represents an explicit Remote Config parameter value with a value that the - * parameter is set to. + * Represents an explicit Remote Config parameter value with a value that the parameter is set to. */ public static final class Explicit extends ParameterValue { @@ -81,8 +154,7 @@ public String getValue() { @Override ParameterValueResponse toParameterValueResponse() { - return new ParameterValueResponse() - .setValue(this.value); + return new ParameterValueResponse().setValue(this.value); } @Override @@ -103,9 +175,7 @@ public int hashCode() { } } - /** - * Represents an in app default parameter value. - */ + /** Represents an in app default parameter value. */ public static final class InAppDefault extends ParameterValue { @Override @@ -124,4 +194,281 @@ public boolean equals(Object o) { return true; } } + + /** Represents a Rollout value. */ + public static final class RolloutValue extends ParameterValue { + private final String rolloutId; + private final String value; + private final double percent; + + private RolloutValue(String rolloutId, String value, double percent) { + this.rolloutId = rolloutId; + this.value = value; + this.percent = percent; + } + + /** + * Gets the ID of the Rollout linked to this value. + * + * @return The Rollout ID + */ + public String getRolloutId() { + return rolloutId; + } + + /** + * Gets the value that is being rolled out. + * + * @return The rollout value + */ + public String getValue() { + return value; + } + + /** + * Gets the rollout percentage representing the exposure of rollout value in the target + * audience. + * + * @return Percentage of audience exposed to the rollout + */ + public double getPercent() { + return percent; + } + + @Override + ParameterValueResponse toParameterValueResponse() { + return new ParameterValueResponse() + .setRolloutValue( + new RolloutValueResponse() + .setRolloutId(this.rolloutId) + .setValue(this.value) + .setPercent(this.percent)); + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + RolloutValue that = (RolloutValue) o; + return Double.compare(that.percent, percent) == 0 + && Objects.equals(rolloutId, that.rolloutId) + && Objects.equals(value, that.value); + } + + @Override + public int hashCode() { + return Objects.hash(rolloutId, value, percent); + } + } + + /** Represents a Personalization value. */ + public static final class PersonalizationValue extends ParameterValue { + private final String personalizationId; + + private PersonalizationValue(String personalizationId) { + this.personalizationId = personalizationId; + } + + /** + * Gets the ID of the Personalization linked to this value. + * + * @return The Personalization ID + */ + public String getPersonalizationId() { + return personalizationId; + } + + @Override + ParameterValueResponse toParameterValueResponse() { + return new ParameterValueResponse() + .setPersonalizationValue( + new PersonalizationValueResponse().setPersonalizationId(this.personalizationId)); + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + PersonalizationValue that = (PersonalizationValue) o; + return Objects.equals(personalizationId, that.personalizationId); + } + + @Override + public int hashCode() { + return Objects.hash(personalizationId); + } + } + + /** Represents a specific variant within an Experiment. */ + public static final class ExperimentVariantValue { + private final String variantId; + private final String value; + private final Boolean noChange; + + ExperimentVariantValue(String variantId, String value, Boolean noChange) { + this.variantId = variantId; + this.value = value; + this.noChange = noChange; + } + + /** + * Creates a new {@link ExperimentVariantValue} instance. + * + * @param variantId The variant ID. + * @param value The value of the variant. + * @return A {@link ExperimentVariantValue} instance. + */ + public static ExperimentVariantValue of(String variantId, String value) { + return new ExperimentVariantValue(variantId, value, null); + } + + /** + * Creates a new {@link ExperimentVariantValue} instance. + * + * @param variantId The variant ID. + * @return A {@link ExperimentVariantValue} instance. + */ + public static ExperimentVariantValue ofNoChange(String variantId) { + return new ExperimentVariantValue(variantId, null, true); + } + + /** + * Gets the ID of the experiment variant. + * + * @return The variant ID + */ + public String getVariantId() { + return variantId; + } + + /** + * Gets the value of the experiment variant. + * + * @return The variant value + */ + @Nullable + public String getValue() { + return value; + } + + @Nullable + Boolean getNoChange() { + return noChange; + } + + /** + * Returns whether the experiment variant is a no-change variant. + * + * @return true if the experiment variant is a no-change variant, and false otherwise. + */ + public boolean isNoChange() { + return Boolean.TRUE.equals(noChange); + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + ExperimentVariantValue that = (ExperimentVariantValue) o; + return noChange == that.noChange + && Objects.equals(variantId, that.variantId) + && Objects.equals(value, that.value); + } + + @Override + public int hashCode() { + return Objects.hash(variantId, value, noChange); + } + } + + /** Represents an Experiment value. */ + public static final class ExperimentValue extends ParameterValue { + private final String experimentId; + private final List variantValues; + private final double exposurePercent; + + private ExperimentValue( + String experimentId, List variantValues, double exposurePercent) { + this.experimentId = experimentId; + this.variantValues = variantValues; + this.exposurePercent = exposurePercent; + } + + /** + * Gets the ID of the experiment linked to this value. + * + * @return The Experiment ID + */ + public String getExperimentId() { + return experimentId; + } + + /** + * Gets the exposure percentage of the experiment linked to this value. + * + * @return Exposure percentage of the experiment linked to this value. + */ + public double getExposurePercent() { + return exposurePercent; + } + + /** + * Gets a collection of variant values served by the experiment. + * + * @return List of {@link ExperimentVariantValue} + */ + public List getExperimentVariantValues() { + return variantValues; + } + + @Override + ParameterValueResponse toParameterValueResponse() { + List variantValueResponses = + variantValues.stream() + .map( + variantValue -> + new ExperimentVariantValueResponse() + .setVariantId(variantValue.getVariantId()) + .setValue(variantValue.getValue()) + .setNoChange(variantValue.getNoChange())) + .collect(toList()); + return new ParameterValueResponse() + .setExperimentValue( + new ExperimentValueResponse() + .setExperimentId(this.experimentId) + .setExperimentVariantValues(variantValueResponses) + .setExposurePercent(this.exposurePercent)); + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + ExperimentValue that = (ExperimentValue) o; + return Objects.equals(experimentId, that.experimentId) + && Objects.equals(variantValues, that.variantValues) + && Double.compare(that.exposurePercent, exposurePercent) == 0; + } + + @Override + public int hashCode() { + return Objects.hash(experimentId, variantValues, exposurePercent); + } + } } diff --git a/src/main/java/com/google/firebase/remoteconfig/Template.java b/src/main/java/com/google/firebase/remoteconfig/Template.java index d94cfc89e..5dfe06aaf 100644 --- a/src/main/java/com/google/firebase/remoteconfig/Template.java +++ b/src/main/java/com/google/firebase/remoteconfig/Template.java @@ -60,7 +60,7 @@ public Template(String etag) { this((String) null); } - Template(@NonNull TemplateResponse templateResponse) { + Template(@NonNull TemplateResponse templateResponse) throws FirebaseRemoteConfigException { checkNotNull(templateResponse); this.parameters = new HashMap<>(); this.conditions = new ArrayList<>(); @@ -86,6 +86,7 @@ public Template(String etag) { if (templateResponse.getVersion() != null) { this.version = new Version(templateResponse.getVersion()); } + validateExperimentExposurePercents(this.parameters, this.parameterGroups); this.etag = templateResponse.getEtag(); } @@ -278,4 +279,59 @@ public boolean equals(Object o) { public int hashCode() { return Objects.hash(etag, parameters, conditions, parameterGroups, version); } + + private void validateExperimentExposurePercents( + Map parameters, + Map parameterGroups) throws FirebaseRemoteConfigException { + Map experimentExposurePercents = new HashMap<>(); + validateParameters(parameters, experimentExposurePercents); + if (parameterGroups != null) { + for (ParameterGroup group : parameterGroups.values()) { + validateParameters(group.getParameters(), experimentExposurePercents); + } + } + } + + private void validateParameters( + Map parameters, + Map experimentExposurePercents) throws FirebaseRemoteConfigException { + if (parameters == null) { + return; + } + for (Map.Entry entry : parameters.entrySet()) { + Parameter parameter = entry.getValue(); + String parameterName = entry.getKey(); + checkExposurePercent(parameter.getDefaultValue(), parameterName, experimentExposurePercents); + if (parameter.getConditionalValues() != null) { + for (ParameterValue value : parameter.getConditionalValues().values()) { + checkExposurePercent(value, parameterName, experimentExposurePercents); + } + } + } + } + + private void checkExposurePercent( + ParameterValue value, + String parameterName, + Map experimentExposurePercents) throws FirebaseRemoteConfigException { + if (value instanceof ParameterValue.ExperimentValue) { + ParameterValue.ExperimentValue experimentValue = (ParameterValue.ExperimentValue) value; + Double exposurePercent = experimentValue.getExposurePercent(); + if (exposurePercent != null) { + // Enforce range [0, 100] + if (exposurePercent < 0 || exposurePercent > 100) { + return; + } + // Enforce consistency for the same experimentId + String experimentId = experimentValue.getExperimentId(); + if (experimentExposurePercents.containsKey(experimentId)) { + if (!Objects.equals(experimentExposurePercents.get(experimentId), exposurePercent)) { + return; + } + } else { + experimentExposurePercents.put(experimentId, exposurePercent); + } + } + } + } } diff --git a/src/main/java/com/google/firebase/remoteconfig/internal/TemplateResponse.java b/src/main/java/com/google/firebase/remoteconfig/internal/TemplateResponse.java index 57a066bf2..32f5bcef9 100644 --- a/src/main/java/com/google/firebase/remoteconfig/internal/TemplateResponse.java +++ b/src/main/java/com/google/firebase/remoteconfig/internal/TemplateResponse.java @@ -161,6 +161,15 @@ public static final class ParameterValueResponse { @Key("useInAppDefault") private Boolean useInAppDefault; + @Key("rolloutValue") + private RolloutValueResponse rolloutValue; + + @Key("personalizationValue") + private PersonalizationValueResponse personalizationValue; + + @Key("experimentValue") + private ExperimentValueResponse experimentValue; + public String getValue() { return value; } @@ -169,6 +178,18 @@ public boolean isUseInAppDefault() { return Boolean.TRUE.equals(this.useInAppDefault); } + public RolloutValueResponse getRolloutValue() { + return rolloutValue; + } + + public PersonalizationValueResponse getPersonalizationValue() { + return personalizationValue; + } + + public ExperimentValueResponse getExperimentValue() { + return experimentValue; + } + public ParameterValueResponse setValue(String value) { this.value = value; return this; @@ -178,6 +199,167 @@ public ParameterValueResponse setUseInAppDefault(boolean useInAppDefault) { this.useInAppDefault = useInAppDefault; return this; } + + public ParameterValueResponse setRolloutValue(RolloutValueResponse rolloutValue) { + this.rolloutValue = rolloutValue; + return this; + } + + public ParameterValueResponse setPersonalizationValue( + PersonalizationValueResponse personalizationValue) { + this.personalizationValue = personalizationValue; + return this; + } + + public ParameterValueResponse setExperimentValue(ExperimentValueResponse experimentValue) { + this.experimentValue = experimentValue; + return this; + } + } + + /** + * The Data Transfer Object for parsing Remote Config Rollout value responses from the + * Remote Config service. + **/ + public static final class RolloutValueResponse { + @Key("rolloutId") + private String rolloutId; + + @Key("value") + private String value; + + @Key("percent") + private Double percent; + + public String getRolloutId() { + return rolloutId; + } + + public String getValue() { + return value; + } + + public Double getPercent() { + return percent; + } + + public RolloutValueResponse setRolloutId(String rolloutId) { + this.rolloutId = rolloutId; + return this; + } + + public RolloutValueResponse setValue(String value) { + this.value = value; + return this; + } + + public RolloutValueResponse setPercent(Double percent) { + this.percent = percent; + return this; + } + } + + /** + * The Data Transfer Object for parsing Remote Config Personalization value responses from the + * Remote Config service. + **/ + public static final class PersonalizationValueResponse { + @Key("personalizationId") + private String personalizationId; + + public String getPersonalizationId() { + return personalizationId; + } + + public PersonalizationValueResponse setPersonalizationId(String personalizationId) { + this.personalizationId = personalizationId; + return this; + } + } + + /** + * The Data Transfer Object for parsing Remote Config Experiment value responses from the + * Remote Config service. + **/ + public static final class ExperimentValueResponse { + @Key("experimentId") + private String experimentId; + + @Key("variantValue") + private List experimentVariantValues; + + @Key("exposurePercent") + private Double exposurePercent; + + public String getExperimentId() { + return experimentId; + } + + public List getExperimentVariantValues() { + return experimentVariantValues; + } + + public Double getExposurePercent() { + return exposurePercent; + } + + public ExperimentValueResponse setExperimentId(String experimentId) { + this.experimentId = experimentId; + return this; + } + + public ExperimentValueResponse setExperimentVariantValues( + List experimentVariantValues) { + this.experimentVariantValues = experimentVariantValues; + return this; + } + + public ExperimentValueResponse setExposurePercent(Double exposurePercent) { + this.exposurePercent = exposurePercent; + return this; + } + } + + /** + * The Data Transfer Object for parsing Remote Config Experiment variant value responses from the + * Remote Config service. + **/ + public static final class ExperimentVariantValueResponse { + @Key("variantId") + private String variantId; + + @Key("value") + private String value; + + @Key("noChange") + private Boolean noChange; + + public String getVariantId() { + return variantId; + } + + public String getValue() { + return value; + } + + public Boolean getNoChange() { + return noChange; + } + + public ExperimentVariantValueResponse setVariantId(String variantId) { + this.variantId = variantId; + return this; + } + + public ExperimentVariantValueResponse setValue(String value) { + this.value = value; + return this; + } + + public ExperimentVariantValueResponse setNoChange(Boolean noChange) { + this.noChange = noChange; + return this; + } } /** diff --git a/src/test/java/com/google/firebase/internal/ApacheHttp2TransportTest.java b/src/test/java/com/google/firebase/internal/ApacheHttp2TransportTest.java index 60b053109..ba604d3d1 100644 --- a/src/test/java/com/google/firebase/internal/ApacheHttp2TransportTest.java +++ b/src/test/java/com/google/firebase/internal/ApacheHttp2TransportTest.java @@ -56,11 +56,13 @@ import org.apache.hc.core5.http.HttpRequestMapper; import org.apache.hc.core5.http.HttpResponse; import org.apache.hc.core5.http.HttpStatus; +import org.apache.hc.core5.http.Message; import org.apache.hc.core5.http.impl.bootstrap.HttpServer; import org.apache.hc.core5.http.impl.io.HttpService; import org.apache.hc.core5.http.io.HttpRequestHandler; import org.apache.hc.core5.http.io.entity.ByteArrayEntity; import org.apache.hc.core5.http.io.support.BasicHttpServerRequestHandler; +import org.apache.hc.core5.http.message.BasicHttpResponse; import org.apache.hc.core5.http.nio.AsyncPushConsumer; import org.apache.hc.core5.http.nio.AsyncRequestProducer; import org.apache.hc.core5.http.nio.AsyncResponseConsumer; @@ -87,7 +89,9 @@ public Future doExecute( final HandlerFactory pushHandlerFactory, final HttpContext context, final FutureCallback callback) { - return (Future) CompletableFuture.completedFuture(new SimpleHttpResponse(200)); + return (Future) CompletableFuture + .completedFuture( + new Message(new BasicHttpResponse(200), null)); } }, requestBuilder); @@ -118,7 +122,9 @@ public Future doExecute( final HandlerFactory pushHandlerFactory, final HttpContext context, final FutureCallback callback) { - return (Future) CompletableFuture.completedFuture(new SimpleHttpResponse(200)); + return (Future) CompletableFuture + .completedFuture( + new Message(new BasicHttpResponse(200), null)); } }, requestBuilder); @@ -148,7 +154,9 @@ public Future doExecute( final HandlerFactory pushHandlerFactory, final HttpContext context, final FutureCallback callback) { - return (Future) CompletableFuture.completedFuture(simpleHttpResponse); + return (Future) CompletableFuture + .completedFuture(new Message(simpleHttpResponse, + new ApacheHttp2Entity(simpleHttpResponse.getBodyBytes(), null))); } }, requestBuilder); LowLevelHttpResponse response = request.execute(); @@ -157,7 +165,7 @@ public Future doExecute( // we confirm that the simple response we prepared in this test is the same as // the content's response assertTrue(response.getContent() instanceof ByteArrayInputStream); - assertEquals(simpleHttpResponse, ((ApacheHttp2Response) response).getResponse()); + assertEquals(simpleHttpResponse, ((ApacheHttp2Response) response).getMessage().getHead()); // No need to cloase ByteArrayInputStream since close() has no effect. } @@ -212,7 +220,9 @@ public Future doExecute( final HandlerFactory pushHandlerFactory, final HttpContext context, final FutureCallback callback) { - return (Future) CompletableFuture.completedFuture(new SimpleHttpResponse(200)); + return (Future) CompletableFuture + .completedFuture(new Message( + new BasicHttpResponse(200), null)); } }; ApacheHttp2Transport transport = new ApacheHttp2Transport(mockClient); @@ -328,6 +338,91 @@ private void execute(ApacheHttp2Request request) throws IOException { request.execute(); } + @Test + public void testGzipResponse() throws IOException { + final String originalContent = "hello world"; + final java.io.ByteArrayOutputStream baos = new java.io.ByteArrayOutputStream(); + try (java.util.zip.GZIPOutputStream gzip = new java.util.zip.GZIPOutputStream(baos)) { + gzip.write(originalContent.getBytes(StandardCharsets.UTF_8)); + } + final byte[] gzippedContent = baos.toByteArray(); + + final HttpRequestHandler handler = new HttpRequestHandler() { + @Override + public void handle( + ClassicHttpRequest request, ClassicHttpResponse response, HttpContext context) + throws HttpException, IOException { + response.setCode(HttpStatus.SC_OK); + response.setHeader(HttpHeaders.CONTENT_ENCODING, "gzip"); + response.setHeader(HttpHeaders.CONTENT_LENGTH, String.valueOf(gzippedContent.length)); + response.setHeader(HttpHeaders.CONTENT_TYPE, "text/plain; charset=UTF-8"); + ByteArrayEntity entity = new ByteArrayEntity(gzippedContent, ContentType.TEXT_PLAIN); + response.setEntity(entity); + } + }; + + try (FakeServer server = new FakeServer(handler)) { + ApacheHttp2Transport transport = new ApacheHttp2Transport(); + GenericUrl testUrl = new GenericUrl("http://localhost/foo"); + testUrl.setPort(server.getPort()); + + // Execute the low-level request directly to accurately assert metadata + // without Google's parsed HttpHeaders map mangling the payload lengths. + ApacheHttp2Request request = transport.buildRequest("GET", testUrl.build()); + LowLevelHttpResponse response = request.execute(); + + assertEquals(200, response.getStatusCode()); + assertEquals("text/plain; charset=UTF-8", response.getContentType()); + + boolean wasAutoDecompressed = response.getContentEncoding() == null; + if (wasAutoDecompressed) { + assertEquals(originalContent.length(), response.getContentLength()); + } else { + assertEquals("gzip", response.getContentEncoding()); + assertEquals(gzippedContent.length, response.getContentLength()); + } + + // Verify the low-level stream returns the exact expected payload based on + // decompression state + java.io.InputStream stream = response.getContent(); + byte[] resultBytes = com.google.common.io.ByteStreams.toByteArray(stream); + + if (wasAutoDecompressed) { + assertEquals(originalContent, new String(resultBytes, StandardCharsets.UTF_8)); + } else { + org.junit.Assert.assertArrayEquals(gzippedContent, resultBytes); + } + } + } + + @Test + public void testEmptyResponseWithHeaders() throws IOException { + // Tests that a response with no actual body but headers does not throw NPE + // in ApacheHttp2Response due to entity being null. + final HttpRequestHandler handler = new HttpRequestHandler() { + @Override + public void handle( + ClassicHttpRequest request, ClassicHttpResponse response, HttpContext context) + throws HttpException, IOException { + response.setCode(HttpStatus.SC_NO_CONTENT); + response.setHeader(HttpHeaders.CONTENT_LENGTH, "0"); + // Explicitly omitting the entity to simulate NO_CONTENT bodyless response + } + }; + + try (FakeServer server = new FakeServer(handler)) { + HttpTransport transport = new ApacheHttp2Transport(); + GenericUrl testUrl = new GenericUrl("http://localhost/empty"); + testUrl.setPort(server.getPort()); + com.google.api.client.http.HttpResponse response = transport.createRequestFactory() + .buildGetRequest(testUrl) + .execute(); + + assertEquals(204, response.getStatusCode()); + assertEquals(0L, response.getHeaders().getContentLength().longValue()); + } + } + private static class FakeServer implements AutoCloseable { private final HttpServer server; diff --git a/src/test/java/com/google/firebase/messaging/MessageTest.java b/src/test/java/com/google/firebase/messaging/MessageTest.java index 263d1ced5..94ca4fa4a 100644 --- a/src/test/java/com/google/firebase/messaging/MessageTest.java +++ b/src/test/java/com/google/firebase/messaging/MessageTest.java @@ -20,7 +20,6 @@ import static org.junit.Assert.assertSame; import static org.junit.Assert.fail; -import com.google.api.client.googleapis.util.Utils; import com.google.api.client.json.JsonFactory; import com.google.api.client.json.JsonParser; import com.google.common.collect.ImmutableList; @@ -224,6 +223,52 @@ public void testAndroidMessageWithDirectBootOk() throws IOException { assertJsonEquals(ImmutableMap.of("topic", "test-topic", "android", data), message); } + @Test + public void testAndroidMessageWithBandwidthConstrainedOk() throws IOException { + Message message = Message.builder() + .setAndroidConfig(AndroidConfig.builder() + .setBandwidthConstrainedOk(true) + .setNotification(AndroidNotification.builder() + .setTitle("android-title") + .setBody("android-body") + .build()) + .build()) + .setTopic("test-topic") + .build(); + Map notification = ImmutableMap.builder() + .put("title", "android-title") + .put("body", "android-body") + .build(); + Map data = ImmutableMap.of( + "bandwidth_constrained_ok", true, + "notification", notification + ); + assertJsonEquals(ImmutableMap.of("topic", "test-topic", "android", data), message); + } + + @Test + public void testAndroidMessageWithRestrictedSatelliteOk() throws IOException { + Message message = Message.builder() + .setAndroidConfig(AndroidConfig.builder() + .setRestrictedSatelliteOk(true) + .setNotification(AndroidNotification.builder() + .setTitle("android-title") + .setBody("android-body") + .build()) + .build()) + .setTopic("test-topic") + .build(); + Map notification = ImmutableMap.builder() + .put("title", "android-title") + .put("body", "android-body") + .build(); + Map data = ImmutableMap.of( + "restricted_satellite_ok", true, + "notification", notification + ); + assertJsonEquals(ImmutableMap.of("topic", "test-topic", "android", data), message); + } + @Test(expected = IllegalArgumentException.class) public void testAndroidNotificationWithNegativeCount() throws IllegalArgumentException { AndroidNotification.builder().setNotificationCount(-1).build(); @@ -968,7 +1013,7 @@ public void testExtendedAndroidNotificationParameters() throws IOException { } private static void assertJsonEquals( - Map expected, Object actual) throws IOException { + Map expected, Object actual) throws IOException { assertEquals(expected, toMap(actual)); } diff --git a/src/test/java/com/google/firebase/remoteconfig/FirebaseRemoteConfigTest.java b/src/test/java/com/google/firebase/remoteconfig/FirebaseRemoteConfigTest.java index 8b416b67b..7c02b3c2f 100644 --- a/src/test/java/com/google/firebase/remoteconfig/FirebaseRemoteConfigTest.java +++ b/src/test/java/com/google/firebase/remoteconfig/FirebaseRemoteConfigTest.java @@ -22,6 +22,7 @@ import static org.junit.Assert.assertTrue; import static org.junit.Assert.fail; +import com.google.common.collect.ImmutableMap; import com.google.firebase.ErrorCode; import com.google.firebase.FirebaseApp; import com.google.firebase.FirebaseOptions; @@ -149,6 +150,35 @@ public void testGetTemplate() throws FirebaseRemoteConfigException { assertEquals(TEST_ETAG, template.getETag()); } + @Test + public void testGetTemplateWithManagedValues() throws FirebaseRemoteConfigException { + Template template = new Template() + .setETag(TEST_ETAG) + .setParameters(ImmutableMap.of( + "p1", new Parameter().setConditionalValues(ImmutableMap.of( + "c1", ParameterValue.ofRollout("rollout_1", "value_1", 10.0))), + "p2", new Parameter().setConditionalValues(ImmutableMap.of( + "c2", ParameterValue.ofPersonalization("personalization_1"))) + )); + MockRemoteConfigClient client = MockRemoteConfigClient.fromTemplate(template); + FirebaseRemoteConfig remoteConfig = getRemoteConfig(client); + + Template fetchedTemplate = remoteConfig.getTemplate(); + + assertEquals(TEST_ETAG, fetchedTemplate.getETag()); + ParameterValue.RolloutValue rolloutValue = + (ParameterValue.RolloutValue) fetchedTemplate.getParameters().get("p1") + .getConditionalValues().get("c1"); + assertEquals("rollout_1", rolloutValue.getRolloutId()); + assertEquals("value_1", rolloutValue.getValue()); + assertEquals(10.0, rolloutValue.getPercent(), 0.0); + + ParameterValue.PersonalizationValue personalizationValue = + (ParameterValue.PersonalizationValue) fetchedTemplate.getParameters().get("p2") + .getConditionalValues().get("c2"); + assertEquals("personalization_1", personalizationValue.getPersonalizationId()); + } + @Test public void testGetTemplateFailure() { MockRemoteConfigClient client = MockRemoteConfigClient.fromException(TEST_EXCEPTION); diff --git a/src/test/java/com/google/firebase/remoteconfig/ParameterTest.java b/src/test/java/com/google/firebase/remoteconfig/ParameterTest.java index 7fc6ee555..598b0008c 100644 --- a/src/test/java/com/google/firebase/remoteconfig/ParameterTest.java +++ b/src/test/java/com/google/firebase/remoteconfig/ParameterTest.java @@ -104,4 +104,22 @@ public void testEquality() { assertNotEquals(parameterThree, parameterFive); assertNotEquals(parameterThree, parameterSeven); } + + @Test + public void testEqualityWithManagedValues() { + final Parameter parameterOne = new Parameter() + .setDefaultValue(ParameterValue.ofRollout("rollout_1", "value_1", 10.0)); + final Parameter parameterTwo = new Parameter() + .setDefaultValue(ParameterValue.ofRollout("rollout_1", "value_1", 10.0)); + + assertEquals(parameterOne, parameterTwo); + + final Parameter parameterThree = new Parameter() + .setDefaultValue(ParameterValue.ofPersonalization("personalization_1")); + final Parameter parameterFour = new Parameter() + .setDefaultValue(ParameterValue.ofPersonalization("personalization_1")); + + assertEquals(parameterThree, parameterFour); + assertNotEquals(parameterOne, parameterThree); + } } diff --git a/src/test/java/com/google/firebase/remoteconfig/ParameterValueTest.java b/src/test/java/com/google/firebase/remoteconfig/ParameterValueTest.java index 842fd808f..46db5c44e 100644 --- a/src/test/java/com/google/firebase/remoteconfig/ParameterValueTest.java +++ b/src/test/java/com/google/firebase/remoteconfig/ParameterValueTest.java @@ -18,6 +18,11 @@ import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertTrue; + +import com.google.common.collect.ImmutableList; +import com.google.firebase.remoteconfig.ParameterValue.ExperimentVariantValue; +import com.google.firebase.remoteconfig.internal.TemplateResponse.ParameterValueResponse; import org.junit.Test; @@ -37,6 +42,50 @@ public void testCreateInAppDefault() { assertEquals(ParameterValue.InAppDefault.class, parameterValue.getClass()); } + @Test + public void testCreateRolloutValue() { + final ParameterValue.RolloutValue parameterValue = + ParameterValue.ofRollout("rollout_1", "value_1", 10.0); + + assertEquals("rollout_1", parameterValue.getRolloutId()); + assertEquals("value_1", parameterValue.getValue()); + assertEquals(10.0, parameterValue.getPercent(), 0.0); + } + + @Test + public void testCreatePersonalizationValue() { + final ParameterValue.PersonalizationValue parameterValue = + ParameterValue.ofPersonalization("personalization_1"); + + assertEquals("personalization_1", parameterValue.getPersonalizationId()); + } + + @Test + public void testCreateExperimentValue() { + final ParameterValue.ExperimentValue parameterValue = + ParameterValue.ofExperiment("experiment_1", ImmutableList.of( + ExperimentVariantValue.of("variant_1", "value_1"), + ExperimentVariantValue.ofNoChange("variant_2") + ), 10.0); + + assertEquals("experiment_1", parameterValue.getExperimentId()); + assertEquals(2, parameterValue.getExperimentVariantValues().size()); + assertEquals(10.0, parameterValue.getExposurePercent(), 0.0); + + assertEquals("experiment_1", parameterValue.getExperimentId()); + assertEquals(2, parameterValue.getExperimentVariantValues().size()); + ExperimentVariantValue variant1 = parameterValue.getExperimentVariantValues().get(0); + assertEquals("variant_1", variant1.getVariantId()); + assertEquals("value_1", variant1.getValue()); + assertEquals(false, variant1.isNoChange()); + assertEquals(null, variant1.getNoChange()); + ExperimentVariantValue variant2 = parameterValue.getExperimentVariantValues().get(1); + assertEquals("variant_2", variant2.getVariantId()); + assertEquals(null, variant2.getValue()); + assertEquals(true, variant2.isNoChange()); + + } + @Test public void testEquality() { ParameterValue.Explicit parameterValueOne = ParameterValue.of("value"); @@ -50,5 +99,61 @@ public void testEquality() { ParameterValue.InAppDefault parameterValueFive = ParameterValue.inAppDefault(); assertEquals(parameterValueFour, parameterValueFive); + + ParameterValue.RolloutValue rolloutValueOne = + ParameterValue.ofRollout("rollout_1", "value_1", 10.0); + ParameterValue.RolloutValue rolloutValueTwo = + ParameterValue.ofRollout("rollout_1", "value_1", 10.0); + ParameterValue.RolloutValue rolloutValueThree = + ParameterValue.ofRollout("rollout_2", "value_1", 10.0); + + assertEquals(rolloutValueOne, rolloutValueTwo); + assertNotEquals(rolloutValueOne, rolloutValueThree); + + ParameterValue.PersonalizationValue personalizationValueOne = + ParameterValue.ofPersonalization("personalization_1"); + ParameterValue.PersonalizationValue personalizationValueTwo = + ParameterValue.ofPersonalization("personalization_1"); + ParameterValue.PersonalizationValue personalizationValueThree = + ParameterValue.ofPersonalization("personalization_2"); + + assertEquals(personalizationValueOne, personalizationValueTwo); + assertNotEquals(personalizationValueOne, personalizationValueThree); + + ParameterValue.ExperimentValue experimentValueOne = + ParameterValue.ofExperiment("experiment_1", ImmutableList.of( + ExperimentVariantValue.of("variant_1", "value_1") + ), 10.0); + ParameterValue.ExperimentValue experimentValueTwo = + ParameterValue.ofExperiment("experiment_1", ImmutableList.of( + ExperimentVariantValue.of("variant_1", "value_1") + ), 10.0); + ParameterValue.ExperimentValue experimentValueThree = + ParameterValue.ofExperiment("experiment_2", ImmutableList.of( + ExperimentVariantValue.of("variant_1", "value_1") + ), 10.0); + ParameterValue.ExperimentValue experimentValueFour = + ParameterValue.ofExperiment("experiment_1", ImmutableList.of( + ExperimentVariantValue.of("variant_2", "value_2") + ), 20.0); + assertEquals(experimentValueOne, experimentValueTwo); + assertNotEquals(experimentValueOne, experimentValueThree); + assertNotEquals(experimentValueOne, experimentValueFour); + } + + @Test + public void testExperimentValueWithZeroExposure() { + ParameterValue.ExperimentValue value = ParameterValue.ofExperiment( + "exp_0", ImmutableList.of(ExperimentVariantValue.of("v1", "foo")), 0.0); + + // Test Serialization + ParameterValueResponse response = value.toParameterValueResponse(); + assertEquals(0.0, response.getExperimentValue().getExposurePercent(), 0.0); + + // Test Deserialization + ParameterValue fromResponse = ParameterValue.fromParameterValueResponse(response); + assertTrue(fromResponse instanceof ParameterValue.ExperimentValue); + assertEquals(0.0, ((ParameterValue.ExperimentValue) fromResponse).getExposurePercent(), 0.0); } + } diff --git a/src/test/java/com/google/firebase/remoteconfig/TemplateTest.java b/src/test/java/com/google/firebase/remoteconfig/TemplateTest.java index a3ea3e878..92abb850c 100644 --- a/src/test/java/com/google/firebase/remoteconfig/TemplateTest.java +++ b/src/test/java/com/google/firebase/remoteconfig/TemplateTest.java @@ -107,7 +107,7 @@ public void testConstructorWithETag() { } @Test(expected = NullPointerException.class) - public void testConstructorWithNullTemplateResponse() { + public void testConstructorWithNullTemplateResponse() throws FirebaseRemoteConfigException { new Template((TemplateResponse) null); }