Add update FCM token grpc

Add update FCM API to the check in grpc definition and to
DeviceCheckInClient. The API will be used in future CLs

Bug: 367447984
Flag: EXEMPT low-risk refactor
Test: atest DeviceCheckInClientImplTest
Change-Id: If35daf24e929e5e06f252a4fa2b8475e2bf10547
diff --git a/DeviceLockController/proto/checkin_service.proto b/DeviceLockController/proto/checkin_service.proto
index 377e29c..f1f1a3a 100644
--- a/DeviceLockController/proto/checkin_service.proto
+++ b/DeviceLockController/proto/checkin_service.proto
@@ -46,6 +46,10 @@
   // provisioning.
   rpc ReportDeviceProvisionState(ReportDeviceProvisionStateRequest)
       returns (ReportDeviceProvisionStateResponse) {}
+
+  // Updates FCM token for a device.
+  rpc UpdateFcmToken(UpdateFcmTokenRequest)
+      returns (UpdateFcmTokenResponse) {}
 }
 
 // Request to retrieve the check-in status of the device.
@@ -252,3 +256,33 @@
   // CLIENT_PROVISION_STATE_DISMISSIBLE_UI
   optional uint32 days_left_until_reset = 2;
 }
+
+// Request to update FCM token for a device.
+message UpdateFcmTokenRequest {
+  // The device identifiers associated with the device provided by the Device
+  // Lock Android client.
+  repeated ClientDeviceIdentifier client_device_identifiers = 1;
+
+  // The Firebase Cloud Messaging (FCM) registration token associated with the
+  // device provided by the Device Lock Android client. The token is only used
+  // for GMS devices.
+  optional string fcm_registration_token = 2;
+}
+
+// Response to a request to update FCM token for a device.
+message UpdateFcmTokenResponse {
+  // The result of the update.
+  optional UpdateFcmTokenResult result = 1;
+}
+
+// The results of FCM token update.
+enum UpdateFcmTokenResult {
+  // Unspecified result.
+  UPDATE_FCM_TOKEN_RESULT_UNSPECIFIED = 0;
+
+  // Update to FCM token was successful.
+  UPDATE_FCM_TOKEN_RESULT_SUCCESS = 1;
+
+  // Update to FCM token was unsuccessful.
+  UPDATE_FCM_TOKEN_RESULT_FAILURE = 2;
+}
diff --git a/DeviceLockController/src/com/android/devicelockcontroller/debug/DeviceCheckInClientDebug.java b/DeviceLockController/src/com/android/devicelockcontroller/debug/DeviceCheckInClientDebug.java
index 2c2bc6b..1c67471 100644
--- a/DeviceLockController/src/com/android/devicelockcontroller/debug/DeviceCheckInClientDebug.java
+++ b/DeviceLockController/src/com/android/devicelockcontroller/debug/DeviceCheckInClientDebug.java
@@ -25,6 +25,7 @@
 import android.util.ArraySet;
 
 import androidx.annotation.Keep;
+import androidx.annotation.NonNull;
 import androidx.annotation.Nullable;
 
 import com.android.devicelockcontroller.common.DeviceId;
@@ -38,6 +39,7 @@
 import com.android.devicelockcontroller.provision.grpc.PauseDeviceProvisioningGrpcResponse;
 import com.android.devicelockcontroller.provision.grpc.ProvisioningConfiguration;
 import com.android.devicelockcontroller.provision.grpc.ReportDeviceProvisionStateGrpcResponse;
+import com.android.devicelockcontroller.provision.grpc.UpdateFcmTokenGrpcResponse;
 import com.android.devicelockcontroller.util.LogUtil;
 import com.android.devicelockcontroller.util.ThreadAsserts;
 
@@ -247,4 +249,16 @@
             }
         };
     }
+
+    @Override
+    public UpdateFcmTokenGrpcResponse updateFcmToken(ArraySet<DeviceId> deviceIds,
+            @NonNull String fcmRegistrationToken) {
+        ThreadAsserts.assertWorkerThread("updateFcmToken");
+        return new UpdateFcmTokenGrpcResponse() {
+            @Override
+            public int getFcmTokenResult() {
+                return FcmTokenResult.RESULT_SUCCESS;
+            }
+        };
+    }
 }
diff --git a/DeviceLockController/src/com/android/devicelockcontroller/provision/grpc/DeviceCheckInClient.java b/DeviceLockController/src/com/android/devicelockcontroller/provision/grpc/DeviceCheckInClient.java
index ab8ff20..e58f4e0 100644
--- a/DeviceLockController/src/com/android/devicelockcontroller/provision/grpc/DeviceCheckInClient.java
+++ b/DeviceLockController/src/com/android/devicelockcontroller/provision/grpc/DeviceCheckInClient.java
@@ -23,6 +23,7 @@
 import android.os.UserHandle;
 import android.util.ArraySet;
 
+import androidx.annotation.NonNull;
 import androidx.annotation.Nullable;
 import androidx.annotation.WorkerThread;
 
@@ -188,6 +189,19 @@
             boolean isSuccessful, @ProvisionFailureReason int failureReason);
 
     /**
+     * Update FCM registration token on device lock backend server for the given device identifiers.
+     *
+     * @param deviceIds            A set of all device unique identifiers, this could include IMEIs,
+     *                             MEIDs, etc.
+     * @param fcmRegistrationToken The fcm registration token
+     * @return A class that encapsulate the response from the backend server.
+     */
+    @WorkerThread
+    public abstract UpdateFcmTokenGrpcResponse updateFcmToken(
+            ArraySet<DeviceId> deviceIds,
+            @NonNull String fcmRegistrationToken);
+
+    /**
      * Called when this device check in client is no longer in use and should clean up its
      * resources.
      */
diff --git a/DeviceLockController/src/com/android/devicelockcontroller/provision/grpc/UpdateFcmTokenGrpcResponse.java b/DeviceLockController/src/com/android/devicelockcontroller/provision/grpc/UpdateFcmTokenGrpcResponse.java
new file mode 100644
index 0000000..a713fc8
--- /dev/null
+++ b/DeviceLockController/src/com/android/devicelockcontroller/provision/grpc/UpdateFcmTokenGrpcResponse.java
@@ -0,0 +1,65 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * 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.android.devicelockcontroller.provision.grpc;
+
+import androidx.annotation.IntDef;
+import androidx.annotation.NonNull;
+
+import io.grpc.Status;
+
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+
+/**
+ * An abstract class that is used to encapsulate the response for updating the FCM registration
+ * token.
+ */
+public abstract class UpdateFcmTokenGrpcResponse extends GrpcResponse {
+    /** Definitions for FCM token results. */
+    @Retention(RetentionPolicy.SOURCE)
+    @IntDef(
+            value = {
+                    FcmTokenResult.RESULT_UNSPECIFIED,
+                    FcmTokenResult.RESULT_SUCCESS,
+                    FcmTokenResult.RESULT_FAILURE
+            }
+    )
+    public @interface FcmTokenResult {
+        /** Result unspecified */
+        int RESULT_UNSPECIFIED = 0;
+        /** FCM registration token successfully updated */
+        int RESULT_SUCCESS = 1;
+        /** FCM registration token falied to update */
+        int RESULT_FAILURE = 2;
+    }
+
+    public UpdateFcmTokenGrpcResponse() {
+        mStatus = null;
+    }
+
+    public UpdateFcmTokenGrpcResponse(@NonNull Status status) {
+        super(status);
+    }
+
+    /**
+     * Get result of updating FCM registration token.
+     *
+     * @return one of {@link FcmTokenResult}
+     */
+    @FcmTokenResult
+    public abstract int getFcmTokenResult();
+}
diff --git a/DeviceLockController/src/com/android/devicelockcontroller/provision/grpc/impl/DeviceCheckInClientImpl.java b/DeviceLockController/src/com/android/devicelockcontroller/provision/grpc/impl/DeviceCheckInClientImpl.java
index eedf8b3..767c0f9 100644
--- a/DeviceLockController/src/com/android/devicelockcontroller/provision/grpc/impl/DeviceCheckInClientImpl.java
+++ b/DeviceLockController/src/com/android/devicelockcontroller/provision/grpc/impl/DeviceCheckInClientImpl.java
@@ -54,11 +54,13 @@
 import com.android.devicelockcontroller.proto.PauseDeviceProvisioningReason;
 import com.android.devicelockcontroller.proto.PauseDeviceProvisioningRequest;
 import com.android.devicelockcontroller.proto.ReportDeviceProvisionStateRequest;
+import com.android.devicelockcontroller.proto.UpdateFcmTokenRequest;
 import com.android.devicelockcontroller.provision.grpc.DeviceCheckInClient;
 import com.android.devicelockcontroller.provision.grpc.GetDeviceCheckInStatusGrpcResponse;
 import com.android.devicelockcontroller.provision.grpc.IsDeviceInApprovedCountryGrpcResponse;
 import com.android.devicelockcontroller.provision.grpc.PauseDeviceProvisioningGrpcResponse;
 import com.android.devicelockcontroller.provision.grpc.ReportDeviceProvisionStateGrpcResponse;
+import com.android.devicelockcontroller.provision.grpc.UpdateFcmTokenGrpcResponse;
 import com.android.devicelockcontroller.util.LogUtil;
 import com.android.devicelockcontroller.util.ThreadAsserts;
 
@@ -201,7 +203,6 @@
             @Nullable String fcmRegistrationToken,
             @NonNull DeviceLockCheckinServiceBlockingStub stub) {
         try {
-            // TODO(339313833): Make a separate grpc call for passing in the token
             return new GetDeviceCheckInStatusGrpcResponseWrapper(
                     stub.withDeadlineAfter(GRPC_DEADLINE_MS, TimeUnit.MILLISECONDS)
                             .getDeviceCheckinStatus(createGetDeviceCheckinStatusRequest(
@@ -316,6 +317,40 @@
     }
 
     @Override
+    public UpdateFcmTokenGrpcResponse updateFcmToken(ArraySet<DeviceId> deviceIds,
+            @NonNull String fcmRegistrationToken) {
+        ThreadAsserts.assertWorkerThread("getDeviceCheckInStatus");
+        UpdateFcmTokenGrpcResponse response =
+                updateFcmToken(deviceIds, fcmRegistrationToken, mDefaultBlockingStub);
+        if (response.hasRecoverableError()) {
+            DeviceLockCheckinServiceBlockingStub stub;
+            synchronized (this) {
+                if (mNonVpnBlockingStub == null) {
+                    return response;
+                }
+                stub = mNonVpnBlockingStub;
+            }
+            LogUtil.d(TAG, "Non-VPN network fallback detected. Re-attempt fcm token update.");
+            return updateFcmToken(deviceIds, fcmRegistrationToken, stub);
+        }
+        return response;
+    }
+
+    private UpdateFcmTokenGrpcResponse updateFcmToken(
+            ArraySet<DeviceId> deviceIds,
+            @NonNull String fcmRegistrationToken,
+            @NonNull DeviceLockCheckinServiceBlockingStub stub) {
+        try {
+            return new UpdateFcmTokenGrpcResponseWrapper(
+                    stub.withDeadlineAfter(GRPC_DEADLINE_MS, TimeUnit.MILLISECONDS)
+                            .updateFcmToken(createUpdateFcmTokenRequest(
+                                    deviceIds, fcmRegistrationToken)));
+        } catch (StatusRuntimeException e) {
+            return new UpdateFcmTokenGrpcResponseWrapper(e.getStatus());
+        }
+    }
+
+    @Override
     public void cleanUp() {
         super.cleanUp();
         mConnectivityManager.unregisterNetworkCallback(mNetworkCallback);
@@ -356,24 +391,10 @@
             @Nullable String fcmRegistrationToken) {
         GetDeviceCheckinStatusRequest.Builder builder = GetDeviceCheckinStatusRequest.newBuilder();
         for (DeviceId deviceId : deviceIds) {
-            DeviceIdentifierType type;
-            switch (deviceId.getType()) {
-                case DeviceIdType.DEVICE_ID_TYPE_UNSPECIFIED:
-                    type = DeviceIdentifierType.DEVICE_IDENTIFIER_TYPE_UNSPECIFIED;
-                    break;
-                case DeviceIdType.DEVICE_ID_TYPE_IMEI:
-                    type = DeviceIdentifierType.DEVICE_IDENTIFIER_TYPE_IMEI;
-                    break;
-                case DeviceIdType.DEVICE_ID_TYPE_MEID:
-                    type = DeviceIdentifierType.DEVICE_IDENTIFIER_TYPE_MEID;
-                    break;
-                default:
-                    throw new IllegalStateException(
-                            "Unexpected DeviceId type: " + deviceId.getType());
-            }
             builder.addClientDeviceIdentifiers(
                     ClientDeviceIdentifier.newBuilder()
-                            .setDeviceIdentifierType(type)
+                            .setDeviceIdentifierType(
+                                    convertToProtoDeviceIdType(deviceId.getType()))
                             .setDeviceIdentifier(deviceId.getId()));
         }
         builder.setCarrierMccmnc(carrierInfo);
@@ -386,6 +407,19 @@
         return builder.build();
     }
 
+    private static DeviceIdentifierType convertToProtoDeviceIdType(@DeviceIdType int deviceIdType) {
+        return switch (deviceIdType) {
+            case DeviceIdType.DEVICE_ID_TYPE_UNSPECIFIED ->
+                    DeviceIdentifierType.DEVICE_IDENTIFIER_TYPE_UNSPECIFIED;
+            case DeviceIdType.DEVICE_ID_TYPE_IMEI ->
+                    DeviceIdentifierType.DEVICE_IDENTIFIER_TYPE_IMEI;
+            case DeviceIdType.DEVICE_ID_TYPE_MEID ->
+                    DeviceIdentifierType.DEVICE_IDENTIFIER_TYPE_MEID;
+            default -> throw new IllegalStateException(
+                    "Unexpected DeviceId type: " + deviceIdType);
+        };
+    }
+
     private static IsDeviceInApprovedCountryRequest createIsDeviceInApprovedCountryRequest(
             String carrierInfo, String registeredId) {
         return IsDeviceInApprovedCountryRequest.newBuilder()
@@ -469,4 +503,18 @@
         }
         return builder.build();
     }
+
+    private static UpdateFcmTokenRequest createUpdateFcmTokenRequest(ArraySet<DeviceId> deviceIds,
+            @NonNull String fcmRegistrationToken) {
+        UpdateFcmTokenRequest.Builder builder = UpdateFcmTokenRequest.newBuilder();
+        for (DeviceId deviceId : deviceIds) {
+            builder.addClientDeviceIdentifiers(
+                    ClientDeviceIdentifier.newBuilder()
+                            .setDeviceIdentifierType(
+                                    convertToProtoDeviceIdType(deviceId.getType()))
+                            .setDeviceIdentifier(deviceId.getId()));
+        }
+        builder.setFcmRegistrationToken(fcmRegistrationToken);
+        return builder.build();
+    }
 }
diff --git a/DeviceLockController/src/com/android/devicelockcontroller/provision/grpc/impl/UpdateFcmTokenGrpcResponseWrapper.java b/DeviceLockController/src/com/android/devicelockcontroller/provision/grpc/impl/UpdateFcmTokenGrpcResponseWrapper.java
new file mode 100644
index 0000000..6f8e90e
--- /dev/null
+++ b/DeviceLockController/src/com/android/devicelockcontroller/provision/grpc/impl/UpdateFcmTokenGrpcResponseWrapper.java
@@ -0,0 +1,53 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * 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.android.devicelockcontroller.provision.grpc.impl;
+
+import com.android.devicelockcontroller.proto.UpdateFcmTokenResponse;
+import com.android.devicelockcontroller.provision.grpc.UpdateFcmTokenGrpcResponse;
+
+import io.grpc.Status;
+
+/**
+ * A wrapper class for {@link UpdateFcmTokenGrpcResponse}.
+ */
+public final class UpdateFcmTokenGrpcResponseWrapper extends UpdateFcmTokenGrpcResponse {
+    private UpdateFcmTokenResponse mResponse;
+
+    public UpdateFcmTokenGrpcResponseWrapper(Status status) {
+        super(status);
+    }
+
+    public UpdateFcmTokenGrpcResponseWrapper(UpdateFcmTokenResponse response) {
+        super();
+        mResponse = response;
+    }
+
+    @Override
+    @FcmTokenResult
+    public int getFcmTokenResult() {
+        if (mResponse == null) {
+            return FcmTokenResult.RESULT_UNSPECIFIED;
+        }
+        return switch (mResponse.getResult()) {
+            case UPDATE_FCM_TOKEN_RESULT_UNSPECIFIED -> FcmTokenResult.RESULT_UNSPECIFIED;
+            case UPDATE_FCM_TOKEN_RESULT_SUCCESS -> FcmTokenResult.RESULT_SUCCESS;
+            case UPDATE_FCM_TOKEN_RESULT_FAILURE -> FcmTokenResult.RESULT_FAILURE;
+            default -> throw new IllegalStateException(
+                    "Unexpected update FCM result: " + mResponse.getResult());
+        };
+    }
+}
diff --git a/DeviceLockController/tests/robolectric/src/com/android/devicelockcontroller/provision/grpc/impl/DeviceCheckinClientImplTest.java b/DeviceLockController/tests/robolectric/src/com/android/devicelockcontroller/provision/grpc/impl/DeviceCheckinClientImplTest.java
index 6ed66db..d3d3c01 100644
--- a/DeviceLockController/tests/robolectric/src/com/android/devicelockcontroller/provision/grpc/impl/DeviceCheckinClientImplTest.java
+++ b/DeviceLockController/tests/robolectric/src/com/android/devicelockcontroller/provision/grpc/impl/DeviceCheckinClientImplTest.java
@@ -41,11 +41,14 @@
 import com.android.devicelockcontroller.proto.PauseDeviceProvisioningResponse;
 import com.android.devicelockcontroller.proto.ReportDeviceProvisionStateRequest;
 import com.android.devicelockcontroller.proto.ReportDeviceProvisionStateResponse;
+import com.android.devicelockcontroller.proto.UpdateFcmTokenRequest;
+import com.android.devicelockcontroller.proto.UpdateFcmTokenResponse;
 import com.android.devicelockcontroller.provision.grpc.DeviceCheckInClient;
 import com.android.devicelockcontroller.provision.grpc.GetDeviceCheckInStatusGrpcResponse;
 import com.android.devicelockcontroller.provision.grpc.IsDeviceInApprovedCountryGrpcResponse;
 import com.android.devicelockcontroller.provision.grpc.PauseDeviceProvisioningGrpcResponse;
 import com.android.devicelockcontroller.provision.grpc.ReportDeviceProvisionStateGrpcResponse;
+import com.android.devicelockcontroller.provision.grpc.UpdateFcmTokenGrpcResponse;
 
 import io.grpc.CallOptions;
 import io.grpc.Channel;
@@ -710,6 +713,127 @@
     }
 
     @Test
+    public void updateFcmToken_succeeds() throws Exception {
+        // GIVEN the service succeeds through the default network
+        mGrpcCleanup.register(InProcessServerBuilder
+                .forName(mDefaultNetworkServerName)
+                .directExecutor()
+                .addService(makeSucceedingService())
+                .build()
+                .start());
+
+        // WHEN we update FCM token
+        AtomicReference<UpdateFcmTokenGrpcResponse> response = new AtomicReference<>();
+        mBgExecutor.submit(() -> response.set(
+                mDeviceCheckInClientImpl.updateFcmToken(
+                        new ArraySet<>(), TEST_FCM_TOKEN))).get();
+
+        // THEN the response is successful
+        assertThat(response.get().isSuccessful()).isTrue();
+        assertThat(mReceivedFcmToken).isEqualTo(TEST_FCM_TOKEN);
+    }
+
+    @Test
+    public void updateFcmToken_noDefaultConnectivity_fallsBackToNonVpn()
+            throws Exception {
+        // GIVEN a non-VPN network is connected with connectivity
+        Set<ConnectivityManager.NetworkCallback> networkCallbacks =
+                mShadowConnectivityManager.getNetworkCallbacks();
+        for (ConnectivityManager.NetworkCallback callback : networkCallbacks) {
+            NetworkCapabilities capabilities =
+                    Shadows.shadowOf(new NetworkCapabilities()).addCapability(
+                            NET_CAPABILITY_VALIDATED);
+            callback.onCapabilitiesChanged(mNonVpnNetwork, capabilities);
+        }
+
+        // GIVEN the service fails through the default network and succeeds through the non-VPN
+        // network
+        mGrpcCleanup.register(InProcessServerBuilder
+                .forName(mDefaultNetworkServerName)
+                .directExecutor()
+                .addService(makeFailingService())
+                .build()
+                .start());
+        mGrpcCleanup.register(InProcessServerBuilder
+                .forName(mNonVpnServerName)
+                .directExecutor()
+                .addService(makeSucceedingService())
+                .build()
+                .start());
+
+        // WHEN we update FCM token
+        AtomicReference<UpdateFcmTokenGrpcResponse> response = new AtomicReference<>();
+        mBgExecutor.submit(() -> response.set(
+                mDeviceCheckInClientImpl.updateFcmToken(
+                        new ArraySet<>(), TEST_FCM_TOKEN))).get();
+
+        // THEN the response is successful
+        assertThat(response.get().isSuccessful()).isTrue();
+        assertThat(mReceivedFcmToken).isEqualTo(TEST_FCM_TOKEN);
+    }
+
+    @Test
+    public void updateFcmToken_noConnectivityOrNonVpnNetwork_isNotSuccessful()
+            throws Exception {
+        // GIVEN non-VPN network connects and then loses connectivity
+        Set<ConnectivityManager.NetworkCallback> networkCallbacks =
+                mShadowConnectivityManager.getNetworkCallbacks();
+        for (ConnectivityManager.NetworkCallback callback : networkCallbacks) {
+            callback.onUnavailable();
+        }
+
+        // GIVEN the service fails through the default network
+        mGrpcCleanup.register(InProcessServerBuilder
+                .forName(mDefaultNetworkServerName)
+                .directExecutor()
+                .addService(makeFailingService())
+                .build()
+                .start());
+
+        // WHEN we update FCM token
+        AtomicReference<UpdateFcmTokenGrpcResponse> response = new AtomicReference<>();
+        mBgExecutor.submit(() -> response.set(
+                mDeviceCheckInClientImpl.updateFcmToken(
+                        new ArraySet<>(), TEST_FCM_TOKEN))).get();
+
+        // THEN the response is unsuccessful
+        assertThat(response.get().isSuccessful()).isFalse();
+    }
+
+    @Test
+    public void updateFcmToken_lostNonVpnConnection_isNotSuccessful()
+            throws Exception {
+        // GIVEN no connectable non-VPN networks
+        Set<ConnectivityManager.NetworkCallback> networkCallbacks =
+                mShadowConnectivityManager.getNetworkCallbacks();
+        for (ConnectivityManager.NetworkCallback callback : networkCallbacks) {
+            NetworkCapabilities capabilities =
+                    Shadows.shadowOf(new NetworkCapabilities()).addCapability(
+                            NET_CAPABILITY_VALIDATED);
+            callback.onCapabilitiesChanged(mNonVpnNetwork, capabilities);
+            callback.onLost(mNonVpnNetwork);
+        }
+
+        // GIVEN the service fails through the default network
+        mGrpcCleanup.register(InProcessServerBuilder
+                .forName(mDefaultNetworkServerName)
+                .directExecutor()
+                .addService(makeFailingService())
+                .build()
+                .start());
+
+        // WHEN we update FCM token
+        AtomicReference<UpdateFcmTokenGrpcResponse> response = new AtomicReference<>();
+        mBgExecutor.submit(() -> response.set(
+                mDeviceCheckInClientImpl.updateFcmToken(
+                        new ArraySet<>(), TEST_FCM_TOKEN))).get();
+
+        // THEN the response is unsuccessful
+        assertThat(response.get().isSuccessful()).isFalse();
+        assertThat(response.get().hasRecoverableError()).isTrue();
+    }
+
+    @Test
     public void cleanUp_unregistersNetworkCallback() {
         // WHEN we call clean up
         mDeviceCheckInClientImpl.cleanUp();
@@ -784,6 +908,17 @@
                 responseObserver.onNext(response);
                 responseObserver.onCompleted();
             }
+
+            @Override
+            public void updateFcmToken(UpdateFcmTokenRequest req,
+                    StreamObserver<UpdateFcmTokenResponse> responseObserver) {
+                mReceivedFcmToken = req.getFcmRegistrationToken();
+                UpdateFcmTokenResponse response = UpdateFcmTokenResponse
+                        .newBuilder()
+                        .build();
+                responseObserver.onNext(response);
+                responseObserver.onCompleted();
+            }
         };
     }
 
@@ -816,6 +951,13 @@
                 responseObserver.onError(new StatusRuntimeException(Status.DEADLINE_EXCEEDED));
                 responseObserver.onCompleted();
             }
+
+            @Override
+            public void updateFcmToken(UpdateFcmTokenRequest req,
+                    StreamObserver<UpdateFcmTokenResponse> responseObserver) {
+                responseObserver.onError(new StatusRuntimeException(Status.DEADLINE_EXCEEDED));
+                responseObserver.onCompleted();
+            }
         };
     }
 }