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/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/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/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/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/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 70d130032..0bca10df1 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;
@@ -55,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
@@ -247,6 +268,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 +1035,7 @@ public void testExtendedAndroidNotificationParameters() throws IOException {
}
private static void assertJsonEquals(
- Map expected, Object actual) throws IOException {
+ Map expected, Object actual) throws IOException {
assertEquals(expected, toMap(actual));
}
diff --git a/src/test/java/com/google/firebase/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());
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"));
+ }
+}
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/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
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);
}