From d04e15b73b4972c1e6e50ed14ca1c2956e761266 Mon Sep 17 00:00:00 2001 From: Jonathan Edey <145066863+jonathanedey@users.noreply.github.com> Date: Thu, 12 Mar 2026 11:48:34 -0400 Subject: [PATCH 01/22] feat(fcm): Add support for restricted satellite API (#1191) --- .../firebase/messaging/AndroidConfig.java | 18 +++++++++++-- .../firebase/messaging/MessageTest.java | 26 +++++++++++++++++-- 2 files changed, 40 insertions(+), 4 deletions(-) diff --git a/src/main/java/com/google/firebase/messaging/AndroidConfig.java b/src/main/java/com/google/firebase/messaging/AndroidConfig.java index 941793479..22f591680 100644 --- a/src/main/java/com/google/firebase/messaging/AndroidConfig.java +++ b/src/main/java/com/google/firebase/messaging/AndroidConfig.java @@ -58,6 +58,9 @@ public class AndroidConfig { @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) { @@ -83,6 +86,7 @@ private AndroidConfig(Builder builder) { this.fcmOptions = builder.fcmOptions; this.directBootOk = builder.directBootOk; this.bandwidthConstrainedOk = builder.bandwidthConstrainedOk; + this.restrictedSatelliteOk = builder.restrictedSatelliteOk; } /** @@ -113,6 +117,7 @@ public static class Builder { private AndroidFcmOptions fcmOptions; private Boolean directBootOk; private Boolean bandwidthConstrainedOk; + private Boolean restrictedSatelliteOk; private Builder() {} @@ -224,14 +229,23 @@ public Builder setDirectBootOk(boolean directBootOk) { } /** - * Sets the {@code bandwidth_constrained_ok} flag. If set to true, messages can be delivered - * even when the device is connected through a bandwidth-constrained network. + * 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/test/java/com/google/firebase/messaging/MessageTest.java b/src/test/java/com/google/firebase/messaging/MessageTest.java index 70d130032..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; @@ -247,6 +246,29 @@ public void testAndroidMessageWithBandwidthConstrainedOk() throws IOException { 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(); @@ -991,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)); } From ab858ef2004c69c1ffee5a7dbb6144618646e206 Mon Sep 17 00:00:00 2001 From: varun rathore <35365856+rathovarun1032@users.noreply.github.com> Date: Mon, 27 Apr 2026 23:28:26 +0530 Subject: [PATCH 02/22] feat(rc):Add exposurePercent to ExperimentValues (#1201) * Add exposurePercent to ExperimentValues * fix comments * fix build * Apply suggestions from code review Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> * fix build * fix build * fix build * fix build * fix build * fix build * fix build * resolved comments * resolved comments * resolved comments * resolved comments * Update TemplateResponse.java * Update Template.java --------- Co-authored-by: Varun Rathore Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- .../firebase/remoteconfig/ParameterValue.java | 133 ++++++++++-------- .../firebase/remoteconfig/Template.java | 58 +++++++- .../internal/TemplateResponse.java | 12 ++ .../remoteconfig/ParameterValueTest.java | 34 ++++- .../firebase/remoteconfig/TemplateTest.java | 2 +- 5 files changed, 172 insertions(+), 67 deletions(-) diff --git a/src/main/java/com/google/firebase/remoteconfig/ParameterValue.java b/src/main/java/com/google/firebase/remoteconfig/ParameterValue.java index db63e3718..1d48bbb03 100644 --- a/src/main/java/com/google/firebase/remoteconfig/ParameterValue.java +++ b/src/main/java/com/google/firebase/remoteconfig/ParameterValue.java @@ -26,14 +26,12 @@ 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.ArrayList; 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 { /** @@ -82,17 +80,18 @@ public static PersonalizationValue ofPersonalization(String personalizationId) { * * @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) { - return new ExperimentValue(experimentId, variantValues); + 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(); @@ -102,7 +101,7 @@ static ParameterValue fromParameterValueResponse( // 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. + // the percent field since 0 is a valid value. double percent = 0; if (rv.getPercent() != null) { percent = rv.getPercent(); @@ -115,18 +114,26 @@ static ParameterValue fromParameterValueResponse( } 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()); - return ParameterValue.ofExperiment(ev.getExperimentId(), variantValues); + 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 { @@ -147,8 +154,7 @@ public String getValue() { @Override ParameterValueResponse toParameterValueResponse() { - return new ParameterValueResponse() - .setValue(this.value); + return new ParameterValueResponse().setValue(this.value); } @Override @@ -169,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 @@ -191,9 +195,7 @@ public boolean equals(Object o) { } } - /** - * Represents a Rollout value. - */ + /** Represents a Rollout value. */ public static final class RolloutValue extends ParameterValue { private final String rolloutId; private final String value; @@ -224,8 +226,8 @@ public String getValue() { } /** - * Gets the rollout percentage representing the exposure of rollout value - * in the target audience. + * Gets the rollout percentage representing the exposure of rollout value in the target + * audience. * * @return Percentage of audience exposed to the rollout */ @@ -235,11 +237,12 @@ public double getPercent() { @Override ParameterValueResponse toParameterValueResponse() { - return new ParameterValueResponse().setRolloutValue( + return new ParameterValueResponse() + .setRolloutValue( new RolloutValueResponse() - .setRolloutId(this.rolloutId) - .setValue(this.value) - .setPercent(this.percent)); + .setRolloutId(this.rolloutId) + .setValue(this.value) + .setPercent(this.percent)); } @Override @@ -252,8 +255,8 @@ public boolean equals(Object o) { } RolloutValue that = (RolloutValue) o; return Double.compare(that.percent, percent) == 0 - && Objects.equals(rolloutId, that.rolloutId) - && Objects.equals(value, that.value); + && Objects.equals(rolloutId, that.rolloutId) + && Objects.equals(value, that.value); } @Override @@ -262,9 +265,7 @@ public int hashCode() { } } - /** - * Represents a Personalization value. - */ + /** Represents a Personalization value. */ public static final class PersonalizationValue extends ParameterValue { private final String personalizationId; @@ -283,9 +284,9 @@ public String getPersonalizationId() { @Override ParameterValueResponse toParameterValueResponse() { - return new ParameterValueResponse().setPersonalizationValue( - new PersonalizationValueResponse() - .setPersonalizationId(this.personalizationId)); + return new ParameterValueResponse() + .setPersonalizationValue( + new PersonalizationValueResponse().setPersonalizationId(this.personalizationId)); } @Override @@ -306,9 +307,7 @@ public int hashCode() { } } - /** - * Represents a specific variant within an Experiment. - */ + /** Represents a specific variant within an Experiment. */ public static final class ExperimentVariantValue { private final String variantId; private final String value; @@ -384,8 +383,8 @@ public boolean equals(Object o) { } ExperimentVariantValue that = (ExperimentVariantValue) o; return noChange == that.noChange - && Objects.equals(variantId, that.variantId) - && Objects.equals(value, that.value); + && Objects.equals(variantId, that.variantId) + && Objects.equals(value, that.value); } @Override @@ -394,16 +393,17 @@ public int hashCode() { } } - /** - * Represents an Experiment value. - */ + /** 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) { + private ExperimentValue( + String experimentId, List variantValues, double exposurePercent) { this.experimentId = experimentId; this.variantValues = variantValues; + this.exposurePercent = exposurePercent; } /** @@ -415,6 +415,15 @@ 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. * @@ -426,16 +435,21 @@ public List getExperimentVariantValues() { @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( + 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)); + .setExperimentId(this.experimentId) + .setExperimentVariantValues(variantValueResponses) + .setExposurePercent(this.exposurePercent)); } @Override @@ -448,12 +462,13 @@ public boolean equals(Object o) { } ExperimentValue that = (ExperimentValue) o; return Objects.equals(experimentId, that.experimentId) - && Objects.equals(variantValues, that.variantValues); + && Objects.equals(variantValues, that.variantValues) + && Double.compare(that.exposurePercent, exposurePercent) == 0; } @Override public int hashCode() { - return Objects.hash(experimentId, variantValues); + 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 b92580f8c..32f5bcef9 100644 --- a/src/main/java/com/google/firebase/remoteconfig/internal/TemplateResponse.java +++ b/src/main/java/com/google/firebase/remoteconfig/internal/TemplateResponse.java @@ -288,6 +288,9 @@ public static final class ExperimentValueResponse { @Key("variantValue") private List experimentVariantValues; + @Key("exposurePercent") + private Double exposurePercent; + public String getExperimentId() { return experimentId; } @@ -296,6 +299,10 @@ public List getExperimentVariantValues() { return experimentVariantValues; } + public Double getExposurePercent() { + return exposurePercent; + } + public ExperimentValueResponse setExperimentId(String experimentId) { this.experimentId = experimentId; return this; @@ -306,6 +313,11 @@ public ExperimentValueResponse setExperimentVariantValues( this.experimentVariantValues = experimentVariantValues; return this; } + + public ExperimentValueResponse setExposurePercent(Double exposurePercent) { + this.exposurePercent = exposurePercent; + return this; + } } /** diff --git a/src/test/java/com/google/firebase/remoteconfig/ParameterValueTest.java b/src/test/java/com/google/firebase/remoteconfig/ParameterValueTest.java index 91f908bd3..46db5c44e 100644 --- a/src/test/java/com/google/firebase/remoteconfig/ParameterValueTest.java +++ b/src/test/java/com/google/firebase/remoteconfig/ParameterValueTest.java @@ -22,6 +22,8 @@ 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; public class ParameterValueTest { @@ -64,7 +66,11 @@ public void testCreateExperimentValue() { 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()); @@ -77,6 +83,7 @@ public void testCreateExperimentValue() { assertEquals("variant_2", variant2.getVariantId()); assertEquals(null, variant2.getValue()); assertEquals(true, variant2.isNoChange()); + } @Test @@ -116,22 +123,37 @@ public void testEquality() { 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); } From 2d7c8d924f458d4e1496e65e5d66e6bdc8e07bb9 Mon Sep 17 00:00:00 2001 From: varun rathore <35365856+rathovarun1032@users.noreply.github.com> Date: Wed, 13 May 2026 19:02:52 +0530 Subject: [PATCH 03/22] chore: Fix issue in the new RC unit tests (#1205) --- .../remoteconfig/ServerTemplateImpl.java | 2 +- .../remoteconfig/ServerTemplateImplTest.java | 24 +++++++++---------- 2 files changed, 12 insertions(+), 14 deletions(-) diff --git a/src/main/java/com/google/firebase/remoteconfig/ServerTemplateImpl.java b/src/main/java/com/google/firebase/remoteconfig/ServerTemplateImpl.java index 742c19803..633730bf3 100644 --- a/src/main/java/com/google/firebase/remoteconfig/ServerTemplateImpl.java +++ b/src/main/java/com/google/firebase/remoteconfig/ServerTemplateImpl.java @@ -75,7 +75,7 @@ private ServerTemplateImpl(Builder builder) { try { this.cache.set(ServerTemplateData.fromJSON(initialTemplate)); } catch (FirebaseRemoteConfigException e) { - e.printStackTrace(); + throw new IllegalArgumentException("Unable to parse JSON string.", e); } } diff --git a/src/test/java/com/google/firebase/remoteconfig/ServerTemplateImplTest.java b/src/test/java/com/google/firebase/remoteconfig/ServerTemplateImplTest.java index bafe84331..d7b761141 100644 --- a/src/test/java/com/google/firebase/remoteconfig/ServerTemplateImplTest.java +++ b/src/test/java/com/google/firebase/remoteconfig/ServerTemplateImplTest.java @@ -18,6 +18,7 @@ import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; import com.google.api.core.ApiFuture; import com.google.firebase.FirebaseApp; @@ -188,20 +189,17 @@ public void testEvaluateWithoutDefaultValueReturnsEmptyString() public void testEvaluateWithInvalidCacheValueThrowsException() throws FirebaseRemoteConfigException { KeysAndValues defaultConfig = new KeysAndValues.Builder().build(); - KeysAndValues context = new KeysAndValues.Builder().build(); String invalidJsonString = "abc"; - ServerTemplate template = - new ServerTemplateImpl.Builder(null) - .defaultConfig(defaultConfig) - .cachedTemplate(invalidJsonString) - .build(); - - FirebaseRemoteConfigException error = - assertThrows(FirebaseRemoteConfigException.class, () -> template.evaluate(context)); - - assertEquals( - "No Remote Config Server template in cache. Call load() before " + "calling evaluate().", - error.getMessage()); + IllegalArgumentException error = assertThrows( + IllegalArgumentException.class, + () -> new ServerTemplateImpl.Builder(null) + .defaultConfig(defaultConfig) + .cachedTemplate(invalidJsonString) + .build()); + + assertEquals("Unable to parse JSON string.", error.getMessage()); + // Verify the cause is the original FirebaseRemoteConfigException + assertTrue(error.getCause() instanceof FirebaseRemoteConfigException); } @Test From de3118480af9806b6b9ba880803885f576f56e27 Mon Sep 17 00:00:00 2001 From: Lahiru Maramba Date: Wed, 13 May 2026 12:44:23 -0400 Subject: [PATCH 04/22] feat(pnv): Add support for Firebase Phone Number Verification (#1203) * feat: add fpnv branch * feat: update pom file * chore: resolve comments * chore: resolve comments * feat: update code and tests * chore: resolve comments * feat: update FirebasePnvException * Update src/main/java/com/google/firebase/fpnv/FirebasePnvToken.java Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> * chore: resolve robot comments * chore: add test coverage * rename fpnv to fully qualified name * security: enforce project-specific issuer validation in PhoneNumberVerification * chore: handle both Date and Number type safely for token claims * style: wrap long lines to meet checkstyle line width limits * style: resolve checkstyle line length constraints across source and test files * docs: add AGENT.md outlining security and style rules for phone number verification * docs: replace specific agent file with general repo-wide AGENTS.md style rules * fix(remoteconfig): catch parsing runtime exceptions in fromJSON to stabilize headless pipelines * test(phonenumberverification): correct claims setup in wrong audience test * fix(phonenumberverification): use null-safe comparison for header algorithm * revert(remoteconfig): completely restore original state of Template.java * fix(phonenumberverification): replace deprecated URL constructor with URI.create().toURL() * fix(phonenumberverification): implement thread-safe lazy initialization for jwtProcessor to avoid constructor leak * docs(phonenumberverification): add descriptive javadoc to package-private constructor overload * refactor(phonenumberverification): use idiomatic checkNotNull and throw IllegalStateException for hardcoded configuration failures * refactor(phonenumberverification): align token constructor pre-conditions checks with idiomatic checkNotNull patterns * test(phonenumberverification): assert NullPointerException in verifier null claims test case * fix(phonenumberverification): add volatile keyword to jwtProcessor for strict double-checked locking memory safety * Refactor Phone Number Verification error handling to align with SDK standards * Update src/main/java/com/google/firebase/phonenumberverification/FirebasePhoneNumberVerification.java Co-authored-by: Kevin Cheung --------- Co-authored-by: Andrii Boiko Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> Co-authored-by: Kevin Cheung --- AGENTS.md | 33 ++ pom.xml | 5 + .../FirebasePhoneNumberVerification.java | 84 ++++ ...ebasePhoneNumberVerificationErrorCode.java | 48 ++ ...ebasePhoneNumberVerificationException.java | 51 ++ .../FirebasePhoneNumberVerificationToken.java | 102 ++++ ...ePhoneNumberVerificationTokenVerifier.java | 252 ++++++++++ ...ePhoneNumberVerificationErrorCodeTest.java | 32 ++ .../FirebasePhoneNumberVerificationTest.java | 170 +++++++ ...ebasePhoneNumberVerificationTokenTest.java | 104 ++++ ...neNumberVerificationTokenVerifierTest.java | 456 ++++++++++++++++++ 11 files changed, 1337 insertions(+) create mode 100644 AGENTS.md create mode 100644 src/main/java/com/google/firebase/phonenumberverification/FirebasePhoneNumberVerification.java create mode 100644 src/main/java/com/google/firebase/phonenumberverification/FirebasePhoneNumberVerificationErrorCode.java create mode 100644 src/main/java/com/google/firebase/phonenumberverification/FirebasePhoneNumberVerificationException.java create mode 100644 src/main/java/com/google/firebase/phonenumberverification/FirebasePhoneNumberVerificationToken.java create mode 100644 src/main/java/com/google/firebase/phonenumberverification/internal/FirebasePhoneNumberVerificationTokenVerifier.java create mode 100644 src/test/java/com/google/firebase/phonenumberverification/FirebasePhoneNumberVerificationErrorCodeTest.java create mode 100644 src/test/java/com/google/firebase/phonenumberverification/FirebasePhoneNumberVerificationTest.java create mode 100644 src/test/java/com/google/firebase/phonenumberverification/FirebasePhoneNumberVerificationTokenTest.java create mode 100644 src/test/java/com/google/firebase/phonenumberverification/internal/FirebasePhoneNumberVerificationTokenVerifierTest.java diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000..d25e006a6 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,33 @@ +# AI coding assistant guidelines: Firebase Admin Java SDK + +This document defines repository-wide expectations, code styles, and Checkstyle compliance metrics that every autonomous AI coding agent or pair programming assistant must strictly follow when introducing or modifying code in this repository. + +--- + +## 🎨 Code Style & Checkstyle Compliance + +This repository enforces standard **Google Java Style** guidelines validated through automated Maven checkstyle executions (`checkstyle.xml`). Any compilation or pull request build will fail if these constraints are violated. + +### 1. Strict 100-Character Line Limit +* **Rule:** No line of code, comments, Javadoc documentation entries, or inline string literal concatenations may exceed **100 characters** under any circumstances. +* **Remediation:** + * Break long method parameters, array initializations, and logic comparisons onto separate lines. + * Wrap long `assertThrows` statements or lambda chains across multiple contiguous lines. + * Format long log messages, exception messages, or assertion string explanations using standard string block concatenation broken across separate lines. + +### 2. Import Ordering & Grouping +* **Rule:** Imports must be grouped and arranged alphabetically to avoid validation noise. +* **Remediation:** + 1. Static imports placed first, grouped, and alphabetically arranged. + 2. Non-static imports grouped alphabetically by package tier. + 3. Avoid utilizing wildcard (`*`) imports. Every import declaration must be explicit. + +### 3. Javadoc Completeness & Formatting +* **Rule:** Every public class, package-private component interface, constructor, and public method signature must include fully formed Javadoc documentation blocks. +* **Remediation:** + * Document all arguments via `@param`, explain error flows via `@throws`, and clear return constraints via `@return`. + * Wrap documentation description text explicitly so that no single javadoc documentation line passes the 100 characters limit. + +### 4. Indentation & Spacing +* **Rule:** Standard indentation uses exactly **2 spaces** per block indentation level. **4 spaces** are used explicitly for wrapped line continuation indentation. +* **Remediation:** Never utilize tab characters. Ensure block braces follow the Google K&R opening line placement convention. diff --git a/pom.xml b/pom.xml index a642c1936..7aeebb7f9 100644 --- a/pom.xml +++ b/pom.xml @@ -446,6 +446,11 @@ httpclient5 5.3.1 + + com.nimbusds + nimbus-jose-jwt + 10.9 + diff --git a/src/main/java/com/google/firebase/phonenumberverification/FirebasePhoneNumberVerification.java b/src/main/java/com/google/firebase/phonenumberverification/FirebasePhoneNumberVerification.java new file mode 100644 index 000000000..6960f5038 --- /dev/null +++ b/src/main/java/com/google/firebase/phonenumberverification/FirebasePhoneNumberVerification.java @@ -0,0 +1,84 @@ +/* + * 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.phonenumberverification; + +import com.google.firebase.FirebaseApp; +import com.google.firebase.ImplFirebaseTrampolines; +import com.google.firebase.internal.FirebaseService; +import com.google.firebase.phonenumberverification.internal.FirebasePhoneNumberVerificationTokenVerifier; + +/** + * This class is the entry point for the Firebase Phone Number Verification service. + * + *

You can get an instance of {@link FirebasePhoneNumberVerification} via {@link #getInstance()}, + * or {@link #getInstance(FirebaseApp)}. + */ +public final class FirebasePhoneNumberVerification { + private static final String SERVICE_ID = FirebasePhoneNumberVerification.class.getName(); + private final FirebasePhoneNumberVerificationTokenVerifier tokenVerifier; + + private FirebasePhoneNumberVerification(FirebaseApp app) { + this.tokenVerifier = new FirebasePhoneNumberVerificationTokenVerifier(app); + } + + /** + * Gets the {@link FirebasePhoneNumberVerification} instance for the default {@link FirebaseApp}. + * + * @return The {@link FirebasePhoneNumberVerification} instance for the default + * {@link FirebaseApp}. + */ + public static FirebasePhoneNumberVerification getInstance() { + return getInstance(FirebaseApp.getInstance()); + } + + /** + * Gets the {@link FirebasePhoneNumberVerification} instance for the specified + * {@link FirebaseApp}. + * + * @return The {@link FirebasePhoneNumberVerification} instance for the specified + * {@link FirebaseApp}. + */ + public static synchronized FirebasePhoneNumberVerification getInstance(FirebaseApp app) { + FirebasePhoneNumberVerificationService service = + ImplFirebaseTrampolines.getService(app, SERVICE_ID, + FirebasePhoneNumberVerificationService.class); + if (service == null) { + service = ImplFirebaseTrampolines.addService( + app, new FirebasePhoneNumberVerificationService(app)); + } + return service.getInstance(); + } + + /** + * Verifies a Firebase Phone Number Verification token (JWT). + * + * @param phoneNumberVerificationJwt The JWT string to verify. + * @return A verified {@link FirebasePhoneNumberVerificationToken}. + * @throws FirebasePhoneNumberVerificationException If verification fails. + */ + public FirebasePhoneNumberVerificationToken verifyToken(String phoneNumberVerificationJwt) + throws FirebasePhoneNumberVerificationException { + return this.tokenVerifier.verifyToken(phoneNumberVerificationJwt); + } + + private static class FirebasePhoneNumberVerificationService + extends FirebaseService { + FirebasePhoneNumberVerificationService(FirebaseApp app) { + super(SERVICE_ID, new FirebasePhoneNumberVerification(app)); + } + } +} diff --git a/src/main/java/com/google/firebase/phonenumberverification/FirebasePhoneNumberVerificationErrorCode.java b/src/main/java/com/google/firebase/phonenumberverification/FirebasePhoneNumberVerificationErrorCode.java new file mode 100644 index 000000000..767f91c1e --- /dev/null +++ b/src/main/java/com/google/firebase/phonenumberverification/FirebasePhoneNumberVerificationErrorCode.java @@ -0,0 +1,48 @@ +/* + * 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.phonenumberverification; + +/** + * Error codes that can be raised by the Phone Number Verification APIs. + */ +public enum FirebasePhoneNumberVerificationErrorCode { + + /** + * One or more arguments specified in the request were invalid. + */ + INVALID_ARGUMENT, + + /** + * The provided phone number verification token is invalid or malformed. + */ + INVALID_TOKEN, + + /** + * The provided phone number verification token has expired. + */ + TOKEN_EXPIRED, + + /** + * Internal error encountered during phone number verification. + */ + INTERNAL_ERROR, + + /** + * Phone number verification service is temporarily unavailable. + */ + SERVICE_ERROR, +} diff --git a/src/main/java/com/google/firebase/phonenumberverification/FirebasePhoneNumberVerificationException.java b/src/main/java/com/google/firebase/phonenumberverification/FirebasePhoneNumberVerificationException.java new file mode 100644 index 000000000..c2e1ddfe7 --- /dev/null +++ b/src/main/java/com/google/firebase/phonenumberverification/FirebasePhoneNumberVerificationException.java @@ -0,0 +1,51 @@ +/* + * 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.phonenumberverification; + +import com.google.firebase.ErrorCode; +import com.google.firebase.FirebaseException; +import com.google.firebase.IncomingHttpResponse; +import com.google.firebase.internal.NonNull; +import com.google.firebase.internal.Nullable; + +/** + * Generic exception related to Firebase Phone Number Verification. Check the error code and message + * for more details. + */ +public class FirebasePhoneNumberVerificationException extends FirebaseException { + + private final FirebasePhoneNumberVerificationErrorCode errorCode; + + public FirebasePhoneNumberVerificationException( + @NonNull ErrorCode errorCode, + @NonNull String message, + Throwable cause, + IncomingHttpResponse response, + FirebasePhoneNumberVerificationErrorCode phoneErrorCode) { + super(errorCode, message, cause, response); + this.errorCode = phoneErrorCode; + } + + public FirebasePhoneNumberVerificationException(FirebaseException base) { + this(base.getErrorCode(), base.getMessage(), base.getCause(), base.getHttpResponse(), null); + } + + @Nullable + public FirebasePhoneNumberVerificationErrorCode getPhoneNumberVerificationErrorCode() { + return errorCode; + } +} diff --git a/src/main/java/com/google/firebase/phonenumberverification/FirebasePhoneNumberVerificationToken.java b/src/main/java/com/google/firebase/phonenumberverification/FirebasePhoneNumberVerificationToken.java new file mode 100644 index 000000000..15404cdc2 --- /dev/null +++ b/src/main/java/com/google/firebase/phonenumberverification/FirebasePhoneNumberVerificationToken.java @@ -0,0 +1,102 @@ +/* + * 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.phonenumberverification; + +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.common.collect.ImmutableMap; +import java.util.List; +import java.util.Map; + +/** + * Represents a verified Firebase Phone Number Verification token. + */ +public class FirebasePhoneNumberVerificationToken { + private final Map claims; + + /** + * Create an instance of {@link FirebasePhoneNumberVerificationToken} from a map of JWT claims. + * + * @param claims A map of JWT claims. + */ + public FirebasePhoneNumberVerificationToken(Map claims) { + checkNotNull(claims, "Claims map must not be null"); + checkArgument(claims.containsKey("sub"), "Claims map must contain sub"); + this.claims = ImmutableMap.copyOf(claims); + } + + /** + * Returns the issuer identifier for the issuer of the response. + */ + public String getIssuer() { + return (String) claims.get("iss"); + } + + /** + * Returns the phone number of the user. + * This corresponds to the 'sub' claim in the JWT. + */ + public String getPhoneNumber() { + return (String) claims.get("sub"); + } + + /** + * Returns the audience for which this token is intended. + */ + public List getAudience() { + Object audience = claims.get("aud"); + if (audience instanceof String) { + return ImmutableList.of((String) audience); + } else if (audience instanceof List) { + @SuppressWarnings("unchecked") + List audienceList = (List) audience; + return ImmutableList.copyOf(audienceList); + } + return ImmutableList.of(); + } + + /** + * Returns the expiration time in seconds since the Unix epoch. + */ + public long getExpirationTime() { + Object exp = claims.get("exp"); + if (exp instanceof java.util.Date) { + return ((java.util.Date) exp).getTime() / 1000L; + } + return exp instanceof Number ? ((Number) exp).longValue() : 0L; + } + + /** + * Returns the issued-at time in seconds since the Unix epoch. + */ + public long getIssuedAt() { + Object iat = claims.get("iat"); + if (iat instanceof java.util.Date) { + return ((java.util.Date) iat).getTime() / 1000L; + } + return iat instanceof Number ? ((Number) iat).longValue() : 0L; + } + + /** + * Returns the entire map of claims. + */ + public Map getClaims() { + return claims; + } +} diff --git a/src/main/java/com/google/firebase/phonenumberverification/internal/FirebasePhoneNumberVerificationTokenVerifier.java b/src/main/java/com/google/firebase/phonenumberverification/internal/FirebasePhoneNumberVerificationTokenVerifier.java new file mode 100644 index 000000000..c51c8f9bf --- /dev/null +++ b/src/main/java/com/google/firebase/phonenumberverification/internal/FirebasePhoneNumberVerificationTokenVerifier.java @@ -0,0 +1,252 @@ +/* + * 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.phonenumberverification.internal; + +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.ErrorCode; +import com.google.firebase.FirebaseApp; +import com.google.firebase.ImplFirebaseTrampolines; +import com.google.firebase.phonenumberverification.FirebasePhoneNumberVerificationErrorCode; +import com.google.firebase.phonenumberverification.FirebasePhoneNumberVerificationException; +import com.google.firebase.phonenumberverification.FirebasePhoneNumberVerificationToken; +import com.nimbusds.jose.JOSEException; +import com.nimbusds.jose.JOSEObjectType; +import com.nimbusds.jose.JWSAlgorithm; +import com.nimbusds.jose.JWSHeader; +import com.nimbusds.jose.jwk.source.JWKSource; +import com.nimbusds.jose.jwk.source.JWKSourceBuilder; +import com.nimbusds.jose.proc.BadJOSEException; +import com.nimbusds.jose.proc.JWSKeySelector; +import com.nimbusds.jose.proc.JWSVerificationKeySelector; +import com.nimbusds.jose.proc.SecurityContext; +import com.nimbusds.jwt.JWTClaimsSet; +import com.nimbusds.jwt.SignedJWT; +import com.nimbusds.jwt.proc.DefaultJWTProcessor; +import com.nimbusds.jwt.proc.ExpiredJWTException; +import java.net.MalformedURLException; +import java.net.URI; +import java.text.ParseException; + +/** + * Internal class to verify Firebase Phone Number Verification tokens. + */ +public class FirebasePhoneNumberVerificationTokenVerifier { + private static final String FPNV_JWKS_URL = "https://fpnv.googleapis.com/v1beta/jwks"; + private static final String HEADER_TYP = "JWT"; + + private final String projectId; + private volatile DefaultJWTProcessor jwtProcessor; + + /** + * Create {@link FirebasePhoneNumberVerificationTokenVerifier} for internal purposes. + * + * @param app The {@link FirebaseApp} to get a project ID from. + */ + public FirebasePhoneNumberVerificationTokenVerifier(FirebaseApp app) { + this.projectId = getProjectId(app); + } + + /** + * Package-private constructor designed explicitly for dependency injection + * during isolated unit testing flows. + */ + FirebasePhoneNumberVerificationTokenVerifier( + String projectId, DefaultJWTProcessor jwtProcessor) { + this.projectId = projectId; + this.jwtProcessor = jwtProcessor; + } + + private DefaultJWTProcessor getJwtProcessor() { + DefaultJWTProcessor processor = this.jwtProcessor; + if (processor == null) { + synchronized (this) { + processor = this.jwtProcessor; + if (processor == null) { + processor = createJwtProcessor(); + this.jwtProcessor = processor; + } + } + } + return processor; + } + + /** + * Main method that performs token verification steps. + * + * @param token String input data + * @return {@link FirebasePhoneNumberVerificationToken} + * @throws FirebasePhoneNumberVerificationException If verification fails + */ + public FirebasePhoneNumberVerificationToken verifyToken(String token) + throws FirebasePhoneNumberVerificationException { + checkArgument(!Strings.isNullOrEmpty(token), + "Firebase Phone Number Verification token must not be null or empty"); + + try { + SignedJWT signedJwt = SignedJWT.parse(token); + verifyHeader(signedJwt.getHeader()); + + JWTClaimsSet claims = getJwtProcessor().process(signedJwt, null); + verifyClaims(claims); + + return new FirebasePhoneNumberVerificationToken(claims.getClaims()); + } catch (ParseException e) { + throw newException( + FirebasePhoneNumberVerificationErrorCode.INVALID_TOKEN, + "Failed to parse JWT token: " + e.getMessage(), + e + ); + } catch (ExpiredJWTException e) { + throw newException( + FirebasePhoneNumberVerificationErrorCode.TOKEN_EXPIRED, + "Firebase Phone Number Verification token has expired.", + e + ); + } catch (BadJOSEException e) { + throw newException( + FirebasePhoneNumberVerificationErrorCode.INVALID_TOKEN, + "Check your project: " + projectId + ". " + + "Firebase Phone Number Verification token is invalid: " + + e.getMessage(), + e + ); + } catch (JOSEException e) { + throw newException( + FirebasePhoneNumberVerificationErrorCode.INTERNAL_ERROR, + "Check your project: " + projectId + ". Failed to verify " + + "Firebase Phone Number Verification token signature: " + e.getMessage(), + e + ); + } + } + + private void verifyHeader(JWSHeader header) throws FirebasePhoneNumberVerificationException { + if (!JWSAlgorithm.ES256.equals(header.getAlgorithm())) { + throw newException( + FirebasePhoneNumberVerificationErrorCode.INVALID_ARGUMENT, + "Firebase Phone Number Verification token has incorrect 'algorithm'. " + + "Expected " + JWSAlgorithm.ES256.getName() + " but got " + header.getAlgorithm()); + } + if (Strings.isNullOrEmpty(header.getKeyID())) { + throw newException( + FirebasePhoneNumberVerificationErrorCode.INVALID_ARGUMENT, + "Firebase Phone Number Verification token has no 'kid' claim." + ); + } + if (!JOSEObjectType.JWT.equals(header.getType())) { + throw newException( + FirebasePhoneNumberVerificationErrorCode.INVALID_ARGUMENT, + "Firebase Phone Number Verification token has incorrect 'typ'. Expected " + HEADER_TYP + + " but got " + header.getType() + ); + } + } + + private void verifyClaims(JWTClaimsSet claims) throws FirebasePhoneNumberVerificationException { + checkNotNull(claims, "JWTClaimsSet claims must not be null"); + String issuer = claims.getIssuer(); + + if (Strings.isNullOrEmpty(issuer)) { + throw newException( + FirebasePhoneNumberVerificationErrorCode.INVALID_ARGUMENT, + "Firebase Phone Number Verification token has no 'iss' (issuer) claim."); + } + + String expectedIssuer = "https://fpnv.googleapis.com/projects/" + this.projectId; + if (!expectedIssuer.equals(issuer)) { + throw newException( + FirebasePhoneNumberVerificationErrorCode.INVALID_TOKEN, + "Firebase Phone Number Verification token has an incorrect 'iss' (issuer) claim."); + } + + if (claims.getAudience().isEmpty() || !claims.getAudience().contains(issuer)) { + throw newException( + FirebasePhoneNumberVerificationErrorCode.INVALID_TOKEN, + "Invalid audience. Expected to contain: " + issuer + + " but found: " + claims.getAudience() + ); + } + + if (Strings.isNullOrEmpty(claims.getSubject())) { + throw newException( + FirebasePhoneNumberVerificationErrorCode.INVALID_TOKEN, + "Token has an empty 'sub' (phone number)." + ); + } + } + + private DefaultJWTProcessor createJwtProcessor() { + DefaultJWTProcessor processor = new DefaultJWTProcessor<>(); + try { + JWKSource keySource = createKeySource(); + JWSKeySelector keySelector = + new JWSVerificationKeySelector<>(JWSAlgorithm.ES256, keySource); + processor.setJWSKeySelector(keySelector); + } catch (MalformedURLException e) { + throw new IllegalStateException("Invalid JWKS URL", e); + } + return processor; + } + + protected JWKSource createKeySource() throws MalformedURLException { + return JWKSourceBuilder + .create(URI.create(FPNV_JWKS_URL).toURL()) + .retrying(true) + .build(); + } + + private String getProjectId(FirebaseApp app) { + String projectId = ImplFirebaseTrampolines.getProjectId(app); + if (Strings.isNullOrEmpty(projectId)) { + throw new IllegalArgumentException("Project ID is required in FirebaseOptions."); + } + return projectId; + } + + private FirebasePhoneNumberVerificationException newException( + FirebasePhoneNumberVerificationErrorCode errorCode, String message) { + return newException(errorCode, message, null); + } + + private FirebasePhoneNumberVerificationException newException( + FirebasePhoneNumberVerificationErrorCode errorCode, String message, Throwable cause) { + ErrorCode baseCode = ErrorCode.INTERNAL; + if (errorCode != null) { + switch (errorCode) { + case INVALID_ARGUMENT: + baseCode = ErrorCode.INVALID_ARGUMENT; + break; + case TOKEN_EXPIRED: + case INVALID_TOKEN: + baseCode = ErrorCode.UNAUTHENTICATED; + break; + case SERVICE_ERROR: + baseCode = ErrorCode.UNAVAILABLE; + break; + case INTERNAL_ERROR: + default: + baseCode = ErrorCode.INTERNAL; + break; + } + } + return new FirebasePhoneNumberVerificationException( + baseCode, message, cause, null, errorCode); + } +} diff --git a/src/test/java/com/google/firebase/phonenumberverification/FirebasePhoneNumberVerificationErrorCodeTest.java b/src/test/java/com/google/firebase/phonenumberverification/FirebasePhoneNumberVerificationErrorCodeTest.java new file mode 100644 index 000000000..c9f02ac23 --- /dev/null +++ b/src/test/java/com/google/firebase/phonenumberverification/FirebasePhoneNumberVerificationErrorCodeTest.java @@ -0,0 +1,32 @@ +/* + * 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.phonenumberverification; + +import static org.junit.Assert.assertNotNull; + +import org.junit.Test; + +public class FirebasePhoneNumberVerificationErrorCodeTest { + @Test + public void testEnum() { + assertNotNull(FirebasePhoneNumberVerificationErrorCode.valueOf("INVALID_ARGUMENT")); + assertNotNull(FirebasePhoneNumberVerificationErrorCode.valueOf("INVALID_TOKEN")); + assertNotNull(FirebasePhoneNumberVerificationErrorCode.valueOf("TOKEN_EXPIRED")); + assertNotNull(FirebasePhoneNumberVerificationErrorCode.valueOf("INTERNAL_ERROR")); + assertNotNull(FirebasePhoneNumberVerificationErrorCode.valueOf("SERVICE_ERROR")); + } +} diff --git a/src/test/java/com/google/firebase/phonenumberverification/FirebasePhoneNumberVerificationTest.java b/src/test/java/com/google/firebase/phonenumberverification/FirebasePhoneNumberVerificationTest.java new file mode 100644 index 000000000..e0a2a4f85 --- /dev/null +++ b/src/test/java/com/google/firebase/phonenumberverification/FirebasePhoneNumberVerificationTest.java @@ -0,0 +1,170 @@ +/* + * 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.phonenumberverification; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertThrows; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.google.firebase.ErrorCode; +import com.google.firebase.FirebaseApp; +import com.google.firebase.FirebaseOptions; +import com.google.firebase.TestOnlyImplFirebaseTrampolines; +import com.google.firebase.internal.FirebaseProcessEnvironment; +import com.google.firebase.phonenumberverification.internal.FirebasePhoneNumberVerificationTokenVerifier; +import com.google.firebase.testing.ServiceAccount; +import com.google.firebase.testing.TestUtils; +import java.lang.reflect.Field; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; + +public class FirebasePhoneNumberVerificationTest { + private static final FirebaseOptions firebaseOptions = FirebaseOptions.builder() + .setCredentials(TestUtils.getCertCredential(ServiceAccount.OWNER.asStream())) + .build(); + + @Mock + private FirebasePhoneNumberVerificationTokenVerifier mockVerifier; + + private FirebasePhoneNumberVerification firebasePhoneNumberVerification; + + @Before + public void setUp() throws Exception { + MockitoAnnotations.openMocks(this); + + FirebaseApp.initializeApp(firebaseOptions); + firebasePhoneNumberVerification = FirebasePhoneNumberVerification.getInstance(); + + Field verifierField = FirebasePhoneNumberVerification.class.getDeclaredField("tokenVerifier"); + verifierField.setAccessible(true); + verifierField.set(firebasePhoneNumberVerification, mockVerifier); + } + + @After + public void tearDown() { + FirebaseProcessEnvironment.clearCache(); + TestOnlyImplFirebaseTrampolines.clearInstancesForTest(); + } + + @Test + public void testGetInstance() { + FirebasePhoneNumberVerification instance = FirebasePhoneNumberVerification.getInstance(); + assertNotNull(instance); + assertSame(instance, FirebasePhoneNumberVerification.getInstance()); + } + + @Test + public void testGetInstanceForApp() { + FirebaseApp app = FirebaseApp.initializeApp(firebaseOptions, "testGetInstanceForApp"); + FirebasePhoneNumberVerification instance = FirebasePhoneNumberVerification.getInstance(app); + assertNotNull(instance); + assertSame(instance, FirebasePhoneNumberVerification.getInstance(app)); + } + + @Test + public void testVerifyToken_DelegatesToVerifier() + throws FirebasePhoneNumberVerificationException { + String testToken = "test.token"; + FirebasePhoneNumberVerificationToken expectedToken = + mock(FirebasePhoneNumberVerificationToken.class); + + when(mockVerifier.verifyToken(testToken)).thenReturn(expectedToken); + + FirebasePhoneNumberVerificationToken result = + firebasePhoneNumberVerification.verifyToken(testToken); + + assertEquals(expectedToken, result); + verify(mockVerifier, times(1)).verifyToken(testToken); + } + + @Test + public void testVerifyToken_PropagatesException() + throws FirebasePhoneNumberVerificationException { + String testToken = "bad.token"; + FirebasePhoneNumberVerificationException error = + new FirebasePhoneNumberVerificationException( + ErrorCode.UNAUTHENTICATED, + "Bad token", + null, + null, + FirebasePhoneNumberVerificationErrorCode.INVALID_TOKEN + ); + + when(mockVerifier.verifyToken(testToken)).thenThrow(error); + + FirebasePhoneNumberVerificationException e = + assertThrows(FirebasePhoneNumberVerificationException.class, () -> + FirebasePhoneNumberVerification.getInstance().verifyToken(testToken) + ); + assertEquals(FirebasePhoneNumberVerificationErrorCode.INVALID_TOKEN, + e.getPhoneNumberVerificationErrorCode()); + } + + @Test + public void testVerifyToken_PropagatesException_Service_Error() + throws FirebasePhoneNumberVerificationException { + String testToken = "SERVICE_ERROR"; + FirebasePhoneNumberVerificationException error = + new FirebasePhoneNumberVerificationException( + ErrorCode.UNAVAILABLE, + "SERVICE_ERROR", + null, + null, + FirebasePhoneNumberVerificationErrorCode.SERVICE_ERROR + ); + + when(mockVerifier.verifyToken(testToken)).thenThrow(error); + + FirebasePhoneNumberVerificationException e = + assertThrows(FirebasePhoneNumberVerificationException.class, () -> + FirebasePhoneNumberVerification.getInstance().verifyToken(testToken) + ); + assertEquals(FirebasePhoneNumberVerificationErrorCode.SERVICE_ERROR, + e.getPhoneNumberVerificationErrorCode()); + } + + @Test + public void testVerifyToken_PropagatesException_Internal_Error() + throws FirebasePhoneNumberVerificationException { + String testToken = "INTERNAL"; + FirebasePhoneNumberVerificationException error = + new FirebasePhoneNumberVerificationException( + ErrorCode.INTERNAL, + "INTERNAL", + null, + null, + null + ); + + when(mockVerifier.verifyToken(testToken)).thenThrow(error); + + FirebasePhoneNumberVerificationException e = + assertThrows(FirebasePhoneNumberVerificationException.class, () -> + FirebasePhoneNumberVerification.getInstance().verifyToken(testToken) + ); + assertNull(e.getPhoneNumberVerificationErrorCode()); + } +} diff --git a/src/test/java/com/google/firebase/phonenumberverification/FirebasePhoneNumberVerificationTokenTest.java b/src/test/java/com/google/firebase/phonenumberverification/FirebasePhoneNumberVerificationTokenTest.java new file mode 100644 index 000000000..3b6ad77b8 --- /dev/null +++ b/src/test/java/com/google/firebase/phonenumberverification/FirebasePhoneNumberVerificationTokenTest.java @@ -0,0 +1,104 @@ +/* + * 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.phonenumberverification; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +import com.google.common.collect.ImmutableList; +import com.google.firebase.TestOnlyImplFirebaseTrampolines; +import com.google.firebase.internal.FirebaseProcessEnvironment; +import com.nimbusds.jwt.JWTClaimsSet; +import java.util.Date; +import java.util.HashMap; +import java.util.Map; +import org.junit.After; +import org.junit.Test; + +public class FirebasePhoneNumberVerificationTokenTest { + private static final String PROJECT_ID = "mock-project-id-1"; + private static final String ISSUER = "https://fpnv.googleapis.com/projects/" + PROJECT_ID; + private final String subject = "+15551234567"; + + @After + public void tearDown() { + FirebaseProcessEnvironment.clearCache(); + TestOnlyImplFirebaseTrampolines.clearInstancesForTest(); + } + + @Test + public void test_Audience_Empty() { + JWTClaimsSet claims = new JWTClaimsSet.Builder() + .issuer(ISSUER) + .subject(subject) + .expirationTime(new Date(System.currentTimeMillis() + 10000)) + .build(); + + FirebasePhoneNumberVerificationToken token = + new FirebasePhoneNumberVerificationToken(claims.getClaims()); + + assertNotNull(token); + assertEquals(ImmutableList.of(), token.getAudience()); + } + + @Test + public void test_Audience_List() { + JWTClaimsSet claims = new JWTClaimsSet.Builder() + .issuer(ISSUER) + .subject(subject) + .audience(ImmutableList.of()) + .expirationTime(new Date(System.currentTimeMillis() + 10000)) + .build(); + + FirebasePhoneNumberVerificationToken token = + new FirebasePhoneNumberVerificationToken(claims.getClaims()); + + assertNotNull(token); + assertEquals(ImmutableList.of(), token.getAudience()); + } + + @Test + public void test_Audience_String() { + Map claims = new HashMap<>(); + claims.put("sub", subject); + claims.put("aud", ISSUER); + + FirebasePhoneNumberVerificationToken token = new FirebasePhoneNumberVerificationToken(claims); + + assertNotNull(token); + assertEquals(ImmutableList.of(ISSUER), token.getAudience()); + } + + @Test + public void test_No_Sub() { + Map claims = new HashMap<>(); + IllegalArgumentException e = assertThrows(IllegalArgumentException.class, () -> + new FirebasePhoneNumberVerificationToken(claims) + ); + assertTrue(e.getMessage().contains("Claims map must contain sub")); + } + + @Test + public void test_Null_Sub() { + NullPointerException e = assertThrows(NullPointerException.class, () -> + new FirebasePhoneNumberVerificationToken(null) + ); + assertEquals("Claims map must not be null", e.getMessage()); + } +} diff --git a/src/test/java/com/google/firebase/phonenumberverification/internal/FirebasePhoneNumberVerificationTokenVerifierTest.java b/src/test/java/com/google/firebase/phonenumberverification/internal/FirebasePhoneNumberVerificationTokenVerifierTest.java new file mode 100644 index 000000000..fa4ca8116 --- /dev/null +++ b/src/test/java/com/google/firebase/phonenumberverification/internal/FirebasePhoneNumberVerificationTokenVerifierTest.java @@ -0,0 +1,456 @@ +/* + * 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.phonenumberverification.internal; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.when; + +import com.google.auth.oauth2.GoogleCredentials; +import com.google.firebase.FirebaseApp; +import com.google.firebase.FirebaseOptions; +import com.google.firebase.TestOnlyImplFirebaseTrampolines; +import com.google.firebase.internal.FirebaseProcessEnvironment; +import com.google.firebase.phonenumberverification.FirebasePhoneNumberVerificationErrorCode; +import com.google.firebase.phonenumberverification.FirebasePhoneNumberVerificationException; +import com.google.firebase.phonenumberverification.FirebasePhoneNumberVerificationToken; +import com.google.firebase.testing.ServiceAccount; +import com.google.firebase.testing.TestUtils; +import com.nimbusds.jose.JOSEException; +import com.nimbusds.jose.JOSEObjectType; +import com.nimbusds.jose.JWSAlgorithm; +import com.nimbusds.jose.JWSHeader; +import com.nimbusds.jose.crypto.ECDSASigner; +import com.nimbusds.jose.crypto.MACSigner; +import com.nimbusds.jose.crypto.RSASSASigner; +import com.nimbusds.jose.jwk.Curve; +import com.nimbusds.jose.jwk.ECKey; +import com.nimbusds.jose.jwk.gen.ECKeyGenerator; +import com.nimbusds.jose.proc.BadJOSEException; +import com.nimbusds.jose.proc.SecurityContext; +import com.nimbusds.jwt.JWTClaimsSet; +import com.nimbusds.jwt.SignedJWT; +import com.nimbusds.jwt.proc.DefaultJWTProcessor; +import com.nimbusds.jwt.proc.ExpiredJWTException; +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.net.MalformedURLException; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.util.Arrays; +import java.util.Collections; +import java.util.Date; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; + +public class FirebasePhoneNumberVerificationTokenVerifierTest { + private static final String PROJECT_ID = "mock-project-id-2"; + private static final FirebaseOptions firebaseOptions = FirebaseOptions.builder() + .setProjectId(PROJECT_ID) + .setCredentials(TestUtils.getCertCredential(ServiceAccount.OWNER.asStream())) + .build(); + private static final String ISSUER = "https://fpnv.googleapis.com/projects/" + PROJECT_ID; + private static final String[] AUD = new String[]{ + ISSUER, + "https://google.com/projects/" + }; + + @Mock + private DefaultJWTProcessor mockJwtProcessor; + + private FirebasePhoneNumberVerificationTokenVerifier verifier; + private KeyPair rsaKeyPair; + private ECKey ecKey; + private JWSHeader header; + private JWTClaimsSet claims; + private final String subject = "+15551234567"; + private final Date issueTime = new Date(); + private final Date expirationTime = new Date(System.currentTimeMillis() + 10000); + + @Before + public void setUp() throws Exception { + MockitoAnnotations.openMocks(this); + + KeyPairGenerator gen = KeyPairGenerator.getInstance("RSA"); + gen.initialize(2048); + rsaKeyPair = gen.generateKeyPair(); + + ecKey = new ECKeyGenerator(Curve.P_256).keyID("ec-key-id").generate(); + + FirebaseApp firebaseApp = FirebaseApp.initializeApp(firebaseOptions); + verifier = new FirebasePhoneNumberVerificationTokenVerifier(firebaseApp); + + Field processorField = FirebasePhoneNumberVerificationTokenVerifier.class + .getDeclaredField("jwtProcessor"); + processorField.setAccessible(true); + processorField.set(verifier, mockJwtProcessor); + + header = new JWSHeader.Builder(JWSAlgorithm.ES256) + .keyID(ecKey.getKeyID()) + .type(JOSEObjectType.JWT) + .build(); + + claims = new JWTClaimsSet.Builder() + .issuer(ISSUER) + .audience(Arrays.asList(AUD)) + .subject(subject) + .issueTime(issueTime) + .expirationTime(expirationTime) + .build(); + } + + @After + public void tearDown() { + FirebaseProcessEnvironment.clearCache(); + TestOnlyImplFirebaseTrampolines.clearInstancesForTest(); + } + + private String createToken(JWSHeader header, JWTClaimsSet claims) throws Exception { + SignedJWT jwt = new SignedJWT(header, claims); + + if (JWSAlgorithm.RS256.equals(header.getAlgorithm())) { + jwt.sign(new RSASSASigner(rsaKeyPair.getPrivate())); + } else if (JWSAlgorithm.HS256.equals(header.getAlgorithm())) { + jwt.sign(new MACSigner("12345678901234567890123456789012")); + } else if (JWSAlgorithm.ES256.equals(header.getAlgorithm())) { + jwt.sign(new ECDSASigner(ecKey.toECPrivateKey())); + } + + return jwt.serialize(); + } + + @Test + public void testVerifyToken_NullOrEmptyToken() { + IllegalArgumentException e = + assertThrows(IllegalArgumentException.class, () -> verifier.verifyToken("")); + assertTrue(e.getMessage().contains( + "Firebase Phone Number Verification token must not be null")); + } + + @Test + public void testVerifyToken_Success() throws Exception { + String tokenString = createToken(header, claims); + + when(mockJwtProcessor.process(any(SignedJWT.class), any())).thenReturn(claims); + + FirebasePhoneNumberVerificationToken result = verifier.verifyToken(tokenString); + + assertNotNull(result); + assertEquals(subject, result.getPhoneNumber()); + assertEquals(issueTime.getTime() / 1000L, result.getIssuedAt()); + assertEquals(expirationTime.getTime() / 1000L, result.getExpirationTime()); + assertEquals(Arrays.asList(AUD), result.getAudience()); + assertEquals(ISSUER, result.getIssuer()); + assertEquals(ISSUER, result.getClaims().get("iss")); + } + + @Test + public void testVerifyToken_Header_WrongAlgorithm() throws Exception { + JWSHeader header = new JWSHeader.Builder(JWSAlgorithm.HS256).build(); + JWTClaimsSet claims = new JWTClaimsSet.Builder().build(); + + String tokenString = createToken(header, claims); + + FirebasePhoneNumberVerificationException e = + assertThrows(FirebasePhoneNumberVerificationException.class, () -> + verifier.verifyToken(tokenString) + ); + + assertEquals(FirebasePhoneNumberVerificationErrorCode.INVALID_ARGUMENT, + e.getPhoneNumberVerificationErrorCode()); + assertTrue(e.getMessage().contains("algorithm")); + } + + @Test + public void testVerifyToken_Header_WrongTyp() throws Exception { + JWSHeader header = new JWSHeader.Builder(JWSAlgorithm.ES256) + .keyID(ecKey.getKeyID()) + .type(JOSEObjectType.JOSE) + .build(); + JWTClaimsSet claims = new JWTClaimsSet.Builder().build(); + + String tokenString = createToken(header, claims); + + FirebasePhoneNumberVerificationException e = + assertThrows(FirebasePhoneNumberVerificationException.class, () -> + verifier.verifyToken(tokenString) + ); + + assertEquals(FirebasePhoneNumberVerificationErrorCode.INVALID_ARGUMENT, + e.getPhoneNumberVerificationErrorCode()); + assertTrue(e.getMessage().contains("has incorrect 'typ'")); + } + + @Test + public void testVerifyToken_Header_MissingKeyId() throws Exception { + JWSHeader header = new JWSHeader.Builder(JWSAlgorithm.ES256).build(); + JWTClaimsSet claims = new JWTClaimsSet.Builder().build(); + + String tokenString = createToken(header, claims); + + FirebasePhoneNumberVerificationException e = + assertThrows(FirebasePhoneNumberVerificationException.class, () -> + verifier.verifyToken(tokenString) + ); + + assertEquals(FirebasePhoneNumberVerificationErrorCode.INVALID_ARGUMENT, + e.getPhoneNumberVerificationErrorCode()); + assertTrue(e.getMessage().contains( + "Firebase Phone Number Verification token has no 'kid' claim")); + } + + @Test + public void testVerifyToken_Claims_Null() throws Exception { + JWTClaimsSet noSubClaims = new JWTClaimsSet.Builder().build(); + + String tokenString = createToken(header, noSubClaims); + when(mockJwtProcessor.process(any(SignedJWT.class), any())).thenReturn(null); + + NullPointerException e = assertThrows(NullPointerException.class, () -> + verifier.verifyToken(tokenString) + ); + + assertTrue(e.getMessage().contains("JWTClaimsSet claims must not be null")); + } + + @Test + public void testVerifyToken_Claims_NoIssuer() throws Exception { + JWTClaimsSet claims = new JWTClaimsSet.Builder() + .audience(ISSUER) + .expirationTime(new Date(System.currentTimeMillis() + 10000)) + .build(); + + String tokenString = createToken(header, claims); + when(mockJwtProcessor.process(any(SignedJWT.class), any())).thenReturn(claims); + + FirebasePhoneNumberVerificationException e = + assertThrows(FirebasePhoneNumberVerificationException.class, () -> + verifier.verifyToken(tokenString) + ); + + assertEquals(FirebasePhoneNumberVerificationErrorCode.INVALID_ARGUMENT, + e.getPhoneNumberVerificationErrorCode()); + assertTrue(e.getMessage().contains( + "Firebase Phone Number Verification token has no 'iss' (issuer) claim.")); + } + + @Test + public void testVerifyToken_Claims_Expired() throws Exception { + JWTClaimsSet claims = new JWTClaimsSet.Builder() + .issuer(ISSUER) + .audience(ISSUER) + .subject("+1555") + .expirationTime(new Date(System.currentTimeMillis() + 10000)) + .build(); + + String tokenString = createToken(header, claims); + ExpiredJWTException error = new ExpiredJWTException("Bad token"); + + when(mockJwtProcessor.process(any(SignedJWT.class), any())).thenThrow(error); + + FirebasePhoneNumberVerificationException e = + assertThrows(FirebasePhoneNumberVerificationException.class, () -> + verifier.verifyToken(tokenString) + ); + + assertEquals(FirebasePhoneNumberVerificationErrorCode.TOKEN_EXPIRED, + e.getPhoneNumberVerificationErrorCode()); + } + + @Test + public void testVerifyToken_Claims_WrongAudience() throws Exception { + JWTClaimsSet badClaims = new JWTClaimsSet.Builder() + .issuer(ISSUER) + .audience("https://wrong-audience.com") + .subject(subject) + .expirationTime(new Date(System.currentTimeMillis() + 10000)) + .build(); + + String tokenString = createToken(header, badClaims); + when(mockJwtProcessor.process(any(SignedJWT.class), any())).thenReturn(badClaims); + + FirebasePhoneNumberVerificationException e = + assertThrows(FirebasePhoneNumberVerificationException.class, () -> + verifier.verifyToken(tokenString) + ); + + assertEquals(FirebasePhoneNumberVerificationErrorCode.INVALID_TOKEN, + e.getPhoneNumberVerificationErrorCode()); + assertTrue(e.getMessage().contains("Invalid audience.")); + } + + @Test + public void testVerifyToken_Claims_EmptyAudience() throws Exception { + JWTClaimsSet badClaims = new JWTClaimsSet.Builder() + .issuer(ISSUER) + .audience(Collections.emptyList()) + .subject(subject) + .expirationTime(new Date(System.currentTimeMillis() + 10000)) + .build(); + + String tokenString = createToken(header, badClaims); + when(mockJwtProcessor.process(any(SignedJWT.class), any())).thenReturn(badClaims); + + FirebasePhoneNumberVerificationException e = + assertThrows(FirebasePhoneNumberVerificationException.class, () -> + verifier.verifyToken(tokenString) + ); + + assertEquals(FirebasePhoneNumberVerificationErrorCode.INVALID_TOKEN, + e.getPhoneNumberVerificationErrorCode()); + assertTrue(e.getMessage().contains("Invalid audience. Expected to contain: ")); + } + + @Test + public void testVerifyToken_Claims_NoSubject() throws Exception { + JWTClaimsSet noSubClaims = new JWTClaimsSet.Builder() + .issuer(ISSUER) + .audience(ISSUER) + .expirationTime(new Date(System.currentTimeMillis() + 10000)) + .build(); + + String tokenString = createToken(header, noSubClaims); + when(mockJwtProcessor.process(any(SignedJWT.class), any())).thenReturn(noSubClaims); + + FirebasePhoneNumberVerificationException e = + assertThrows(FirebasePhoneNumberVerificationException.class, () -> + verifier.verifyToken(tokenString) + ); + + assertEquals(FirebasePhoneNumberVerificationErrorCode.INVALID_TOKEN, + e.getPhoneNumberVerificationErrorCode()); + assertTrue(e.getMessage().contains("Token has an empty 'sub' (phone number)")); + } + + @Test + public void testVerifyToken_ParseException() { + FirebasePhoneNumberVerificationException e = + assertThrows(FirebasePhoneNumberVerificationException.class, () -> + verifier.verifyToken(" ") + ); + assertEquals(FirebasePhoneNumberVerificationErrorCode.INVALID_TOKEN, + e.getPhoneNumberVerificationErrorCode()); + assertTrue(e.getMessage().contains("Failed to parse JWT token")); + } + + @Test + public void testVerifyToken_BadJOSEException() throws Exception { + String tokenString = createToken(header, claims); + String errorMessage = "BadJOSEException"; + BadJOSEException error = new BadJOSEException(errorMessage); + + when(mockJwtProcessor.process(any(SignedJWT.class), any())).thenThrow(error); + + FirebasePhoneNumberVerificationException e = + assertThrows(FirebasePhoneNumberVerificationException.class, () -> + verifier.verifyToken(tokenString) + ); + + assertEquals(FirebasePhoneNumberVerificationErrorCode.INVALID_TOKEN, + e.getPhoneNumberVerificationErrorCode()); + assertTrue(e.getMessage().contains("Firebase Phone Number Verification token is invalid:")); + } + + @Test + public void testVerifyToken_JOSEException() throws Exception { + String tokenString = createToken(header, claims); + String errorMessage = "JOSEException"; + JOSEException error = new JOSEException(errorMessage); + + when(mockJwtProcessor.process(any(SignedJWT.class), any())).thenThrow(error); + + FirebasePhoneNumberVerificationException e = + assertThrows(FirebasePhoneNumberVerificationException.class, () -> + verifier.verifyToken(tokenString) + ); + + assertEquals(FirebasePhoneNumberVerificationErrorCode.INTERNAL_ERROR, + e.getPhoneNumberVerificationErrorCode()); + assertTrue(e.getMessage().contains( + "Failed to verify Firebase Phone Number Verification token signature:")); + } + + @Test + public void testVerifierWithoutProjectId() { + FirebaseOptions localFirebaseOptions = FirebaseOptions.builder() + .setCredentials(GoogleCredentials.create(null)) + .build(); + + FirebaseApp firebaseApp = FirebaseApp.initializeApp(localFirebaseOptions, "second"); + + IllegalArgumentException e = + assertThrows(IllegalArgumentException.class, () -> + new FirebasePhoneNumberVerificationTokenVerifier(firebaseApp) + ); + + assertEquals("Project ID is required in FirebaseOptions.", e.getMessage()); + } + + @Test + public void testCreateJwtProcessor_HandlesException() throws Exception { + FirebaseApp firebaseApp = FirebaseApp.initializeApp(firebaseOptions, "third"); + FirebasePhoneNumberVerificationTokenVerifier original = + new FirebasePhoneNumberVerificationTokenVerifier(firebaseApp); + FirebasePhoneNumberVerificationTokenVerifier spyClass = spy(original); + + doThrow(new MalformedURLException("Simulated bad URL")) + .when(spyClass).createKeySource(); + + Method method = FirebasePhoneNumberVerificationTokenVerifier.class + .getDeclaredMethod("createJwtProcessor"); + method.setAccessible(true); + + try { + method.invoke(spyClass); + } catch (Exception e) { + Throwable cause = e.getCause(); + assertEquals(IllegalStateException.class, cause.getClass()); + assertEquals("Invalid JWKS URL", cause.getMessage()); + assertTrue(cause.getCause() instanceof MalformedURLException); + } + } + + @Test + public void testVerifyToken_Claims_InvalidIssuerProject() throws Exception { + JWTClaimsSet badIssuerClaims = new JWTClaimsSet.Builder() + .issuer("https://fpnv.googleapis.com/projects/attacker-project-id") + .audience("https://fpnv.googleapis.com/projects/attacker-project-id") + .subject(subject) + .expirationTime(new Date(System.currentTimeMillis() + 10000)) + .build(); + + String tokenString = createToken(header, badIssuerClaims); + when(mockJwtProcessor.process(any(SignedJWT.class), any())).thenReturn(badIssuerClaims); + + FirebasePhoneNumberVerificationException e = + assertThrows(FirebasePhoneNumberVerificationException.class, () -> + verifier.verifyToken(tokenString) + ); + + assertEquals(FirebasePhoneNumberVerificationErrorCode.INVALID_TOKEN, + e.getPhoneNumberVerificationErrorCode()); + assertTrue(e.getMessage().contains("incorrect 'iss' (issuer) claim")); + } +} From 62c5bb6e913730e3d365d6f1776c682b66bce0e0 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 13 May 2026 12:55:44 -0400 Subject: [PATCH 05/22] chore(deps): Bump io.netty:netty-codec-http (#1204) Bumps [io.netty:netty-codec-http](https://github.com/netty/netty) from 4.2.10.Final to 4.2.13.Final. - [Release notes](https://github.com/netty/netty/releases) - [Commits](https://github.com/netty/netty/compare/netty-4.2.10.Final...netty-4.2.13.Final) --- updated-dependencies: - dependency-name: io.netty:netty-codec-http dependency-version: 4.2.13.Final dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index 7aeebb7f9..d27d01a77 100644 --- a/pom.xml +++ b/pom.xml @@ -59,7 +59,7 @@ UTF-8 UTF-8 ${skipTests} - 4.2.10.Final + 4.2.13.Final From e471ee1fe449888b8d270cba5f1222ad3804781f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 13 May 2026 13:02:53 -0400 Subject: [PATCH 06/22] chore(deps): Bump com.google.cloud:libraries-bom from 26.76.0 to 26.77.0 (#1193) Bumps [com.google.cloud:libraries-bom](https://github.com/googleapis/java-cloud-bom) from 26.76.0 to 26.77.0. - [Release notes](https://github.com/googleapis/java-cloud-bom/releases) - [Commits](https://github.com/googleapis/java-cloud-bom/compare/v26.76.0...v26.77.0) --- updated-dependencies: - dependency-name: com.google.cloud:libraries-bom dependency-version: 26.77.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Lahiru Maramba --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index d27d01a77..925efbec3 100644 --- a/pom.xml +++ b/pom.xml @@ -378,7 +378,7 @@ com.google.cloud libraries-bom - 26.76.0 + 26.77.0 pom import From 8e92e878806a8a9696c9587312056e52c4cf528a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 13 May 2026 13:14:20 -0400 Subject: [PATCH 07/22] chore(deps-dev): Bump org.apache.maven.plugins:maven-surefire-plugin (#1190) Bumps [org.apache.maven.plugins:maven-surefire-plugin](https://github.com/apache/maven-surefire) from 3.5.4 to 3.5.5. - [Release notes](https://github.com/apache/maven-surefire/releases) - [Commits](https://github.com/apache/maven-surefire/compare/surefire-3.5.4...surefire-3.5.5) --- updated-dependencies: - dependency-name: org.apache.maven.plugins:maven-surefire-plugin dependency-version: 3.5.5 dependency-type: direct:development update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Lahiru Maramba --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index 925efbec3..e49d89567 100644 --- a/pom.xml +++ b/pom.xml @@ -273,7 +273,7 @@ maven-surefire-plugin - 3.5.4 + 3.5.5 ${skipUTs} From b465586481f912582098a56319412f352744cbd0 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 13 May 2026 13:29:00 -0400 Subject: [PATCH 08/22] chore(deps-dev): Bump org.apache.maven.plugins:maven-failsafe-plugin (#1188) Bumps [org.apache.maven.plugins:maven-failsafe-plugin](https://github.com/apache/maven-surefire) from 3.5.4 to 3.5.5. - [Release notes](https://github.com/apache/maven-surefire/releases) - [Commits](https://github.com/apache/maven-surefire/compare/surefire-3.5.4...surefire-3.5.5) --- updated-dependencies: - dependency-name: org.apache.maven.plugins:maven-failsafe-plugin dependency-version: 3.5.5 dependency-type: direct:development update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Lahiru Maramba --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index e49d89567..4b323a8ac 100644 --- a/pom.xml +++ b/pom.xml @@ -330,7 +330,7 @@ maven-failsafe-plugin - 3.5.4 + 3.5.5 From 533bc3d39c27a6c0cfa316bc996adf27f652560b Mon Sep 17 00:00:00 2001 From: Lahiru Maramba Date: Thu, 14 May 2026 16:35:33 -0400 Subject: [PATCH 09/22] [chore] Release 9.9.0 (#1206) --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index 4b323a8ac..0807bd96a 100644 --- a/pom.xml +++ b/pom.xml @@ -19,7 +19,7 @@ com.google.firebase firebase-admin - 9.8.0 + 9.9.0 jar firebase-admin From d86e023f68113642bfeacd903ba10acd09fc1508 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 19 May 2026 09:39:24 -0400 Subject: [PATCH 10/22] chore(deps): Bump com.google.cloud:libraries-bom from 26.77.0 to 26.82.0 (#1209) Bumps [com.google.cloud:libraries-bom](https://github.com/googleapis/java-cloud-bom) from 26.77.0 to 26.82.0. - [Release notes](https://github.com/googleapis/java-cloud-bom/releases) - [Commits](https://github.com/googleapis/java-cloud-bom/compare/v26.77.0...v26.82.0) --- updated-dependencies: - dependency-name: com.google.cloud:libraries-bom dependency-version: 26.82.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index 0807bd96a..f165f86b4 100644 --- a/pom.xml +++ b/pom.xml @@ -378,7 +378,7 @@ com.google.cloud libraries-bom - 26.77.0 + 26.82.0 pom import From 0ec98fac63c0103006311d3ecdce498d8c3f9d15 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 19 May 2026 09:43:23 -0400 Subject: [PATCH 11/22] chore(deps): Bump org.slf4j:slf4j-api from 2.0.17 to 2.0.18 (#1208) Bumps org.slf4j:slf4j-api from 2.0.17 to 2.0.18. --- updated-dependencies: - dependency-name: org.slf4j:slf4j-api dependency-version: 2.0.18 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Lahiru Maramba --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index f165f86b4..5e0547113 100644 --- a/pom.xml +++ b/pom.xml @@ -424,7 +424,7 @@ org.slf4j slf4j-api - 2.0.17 + 2.0.18 io.netty From d5c6cb91edce90a7b4187791a8c6f90a437624c1 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 25 May 2026 11:29:36 -0400 Subject: [PATCH 12/22] chore(deps): Bump netty.version from 4.2.13.Final to 4.2.14.Final (#1213) Bumps `netty.version` from 4.2.13.Final to 4.2.14.Final. Updates `io.netty:netty-codec-http` from 4.2.13.Final to 4.2.14.Final - [Release notes](https://github.com/netty/netty/releases) - [Commits](https://github.com/netty/netty/compare/netty-4.2.13.Final...netty-4.2.14.Final) Updates `io.netty:netty-handler` from 4.2.13.Final to 4.2.14.Final - [Release notes](https://github.com/netty/netty/releases) - [Commits](https://github.com/netty/netty/compare/netty-4.2.13.Final...netty-4.2.14.Final) Updates `io.netty:netty-transport` from 4.2.13.Final to 4.2.14.Final - [Release notes](https://github.com/netty/netty/releases) - [Commits](https://github.com/netty/netty/compare/netty-4.2.13.Final...netty-4.2.14.Final) --- updated-dependencies: - dependency-name: io.netty:netty-codec-http dependency-version: 4.2.14.Final dependency-type: direct:production update-type: version-update:semver-patch - dependency-name: io.netty:netty-handler dependency-version: 4.2.14.Final dependency-type: direct:production update-type: version-update:semver-patch - dependency-name: io.netty:netty-transport dependency-version: 4.2.14.Final dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index 5e0547113..cf17319cf 100644 --- a/pom.xml +++ b/pom.xml @@ -59,7 +59,7 @@ UTF-8 UTF-8 ${skipTests} - 4.2.13.Final + 4.2.14.Final From c2be37e8df2e5801d2c8ca61f47699aaa0e49ace Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 26 May 2026 12:53:25 -0400 Subject: [PATCH 13/22] chore(deps): Bump com.google.cloud:libraries-bom from 26.82.0 to 26.83.0 (#1212) Bumps [com.google.cloud:libraries-bom](https://github.com/googleapis/java-cloud-bom) from 26.82.0 to 26.83.0. - [Release notes](https://github.com/googleapis/java-cloud-bom/releases) - [Commits](https://github.com/googleapis/java-cloud-bom/compare/v26.82.0...v26.83.0) --- updated-dependencies: - dependency-name: com.google.cloud:libraries-bom dependency-version: 26.83.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index cf17319cf..a161d337d 100644 --- a/pom.xml +++ b/pom.xml @@ -378,7 +378,7 @@ com.google.cloud libraries-bom - 26.82.0 + 26.83.0 pom import From c16536694d4beedede4d26cabf51d8f9eebc6849 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 10 Jun 2026 12:38:30 -0400 Subject: [PATCH 14/22] chore(deps): Bump netty.version from 4.2.14.Final to 4.2.15.Final (#1218) Bumps `netty.version` from 4.2.14.Final to 4.2.15.Final. Updates `io.netty:netty-codec-http` from 4.2.14.Final to 4.2.15.Final - [Release notes](https://github.com/netty/netty/releases) - [Commits](https://github.com/netty/netty/compare/netty-4.2.14.Final...netty-4.2.15.Final) Updates `io.netty:netty-handler` from 4.2.14.Final to 4.2.15.Final - [Release notes](https://github.com/netty/netty/releases) - [Commits](https://github.com/netty/netty/compare/netty-4.2.14.Final...netty-4.2.15.Final) Updates `io.netty:netty-transport` from 4.2.14.Final to 4.2.15.Final - [Release notes](https://github.com/netty/netty/releases) - [Commits](https://github.com/netty/netty/compare/netty-4.2.14.Final...netty-4.2.15.Final) --- updated-dependencies: - dependency-name: io.netty:netty-codec-http dependency-version: 4.2.15.Final dependency-type: direct:production update-type: version-update:semver-patch - dependency-name: io.netty:netty-handler dependency-version: 4.2.15.Final dependency-type: direct:production update-type: version-update:semver-patch - dependency-name: io.netty:netty-transport dependency-version: 4.2.15.Final dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index a161d337d..1a01951df 100644 --- a/pom.xml +++ b/pom.xml @@ -59,7 +59,7 @@ UTF-8 UTF-8 ${skipTests} - 4.2.14.Final + 4.2.15.Final From ba7917da119d538c95300e780d12ca50e9b9ddd3 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 10 Jun 2026 12:43:41 -0400 Subject: [PATCH 15/22] chore(deps-dev): Bump org.apache.maven.plugins:maven-surefire-plugin (#1217) Bumps [org.apache.maven.plugins:maven-surefire-plugin](https://github.com/apache/maven-surefire) from 3.5.5 to 3.5.6. - [Release notes](https://github.com/apache/maven-surefire/releases) - [Commits](https://github.com/apache/maven-surefire/compare/surefire-3.5.5...surefire-3.5.6) --- updated-dependencies: - dependency-name: org.apache.maven.plugins:maven-surefire-plugin dependency-version: 3.5.6 dependency-type: direct:development update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index 1a01951df..c6e2674f2 100644 --- a/pom.xml +++ b/pom.xml @@ -273,7 +273,7 @@ maven-surefire-plugin - 3.5.5 + 3.5.6 ${skipUTs} From 5ac228cdfa1fc9dc621cb5df1ebfc455227ef2fb Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 10 Jun 2026 12:50:18 -0400 Subject: [PATCH 16/22] chore(deps-dev): Bump org.apache.maven.plugins:maven-failsafe-plugin (#1216) Bumps [org.apache.maven.plugins:maven-failsafe-plugin](https://github.com/apache/maven-surefire) from 3.5.5 to 3.5.6. - [Release notes](https://github.com/apache/maven-surefire/releases) - [Commits](https://github.com/apache/maven-surefire/compare/surefire-3.5.5...surefire-3.5.6) --- updated-dependencies: - dependency-name: org.apache.maven.plugins:maven-failsafe-plugin dependency-version: 3.5.6 dependency-type: direct:development update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index c6e2674f2..99cf7c52d 100644 --- a/pom.xml +++ b/pom.xml @@ -330,7 +330,7 @@ maven-failsafe-plugin - 3.5.5 + 3.5.6 From 3d7434c8f3746068960f9ad2c4018d66ec322ed5 Mon Sep 17 00:00:00 2001 From: Yvonne Pan <103622026+yvonnep165@users.noreply.github.com> Date: Mon, 29 Jun 2026 10:40:04 -0400 Subject: [PATCH 17/22] feat(fcm): Enable `fid` and deprecate `token` for Send API (#1211) * Enable fid and deprecate token for Message Class * Add fid supported methods and deprecate the token methods for the MulticastMessage Class * Add integration tests and update all public multicast send methods * Update docstring * Update integration tests error code for invalid fid target --- .../firebase/messaging/FirebaseMessaging.java | 44 ++++---- .../google/firebase/messaging/Message.java | 37 ++++++- .../firebase/messaging/MulticastMessage.java | 97 +++++++++++++---- .../messaging/FirebaseMessagingIT.java | 88 +++++++++++++++ .../firebase/messaging/MessageTest.java | 22 ++++ .../messaging/MulticastMessageTest.java | 101 ++++++++++++++++++ 6 files changed, 352 insertions(+), 37 deletions(-) diff --git a/src/main/java/com/google/firebase/messaging/FirebaseMessaging.java b/src/main/java/com/google/firebase/messaging/FirebaseMessaging.java index bb7b8208f..0e9831588 100644 --- a/src/main/java/com/google/firebase/messaging/FirebaseMessaging.java +++ b/src/main/java/com/google/firebase/messaging/FirebaseMessaging.java @@ -265,12 +265,14 @@ protected SendResponse execute() { } /** - * Sends the given multicast message to all the FCM registration tokens specified in it. + * Sends the given multicast message to all the FCM registration tokens and/or FIDs + * specified in it. * *

This method uses the {@link #sendEach(List)} API under the hood to send the given * message to all the target recipients. The list of responses obtained by calling * {@link BatchResponse#getResponses()} on the return value is in the same order as the - * tokens in the {@link MulticastMessage}. + * tokens and/or FIDs in the {@link MulticastMessage}. If both tokens and FIDs are + * provided, tokens are processed first, followed by FIDs. * * @param message A non-null {@link MulticastMessage} * @return A {@link BatchResponse} indicating the result of the operation. @@ -285,17 +287,19 @@ public BatchResponse sendEachForMulticast( } /** - * Sends the given multicast message to all the FCM registration tokens specified in it. + * Sends the given multicast message to all the FCM registration tokens and/or FIDs + * specified in it. * - *

If the {@code dryRun} option is set to true, the message will not be actually sent. Instead - * FCM performs all the necessary validations, and emulates the send operation. The {@code dryRun} - * option is useful for determining whether an FCM registration has been deleted. But it cannot be - * used to validate APNs tokens. + *

If the {@code dryRun} option is set to true, the message will not be actually sent. + * Instead FCM performs all the necessary validations, and emulates the send operation. + * The {@code dryRun} option is useful for determining whether an FCM registration has + * been deleted. But it cannot be used to validate APNs tokens. * *

This method uses the {@link #sendEach(List)} API under the hood to send the given * message to all the target recipients. The list of responses obtained by calling * {@link BatchResponse#getResponses()} on the return value is in the same order as the - * tokens in the {@link MulticastMessage}. + * tokens and/or FIDs in the {@link MulticastMessage}. If both tokens and FIDs are + * provided, tokens are processed first, followed by FIDs. * * @param message A non-null {@link MulticastMessage}. * @param dryRun A boolean indicating whether to perform a dry run (validation only) of the send. @@ -417,12 +421,14 @@ public ApiFuture sendAllAsync( } /** - * Sends the given multicast message to all the FCM registration tokens specified in it. + * Sends the given multicast message to all the FCM registration tokens and/or FIDs + * specified in it. * *

This method uses the {@link #sendAll(List)} API under the hood to send the given * message to all the target recipients. The responses list obtained by calling - * {@link BatchResponse#getResponses()} on the return value corresponds to the order of tokens - * in the {@link MulticastMessage}. + * {@link BatchResponse#getResponses()} on the return value corresponds to the order of + * tokens and/or FIDs in the {@link MulticastMessage}. If both tokens and FIDs are + * provided, tokens are processed first, followed by FIDs. * * @param message A non-null {@link MulticastMessage} * @return A {@link BatchResponse} indicating the result of the operation. @@ -439,17 +445,19 @@ public BatchResponse sendMulticast( } /** - * Sends the given multicast message to all the FCM registration tokens specified in it. + * Sends the given multicast message to all the FCM registration tokens and/or FIDs + * specified in it. * - *

If the {@code dryRun} option is set to true, the message will not be actually sent. Instead - * FCM performs all the necessary validations, and emulates the send operation. The {@code dryRun} - * option is useful for determining whether an FCM registration has been deleted. But it cannot be - * used to validate APNs tokens. + *

If the {@code dryRun} option is set to true, the message will not be actually sent. + * Instead FCM performs all the necessary validations, and emulates the send operation. + * The {@code dryRun} option is useful for determining whether an FCM registration has + * been deleted. But it cannot be used to validate APNs tokens. * *

This method uses the {@link #sendAll(List)} API under the hood to send the given * message to all the target recipients. The responses list obtained by calling - * {@link BatchResponse#getResponses()} on the return value corresponds to the order of tokens - * in the {@link MulticastMessage}. + * {@link BatchResponse#getResponses()} on the return value corresponds to the order of + * tokens and/or FIDs in the {@link MulticastMessage}. If both tokens and FIDs are + * provided, tokens are processed first, followed by FIDs. * * @param message A non-null {@link MulticastMessage}. * @param dryRun A boolean indicating whether to perform a dry run (validation only) of the send. diff --git a/src/main/java/com/google/firebase/messaging/Message.java b/src/main/java/com/google/firebase/messaging/Message.java index 1514fce3d..da790fa7f 100644 --- a/src/main/java/com/google/firebase/messaging/Message.java +++ b/src/main/java/com/google/firebase/messaging/Message.java @@ -54,9 +54,16 @@ public class Message { @Key("apns") private final ApnsConfig apnsConfig; + /** + * @deprecated Use {@link #fid} instead. + */ + @Deprecated @Key("token") private final String token; + @Key("fid") + private final String fid; + @Key("topic") private final String topic; @@ -74,11 +81,14 @@ private Message(Builder builder) { this.apnsConfig = builder.apnsConfig; int count = Booleans.countTrue( !Strings.isNullOrEmpty(builder.token), + !Strings.isNullOrEmpty(builder.fid), !Strings.isNullOrEmpty(builder.topic), !Strings.isNullOrEmpty(builder.condition) ); - checkArgument(count == 1, "Exactly one of token, topic or condition must be specified"); + checkArgument(count == 1, + "Exactly one of token, fid, topic or condition must be specified"); this.token = builder.token; + this.fid = builder.fid; this.topic = stripPrefix(builder.topic); this.condition = builder.condition; this.fcmOptions = builder.fcmOptions; @@ -109,11 +119,20 @@ ApnsConfig getApnsConfig() { return apnsConfig; } + /** + * @deprecated Use {@link #getFid()} instead. + */ + @Deprecated @VisibleForTesting String getToken() { return token; } + @VisibleForTesting + String getFid() { + return fid; + } + @VisibleForTesting String getTopic() { return topic; @@ -166,7 +185,9 @@ public static class Builder { private AndroidConfig androidConfig; private WebpushConfig webpushConfig; private ApnsConfig apnsConfig; + @Deprecated private String token; + private String fid; private String topic; private String condition; private FcmOptions fcmOptions; @@ -222,12 +243,26 @@ public Builder setApnsConfig(ApnsConfig apnsConfig) { * * @param token A valid device registration token. * @return This builder. + * @deprecated Use {@link #setFid(String)} instead. */ + @Deprecated public Builder setToken(String token) { this.token = token; return this; } + /** + * Sets the Firebase Installation ID (FID) of the app instance to which the message + * should be sent. + * + * @param fid A valid Firebase Installation ID. + * @return This builder. + */ + public Builder setFid(String fid) { + this.fid = fid; + return this; + } + /** * Sets the name of the FCM topic to which the message should be sent. Topic names may * contain the {@code /topics/} prefix. diff --git a/src/main/java/com/google/firebase/messaging/MulticastMessage.java b/src/main/java/com/google/firebase/messaging/MulticastMessage.java index cc43b187b..a880a88f2 100644 --- a/src/main/java/com/google/firebase/messaging/MulticastMessage.java +++ b/src/main/java/com/google/firebase/messaging/MulticastMessage.java @@ -30,22 +30,26 @@ /** * Represents a message that can be sent to multiple devices via Firebase Cloud Messaging (FCM). - * Contains payload information as well as the list of device registration tokens to which the - * message should be sent. A single {@code MulticastMessage} may contain up to 500 registration - * tokens. + * Contains payload information as well as the list of device registration tokens and/or + * Firebase Installation IDs (FIDs) to which the message should be sent. A single + * {@code MulticastMessage} may contain up to 500 registration tokens and FIDs combined. * *

Instances of this class are thread-safe and immutable. Use {@link MulticastMessage.Builder} * to create new instances. See {@link FirebaseMessaging#sendMulticast(MulticastMessage)} for * details on how to send the message to FCM for multicast delivery. * - *

This class and the associated Builder retain the order of tokens. Therefore the order of - * the responses list obtained by calling {@link BatchResponse#getResponses()} on the return value - * of {@link FirebaseMessaging#sendMulticast(MulticastMessage)} corresponds to the order in which - * tokens were added to the {@link MulticastMessage.Builder}. + *

This class and the associated Builder retain the order of tokens and FIDs. Therefore + * the order of the responses list obtained by calling {@link BatchResponse#getResponses()} + * on the return value of {@link FirebaseMessaging#sendMulticast(MulticastMessage)} + * corresponds to the order in which targets were added to the + * {@link MulticastMessage.Builder}. If both tokens and FIDs are provided, tokens are + * processed first, followed by FIDs. */ public class MulticastMessage { + @Deprecated private final List tokens; + private final List fids; private final Map data; private final Notification notification; private final AndroidConfig androidConfig; @@ -55,11 +59,18 @@ public class MulticastMessage { private MulticastMessage(Builder builder) { this.tokens = builder.tokens.build(); - checkArgument(!this.tokens.isEmpty(), "at least one token must be specified"); - checkArgument(this.tokens.size() <= 500, "no more than 500 tokens can be specified"); + this.fids = builder.fids.build(); + int tokensSize = this.tokens.size(); + int fidsSize = this.fids.size(); + checkArgument(tokensSize + fidsSize > 0, "at least one token or fid must be specified"); + checkArgument(tokensSize + fidsSize <= 500, + "no more than 500 tokens and fids combined can be specified"); for (String token : this.tokens) { checkArgument(!Strings.isNullOrEmpty(token), "none of the tokens can be null or empty"); } + for (String fid : this.fids) { + checkArgument(!Strings.isNullOrEmpty(fid), "none of the fids can be null or empty"); + } this.data = builder.data.isEmpty() ? null : ImmutableMap.copyOf(builder.data); this.notification = builder.notification; this.androidConfig = builder.androidConfig; @@ -69,6 +80,26 @@ private MulticastMessage(Builder builder) { } List getMessageList() { + ImmutableList.Builder messages = ImmutableList.builder(); + + if (!this.tokens.isEmpty()) { + Message.Builder tokenBuilder = getMetadataBuilder(); + for (String token : this.tokens) { + messages.add(tokenBuilder.setToken(token).build()); + } + } + + if (!this.fids.isEmpty()) { + Message.Builder fidBuilder = getMetadataBuilder(); + for (String fid : this.fids) { + messages.add(fidBuilder.setFid(fid).build()); + } + } + + return messages.build(); + } + + private Message.Builder getMetadataBuilder() { Message.Builder builder = Message.builder() .setNotification(this.notification) .setAndroidConfig(this.androidConfig) @@ -78,11 +109,7 @@ List getMessageList() { if (this.data != null) { builder.putAllData(this.data); } - ImmutableList.Builder messages = ImmutableList.builder(); - for (String token : this.tokens) { - messages.add(builder.setToken(token).build()); - } - return messages.build(); + return builder; } /** @@ -96,7 +123,9 @@ public static Builder builder() { public static class Builder { + @Deprecated private final ImmutableList.Builder tokens = ImmutableList.builder(); + private final ImmutableList.Builder fids = ImmutableList.builder(); private final Map data = new HashMap<>(); private Notification notification; private AndroidConfig androidConfig; @@ -107,29 +136,61 @@ public static class Builder { private Builder() {} /** - * Adds a token to which the message should be sent. Up to 500 tokens can be specified on - * a single instance of {@link MulticastMessage}. + * Adds a token to which the message should be sent. Up to 500 tokens + * and FIDs combined can be specified on a single instance of + * {@link MulticastMessage}. * * @param token A non-null, non-empty Firebase device registration token. * @return This builder. + * @deprecated Use {@link #addFid(String)} instead. */ + @Deprecated public Builder addToken(@NonNull String token) { this.tokens.add(token); return this; } /** - * Adds a collection of tokens to which the message should be sent. Up to 500 tokens can be - * specified on a single instance of {@link MulticastMessage}. + * Adds a Firebase Installation ID (FID) to which the message should be sent. + * Up to 500 tokens and FIDs combined can be specified on a single instance + * of {@link MulticastMessage}. + * + * @param fid A non-null, non-empty Firebase Installation ID. + * @return This builder. + */ + public Builder addFid(@NonNull String fid) { + this.fids.add(fid); + return this; + } + + /** + * Adds a collection of tokens to which the message should be sent. Up to 500 + * tokens and FIDs combined can be specified on a single instance of + * {@link MulticastMessage}. * * @param tokens Collection of Firebase device registration tokens. * @return This builder. + * @deprecated Use {@link #addAllFids(Collection)} instead. */ + @Deprecated public Builder addAllTokens(@NonNull Collection tokens) { this.tokens.addAll(tokens); return this; } + /** + * Adds a collection of Firebase Installation IDs (FIDs) to which the message + * should be sent. Up to 500 tokens and FIDs combined can be specified on a + * single instance of {@link MulticastMessage}. + * + * @param fids Collection of Firebase Installation IDs. + * @return This builder. + */ + public Builder addAllFids(@NonNull Collection fids) { + this.fids.addAll(fids); + return this; + } + /** * Sets the notification information to be included in the message. * diff --git a/src/test/java/com/google/firebase/messaging/FirebaseMessagingIT.java b/src/test/java/com/google/firebase/messaging/FirebaseMessagingIT.java index e084b8c29..85667f799 100644 --- a/src/test/java/com/google/firebase/messaging/FirebaseMessagingIT.java +++ b/src/test/java/com/google/firebase/messaging/FirebaseMessagingIT.java @@ -21,6 +21,7 @@ import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertNull; import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; import com.google.api.client.http.HttpResponseException; import com.google.common.collect.ImmutableList; @@ -97,6 +98,28 @@ public void testSendError() throws InterruptedException { } } + @Test + public void testSendFidError() throws InterruptedException { + FirebaseMessaging messaging = FirebaseMessaging.getInstance(); + Message message = Message.builder() + .setNotification(Notification.builder() + .setTitle("Title") + .setBody("Body") + .build()) + .setFid("not-a-fid") + .build(); + try { + messaging.sendAsync(message, true).get(); + fail("No exception thrown for invalid FID"); + } catch (ExecutionException e) { + FirebaseMessagingException cause = (FirebaseMessagingException) e.getCause(); + assertEquals(ErrorCode.NOT_FOUND, cause.getErrorCode()); + assertEquals(MessagingErrorCode.UNREGISTERED, cause.getMessagingErrorCode()); + assertNotNull(cause.getHttpResponse()); + assertTrue(cause.getCause() instanceof HttpResponseException); + } + } + @Test public void testSendEach() throws Exception { List messages = new ArrayList<>(); @@ -194,6 +217,71 @@ public void testSendEachForMulticast() throws Exception { } } + @Test + public void testSendEachForMulticastFidsError() throws Exception { + MulticastMessage multicastMessage = MulticastMessage.builder() + .setNotification(Notification.builder() + .setTitle("Title") + .setBody("Body") + .build()) + .addFid("not-a-fid") + .addFid("also-not-a-fid") + .build(); + + BatchResponse response = FirebaseMessaging.getInstance().sendEachForMulticast( + multicastMessage, true); + + assertEquals(0, response.getSuccessCount()); + assertEquals(2, response.getFailureCount()); + assertEquals(2, response.getResponses().size()); + for (SendResponse sendResponse : response.getResponses()) { + assertFalse(sendResponse.isSuccessful()); + assertNull(sendResponse.getMessageId()); + assertNotNull(sendResponse.getException()); + assertEquals(ErrorCode.NOT_FOUND, + sendResponse.getException().getErrorCode()); + assertEquals(MessagingErrorCode.UNREGISTERED, + sendResponse.getException().getMessagingErrorCode()); + } + } + + @Test + public void testSendEachForMulticastMixedError() throws Exception { + MulticastMessage multicastMessage = MulticastMessage.builder() + .setNotification(Notification.builder() + .setTitle("Title") + .setBody("Body") + .build()) + .addToken("not-a-token") + .addFid("not-a-fid") + .build(); + + BatchResponse response = FirebaseMessaging.getInstance().sendEachForMulticast( + multicastMessage, true); + + assertEquals(0, response.getSuccessCount()); + assertEquals(2, response.getFailureCount()); + assertEquals(2, response.getResponses().size()); + + SendResponse response1 = response.getResponses().get(0); + assertFalse(response1.isSuccessful()); + assertNull(response1.getMessageId()); + assertNotNull(response1.getException()); + assertEquals(ErrorCode.INVALID_ARGUMENT, response1.getException().getErrorCode()); + assertEquals( + MessagingErrorCode.INVALID_ARGUMENT, + response1.getException().getMessagingErrorCode()); + + SendResponse response2 = response.getResponses().get(1); + assertFalse(response2.isSuccessful()); + assertNull(response2.getMessageId()); + assertNotNull(response2.getException()); + assertEquals(ErrorCode.NOT_FOUND, response2.getException().getErrorCode()); + assertEquals( + MessagingErrorCode.UNREGISTERED, + response2.getException().getMessagingErrorCode()); + } + @Test public void testSubscribe() throws Exception { FirebaseMessaging messaging = FirebaseMessaging.getInstance(); diff --git a/src/test/java/com/google/firebase/messaging/MessageTest.java b/src/test/java/com/google/firebase/messaging/MessageTest.java index 94ca4fa4a..0bca10df1 100644 --- a/src/test/java/com/google/firebase/messaging/MessageTest.java +++ b/src/test/java/com/google/firebase/messaging/MessageTest.java @@ -54,6 +54,28 @@ public void testEmptyMessage() throws IOException { Message.builder().setCondition("'foo' in topics").build()); assertJsonEquals(ImmutableMap.of("token", "test-token"), Message.builder().setToken("test-token").build()); + assertJsonEquals(ImmutableMap.of("fid", "test-fid"), + Message.builder().setFid("test-fid").build()); + } + + @Test + public void testMultipleTargets() { + List builders = ImmutableList.of( + Message.builder().setToken("token").setFid("fid"), + Message.builder().setToken("token").setTopic("topic"), + Message.builder().setFid("fid").setCondition("cond"), + Message.builder().setTopic("topic").setCondition("cond"), + Message.builder().setToken("token").setFid("fid").setTopic("topic").setCondition("cond") + ); + for (int i = 0; i < builders.size(); i++) { + try { + builders.get(i).build(); + fail("No error thrown for multiple targets: " + i); + } catch (IllegalArgumentException expected) { + assertEquals("Exactly one of token, fid, topic or condition must be specified", + expected.getMessage()); + } + } } @Test diff --git a/src/test/java/com/google/firebase/messaging/MulticastMessageTest.java b/src/test/java/com/google/firebase/messaging/MulticastMessageTest.java index aa0dae10c..481ccccc9 100644 --- a/src/test/java/com/google/firebase/messaging/MulticastMessageTest.java +++ b/src/test/java/com/google/firebase/messaging/MulticastMessageTest.java @@ -86,6 +86,21 @@ public void testTooManyTokens() { } } + @Test + public void testTooManyFids() { + MulticastMessage.Builder builder = MulticastMessage.builder(); + for (int i = 0; i < 501; i++) { + builder.addFid("fid" + i); + } + try { + builder.build(); + fail("No error thrown for more than 500 fids"); + } catch (IllegalArgumentException expected) { + assertEquals("no more than 500 tokens and fids combined can be specified", + expected.getMessage()); + } + } + @Test(expected = NullPointerException.class) public void testNullToken() { MulticastMessage.builder().addToken(null).build(); @@ -96,6 +111,92 @@ public void testEmptyToken() { MulticastMessage.builder().addToken("").build(); } + @Test + public void testMulticastMessageFids() { + MulticastMessage multicastMessage = MulticastMessage.builder() + .setAndroidConfig(ANDROID) + .setApnsConfig(APNS) + .setWebpushConfig(WEBPUSH) + .setNotification(NOTIFICATION) + .setFcmOptions(FCM_OPTIONS) + .putData("key1", "value1") + .putAllData(ImmutableMap.of("key2", "value2")) + .addFid("fid1") + .addAllFids(ImmutableList.of("fid2", "fid3")) + .build(); + + List messages = multicastMessage.getMessageList(); + + assertEquals(3, messages.size()); + for (int i = 0; i < 3; i++) { + Message message = messages.get(i); + assertMessageFid(message, "fid" + (i + 1)); + } + } + + @Test + public void testMulticastMessageMixed() { + MulticastMessage multicastMessage = MulticastMessage.builder() + .setAndroidConfig(ANDROID) + .setApnsConfig(APNS) + .setWebpushConfig(WEBPUSH) + .setNotification(NOTIFICATION) + .setFcmOptions(FCM_OPTIONS) + .putData("key1", "value1") + .putAllData(ImmutableMap.of("key2", "value2")) + .addToken("token1") + .addFid("fid1") + .addToken("token2") + .addFid("fid2") + .build(); + + List messages = multicastMessage.getMessageList(); + + assertEquals(4, messages.size()); + assertMessage(messages.get(0), "token1"); + assertMessage(messages.get(1), "token2"); + assertMessageFid(messages.get(2), "fid1"); + assertMessageFid(messages.get(3), "fid2"); + } + + @Test + public void testTooManyTargetsCombined() { + MulticastMessage.Builder builder = MulticastMessage.builder(); + for (int i = 0; i < 250; i++) { + builder.addToken("token" + i); + } + for (int i = 0; i < 251; i++) { + builder.addFid("fid" + i); + } + try { + builder.build(); + fail("No error thrown for more than 500 combined targets"); + } catch (IllegalArgumentException expected) { + assertEquals("no more than 500 tokens and fids combined can be specified", + expected.getMessage()); + } + } + + @Test(expected = NullPointerException.class) + public void testNullFid() { + MulticastMessage.builder().addFid(null).build(); + } + + @Test(expected = IllegalArgumentException.class) + public void testEmptyFid() { + MulticastMessage.builder().addFid("").build(); + } + + private void assertMessageFid(Message message, String expectedFid) { + assertSame(ANDROID, message.getAndroidConfig()); + assertSame(APNS, message.getApnsConfig()); + assertSame(WEBPUSH, message.getWebpushConfig()); + assertSame(NOTIFICATION, message.getNotification()); + assertSame(FCM_OPTIONS, message.getFcmOptions()); + assertEquals(ImmutableMap.of("key1", "value1", "key2", "value2"), message.getData()); + assertEquals(expectedFid, message.getFid()); + } + private void assertMessage(Message message, String expectedToken) { assertSame(ANDROID, message.getAndroidConfig()); assertSame(APNS, message.getApnsConfig()); From e17fa586088867c300ad6dc232a54d86e757fcce Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 29 Jun 2026 12:36:47 -0400 Subject: [PATCH 18/22] chore(deps): Bump com.nimbusds:nimbus-jose-jwt from 10.9 to 10.9.1 (#1220) Bumps [com.nimbusds:nimbus-jose-jwt](https://bitbucket.org/connect2id/nimbus-jose-jwt) from 10.9 to 10.9.1. - [Changelog](https://bitbucket.org/connect2id/nimbus-jose-jwt/src/master/CHANGELOG.txt) - [Commits](https://bitbucket.org/connect2id/nimbus-jose-jwt/branches/compare/10.9.1..10.9) --- updated-dependencies: - dependency-name: com.nimbusds:nimbus-jose-jwt dependency-version: 10.9.1 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Yvonne Pan <103622026+yvonnep165@users.noreply.github.com> --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index 99cf7c52d..a86ce31af 100644 --- a/pom.xml +++ b/pom.xml @@ -449,7 +449,7 @@ com.nimbusds nimbus-jose-jwt - 10.9 + 10.9.1 From 4d48b282748e7c94326964ed8aef421c2e051426 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 29 Jun 2026 12:39:44 -0400 Subject: [PATCH 19/22] chore(deps-dev): Bump org.sonatype.central:central-publishing-maven-plugin (#1221) Bumps [org.sonatype.central:central-publishing-maven-plugin](https://github.com/sonatype/central-publishing-maven-plugin) from 0.10.0 to 0.11.0. - [Commits](https://github.com/sonatype/central-publishing-maven-plugin/commits) --- updated-dependencies: - dependency-name: org.sonatype.central:central-publishing-maven-plugin dependency-version: 0.11.0 dependency-type: direct:development update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Yvonne Pan <103622026+yvonnep165@users.noreply.github.com> --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index a86ce31af..ba9895367 100644 --- a/pom.xml +++ b/pom.xml @@ -345,7 +345,7 @@ org.sonatype.central central-publishing-maven-plugin - 0.10.0 + 0.11.0 true central From 7c0d9264adf3316a16d7a140488be47ea9fe340b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 29 Jun 2026 13:23:46 -0400 Subject: [PATCH 20/22] chore(deps-dev): Bump org.jacoco:jacoco-maven-plugin (#1219) Bumps [org.jacoco:jacoco-maven-plugin](https://github.com/jacoco/jacoco) from 0.8.14 to 0.8.15. - [Release notes](https://github.com/jacoco/jacoco/releases) - [Commits](https://github.com/jacoco/jacoco/compare/v0.8.14...v0.8.15) --- updated-dependencies: - dependency-name: org.jacoco:jacoco-maven-plugin dependency-version: 0.8.15 dependency-type: direct:development update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Yvonne Pan <103622026+yvonnep165@users.noreply.github.com> --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index ba9895367..059f2fa8a 100644 --- a/pom.xml +++ b/pom.xml @@ -182,7 +182,7 @@ org.jacoco jacoco-maven-plugin - 0.8.14 + 0.8.15 pre-unit-test From 074d5c5634689a8376b993c466ebdf1a33b59093 Mon Sep 17 00:00:00 2001 From: Yvonne Pan <103622026+yvonnep165@users.noreply.github.com> Date: Thu, 2 Jul 2026 10:35:17 -0400 Subject: [PATCH 21/22] [chore] Release 9.10.0 (#1223) --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index 059f2fa8a..1323744e1 100644 --- a/pom.xml +++ b/pom.xml @@ -19,7 +19,7 @@ com.google.firebase firebase-admin - 9.9.0 + 9.10.0 jar firebase-admin From 197489e1506302618ac76b074e921c4dd6174ddd Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 29 Jul 2026 14:35:48 -0400 Subject: [PATCH 22/22] chore(deps): Bump netty.version from 4.2.15.Final to 4.2.16.Final (#1225) Bumps `netty.version` from 4.2.15.Final to 4.2.16.Final. Updates `io.netty:netty-codec-http` from 4.2.15.Final to 4.2.16.Final - [Release notes](https://github.com/netty/netty/releases) - [Commits](https://github.com/netty/netty/compare/netty-4.2.15.Final...netty-4.2.16.Final) Updates `io.netty:netty-handler` from 4.2.15.Final to 4.2.16.Final - [Release notes](https://github.com/netty/netty/releases) - [Commits](https://github.com/netty/netty/compare/netty-4.2.15.Final...netty-4.2.16.Final) Updates `io.netty:netty-transport` from 4.2.15.Final to 4.2.16.Final - [Release notes](https://github.com/netty/netty/releases) - [Commits](https://github.com/netty/netty/compare/netty-4.2.15.Final...netty-4.2.16.Final) --- updated-dependencies: - dependency-name: io.netty:netty-codec-http dependency-version: 4.2.16.Final dependency-type: direct:production update-type: version-update:semver-patch - dependency-name: io.netty:netty-handler dependency-version: 4.2.16.Final dependency-type: direct:production update-type: version-update:semver-patch - dependency-name: io.netty:netty-transport dependency-version: 4.2.16.Final dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index 1323744e1..7ad9e8e78 100644 --- a/pom.xml +++ b/pom.xml @@ -59,7 +59,7 @@ UTF-8 UTF-8 ${skipTests} - 4.2.15.Final + 4.2.16.Final