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 39/70] 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 40/70] 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 41/70] 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 42/70] 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 43/70] [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 44/70] 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 45/70] 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 46/70] 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 47/70] 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 48/70] 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 49/70] 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 50/70] 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 51/70] 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