diff --git a/firebase-installations-interop/api.txt b/firebase-installations-interop/api.txt index 8f80b256520..b2a54de1051 100644 --- a/firebase-installations-interop/api.txt +++ b/firebase-installations-interop/api.txt @@ -5,7 +5,7 @@ package com.google.firebase.installations { ctor public InstallationTokenResult(); method @NonNull public static com.google.firebase.installations.InstallationTokenResult.Builder builder(); method @NonNull public abstract String getToken(); - method @NonNull public abstract long getTokenExpirationTimestampMillis(); + method @NonNull public abstract long getTokenExpirationInSecs(); method @NonNull public abstract com.google.firebase.installations.InstallationTokenResult.Builder toBuilder(); } @@ -13,7 +13,7 @@ package com.google.firebase.installations { ctor public InstallationTokenResult.Builder(); method @NonNull public abstract com.google.firebase.installations.InstallationTokenResult build(); method @NonNull public abstract com.google.firebase.installations.InstallationTokenResult.Builder setToken(@NonNull String); - method @NonNull public abstract com.google.firebase.installations.InstallationTokenResult.Builder setTokenExpirationTimestampMillis(@NonNull long); + method @NonNull public abstract com.google.firebase.installations.InstallationTokenResult.Builder setTokenExpirationInSecs(long); } } diff --git a/firebase-installations-interop/src/main/java/com/google/firebase/installations/FirebaseInstallationsApi.java b/firebase-installations-interop/src/main/java/com/google/firebase/installations/FirebaseInstallationsApi.java index 6947d7ec037..7ecdb6a9961 100644 --- a/firebase-installations-interop/src/main/java/com/google/firebase/installations/FirebaseInstallationsApi.java +++ b/firebase-installations-interop/src/main/java/com/google/firebase/installations/FirebaseInstallationsApi.java @@ -14,7 +14,11 @@ package com.google.firebase.installations; +import static java.lang.annotation.RetentionPolicy.SOURCE; + +import androidx.annotation.IntDef; import com.google.android.gms.tasks.Task; +import java.lang.annotation.Retention; /** * This is an interface of {@code FirebaseInstallations} that is only exposed to 2p via component @@ -24,6 +28,21 @@ */ public interface FirebaseInstallationsApi { + /** Specifies the options to get a FIS AuthToken. */ + @IntDef({DO_NOT_FORCE_REFRESH, FORCE_REFRESH}) + @Retention(SOURCE) + @interface AuthTokenOption {} + /** + * AuthToken is not refreshed until requested by the developer or if one doesn't exist, is expired + * or about to expire. + */ + int DO_NOT_FORCE_REFRESH = 0; + /** + * AuthToken is forcefully refreshed on calling the {@link + * FirebaseInstallationsApi#getAuthToken(int)}. + */ + int FORCE_REFRESH = 1; + /** * Async function that returns a globally unique identifier of this Firebase app installation. * This is a url-safe base64 string of a 128-bit integer. @@ -31,7 +50,7 @@ public interface FirebaseInstallationsApi { Task getId(); /** Async function that returns a auth token(public key) of this Firebase app installation. */ - Task getAuthToken(boolean forceRefresh); + Task getAuthToken(@AuthTokenOption int authTokenOption); /** * Async function that deletes this Firebase app installation from Firebase backend. This call diff --git a/firebase-installations-interop/src/main/java/com/google/firebase/installations/InstallationTokenResult.java b/firebase-installations-interop/src/main/java/com/google/firebase/installations/InstallationTokenResult.java index 5cd761299eb..94266bcc9f2 100644 --- a/firebase-installations-interop/src/main/java/com/google/firebase/installations/InstallationTokenResult.java +++ b/firebase-installations-interop/src/main/java/com/google/firebase/installations/InstallationTokenResult.java @@ -25,11 +25,10 @@ public abstract class InstallationTokenResult { @NonNull public abstract String getToken(); /** - * The amount of time, in milliseconds, before the auth-token expires for this Firebase - * Installation. + * The amount of time, in seconds, before the auth-token expires for this Firebase Installation. */ @NonNull - public abstract long getTokenExpirationTimestampMillis(); + public abstract long getTokenExpirationInSecs(); @NonNull public abstract Builder toBuilder(); @@ -46,7 +45,7 @@ public abstract static class Builder { public abstract Builder setToken(@NonNull String value); @NonNull - public abstract Builder setTokenExpirationTimestampMillis(@NonNull long value); + public abstract Builder setTokenExpirationInSecs(long value); @NonNull public abstract InstallationTokenResult build(); diff --git a/firebase-installations/api.txt b/firebase-installations/api.txt index 6cc5d9bb863..fed20da199e 100644 --- a/firebase-installations/api.txt +++ b/firebase-installations/api.txt @@ -3,7 +3,7 @@ package com.google.firebase.installations { public class FirebaseInstallations { method @NonNull public Task delete(); - method @NonNull public Task getAuthToken(boolean); + method @NonNull public Task getAuthToken(int); method @NonNull public Task getId(); method @NonNull public static com.google.firebase.installations.FirebaseInstallations getInstance(); method @NonNull public static com.google.firebase.installations.FirebaseInstallations getInstance(@NonNull FirebaseApp); diff --git a/firebase-installations/src/androidTest/java/com/google/firebase/installations/FirebaseInstallationsInstrumentedTest.java b/firebase-installations/src/androidTest/java/com/google/firebase/installations/FirebaseInstallationsInstrumentedTest.java index 781747f5a5e..502bef1b66a 100644 --- a/firebase-installations/src/androidTest/java/com/google/firebase/installations/FirebaseInstallationsInstrumentedTest.java +++ b/firebase-installations/src/androidTest/java/com/google/firebase/installations/FirebaseInstallationsInstrumentedTest.java @@ -15,21 +15,31 @@ package com.google.firebase.installations; import static com.google.common.truth.Truth.assertWithMessage; +import static com.google.firebase.installations.FisAndroidTestConstants.TEST_API_KEY; import static com.google.firebase.installations.FisAndroidTestConstants.TEST_APP_ID_1; import static com.google.firebase.installations.FisAndroidTestConstants.TEST_AUTH_TOKEN; +import static com.google.firebase.installations.FisAndroidTestConstants.TEST_AUTH_TOKEN_2; +import static com.google.firebase.installations.FisAndroidTestConstants.TEST_AUTH_TOKEN_3; +import static com.google.firebase.installations.FisAndroidTestConstants.TEST_AUTH_TOKEN_4; import static com.google.firebase.installations.FisAndroidTestConstants.TEST_CREATION_TIMESTAMP_1; +import static com.google.firebase.installations.FisAndroidTestConstants.TEST_CREATION_TIMESTAMP_2; import static com.google.firebase.installations.FisAndroidTestConstants.TEST_FID_1; import static com.google.firebase.installations.FisAndroidTestConstants.TEST_PROJECT_ID; import static com.google.firebase.installations.FisAndroidTestConstants.TEST_REFRESH_TOKEN; import static com.google.firebase.installations.FisAndroidTestConstants.TEST_TOKEN_EXPIRATION_TIMESTAMP; +import static com.google.firebase.installations.FisAndroidTestConstants.TEST_TOKEN_EXPIRATION_TIMESTAMP_2; import static org.junit.Assert.fail; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import androidx.test.core.app.ApplicationProvider; import androidx.test.runner.AndroidJUnit4; import com.google.android.gms.common.util.Clock; +import com.google.android.gms.tasks.Task; import com.google.android.gms.tasks.Tasks; import com.google.firebase.FirebaseApp; import com.google.firebase.FirebaseOptions; @@ -40,7 +50,7 @@ import com.google.firebase.installations.remote.InstallationResponse; import java.util.concurrent.ExecutionException; import java.util.concurrent.ExecutorService; -import java.util.concurrent.SynchronousQueue; +import java.util.concurrent.LinkedBlockingQueue; import java.util.concurrent.ThreadPoolExecutor; import java.util.concurrent.TimeUnit; import org.junit.After; @@ -49,6 +59,7 @@ import org.junit.Test; import org.junit.runner.RunWith; import org.junit.runners.MethodSorters; +import org.mockito.AdditionalAnswers; import org.mockito.Mock; import org.mockito.MockitoAnnotations; @@ -68,19 +79,60 @@ public class FirebaseInstallationsInstrumentedTest { @Mock private PersistedFid persistedFidReturnsError; @Mock private Utils mockUtils; @Mock private Clock mockClock; + @Mock private PersistedFid mockPersistedFid; + + private static final PersistedFidEntry REGISTERED_FID_ENTRY = + PersistedFidEntry.builder() + .setFirebaseInstallationId(TEST_FID_1) + .setAuthToken(TEST_AUTH_TOKEN) + .setRefreshToken(TEST_REFRESH_TOKEN) + .setTokenCreationEpochInSecs(TEST_CREATION_TIMESTAMP_2) + .setExpiresInSecs(TEST_TOKEN_EXPIRATION_TIMESTAMP) + .setRegistrationStatus(PersistedFid.RegistrationStatus.REGISTERED) + .build(); + + private static final PersistedFidEntry EXPIRED_AUTH_TOKEN_ENTRY = + PersistedFidEntry.builder() + .setFirebaseInstallationId(TEST_FID_1) + .setAuthToken(TEST_AUTH_TOKEN) + .setRefreshToken(TEST_REFRESH_TOKEN) + .setTokenCreationEpochInSecs(TEST_CREATION_TIMESTAMP_2) + .setExpiresInSecs(TEST_TOKEN_EXPIRATION_TIMESTAMP_2) + .setRegistrationStatus(PersistedFid.RegistrationStatus.REGISTERED) + .build(); + + private static final PersistedFidEntry UNREGISTERED_FID_ENTRY = + PersistedFidEntry.builder() + .setFirebaseInstallationId(TEST_FID_1) + .setAuthToken("") + .setRefreshToken("") + .setTokenCreationEpochInSecs(TEST_CREATION_TIMESTAMP_2) + .setExpiresInSecs(0) + .setRegistrationStatus(PersistedFid.RegistrationStatus.UNREGISTERED) + .build(); + + private static final PersistedFidEntry UPDATED_AUTH_TOKEN_FID_ENTRY = + PersistedFidEntry.builder() + .setFirebaseInstallationId(TEST_FID_1) + .setAuthToken(TEST_AUTH_TOKEN_2) + .setRefreshToken(TEST_REFRESH_TOKEN) + .setTokenCreationEpochInSecs(TEST_CREATION_TIMESTAMP_2) + .setExpiresInSecs(TEST_TOKEN_EXPIRATION_TIMESTAMP) + .setRegistrationStatus(PersistedFid.RegistrationStatus.REGISTERED) + .build(); @Before public void setUp() throws FirebaseInstallationServiceException { MockitoAnnotations.initMocks(this); FirebaseApp.clearInstancesForTest(); - executor = new ThreadPoolExecutor(0, 2, 10L, TimeUnit.SECONDS, new SynchronousQueue<>()); + executor = new ThreadPoolExecutor(0, 1, 30L, TimeUnit.SECONDS, new LinkedBlockingQueue<>()); firebaseApp = FirebaseApp.initializeApp( ApplicationProvider.getApplicationContext(), new FirebaseOptions.Builder() .setApplicationId(TEST_APP_ID_1) .setProjectId(TEST_PROJECT_ID) - .setApiKey("api_key") + .setApiKey(TEST_API_KEY) .build()); persistedFid = new PersistedFid(firebaseApp); when(backendClientReturnsOk.createFirebaseInstallation( @@ -92,9 +144,16 @@ public void setUp() throws FirebaseInstallationServiceException { .setAuthToken( InstallationTokenResult.builder() .setToken(TEST_AUTH_TOKEN) - .setTokenExpirationTimestampMillis(TEST_TOKEN_EXPIRATION_TIMESTAMP) + .setTokenExpirationInSecs(TEST_TOKEN_EXPIRATION_TIMESTAMP) .build()) .build()); + when(backendClientReturnsOk.generateAuthToken( + anyString(), anyString(), anyString(), anyString())) + .thenReturn( + InstallationTokenResult.builder() + .setToken(TEST_AUTH_TOKEN_2) + .setTokenExpirationInSecs(TEST_TOKEN_EXPIRATION_TIMESTAMP) + .build()); when(backendClientReturnsError.createFirebaseInstallation( anyString(), anyString(), anyString(), anyString())) .thenThrow( @@ -206,15 +265,222 @@ public void testGetId_PersistedFidError_BackendOk() throws InterruptedException // Expect exception try { Tasks.await(firebaseInstallations.getId()); - fail(); + fail("Could not update local storage."); } catch (ExecutionException expected) { - Throwable cause = expected.getCause(); assertWithMessage("Exception class doesn't match") - .that(cause) + .that(expected) + .hasCauseThat() .isInstanceOf(FirebaseInstallationsException.class); assertWithMessage("Exception status doesn't match") - .that(((FirebaseInstallationsException) cause).getStatus()) + .that(((FirebaseInstallationsException) expected.getCause()).getStatus()) .isEqualTo(FirebaseInstallationsException.Status.CLIENT_ERROR); } } + + @Test + public void testGetAuthToken_fidDoesNotExist_successful() throws Exception { + FirebaseInstallations firebaseInstallations = + new FirebaseInstallations( + mockClock, executor, firebaseApp, backendClientReturnsOk, persistedFid, mockUtils); + + Tasks.await(firebaseInstallations.getAuthToken(FirebaseInstallationsApi.DO_NOT_FORCE_REFRESH)); + + PersistedFidEntry entryValue = persistedFid.readPersistedFidEntryValue(); + assertWithMessage("Persisted Auth Token doesn't match") + .that(entryValue.getAuthToken()) + .isEqualTo(TEST_AUTH_TOKEN); + } + + @Test + public void testGetAuthToken_PersistedFidError_failure() throws Exception { + FirebaseInstallations firebaseInstallations = + new FirebaseInstallations( + mockClock, + executor, + firebaseApp, + backendClientReturnsOk, + persistedFidReturnsError, + mockUtils); + + // Expect exception + try { + Tasks.await( + firebaseInstallations.getAuthToken(FirebaseInstallationsApi.DO_NOT_FORCE_REFRESH)); + fail("Could not update local storage."); + } catch (ExecutionException expected) { + assertWithMessage("Exception class doesn't match") + .that(expected) + .hasCauseThat() + .isInstanceOf(FirebaseInstallationsException.class); + assertWithMessage("Exception status doesn't match") + .that(((FirebaseInstallationsException) expected.getCause()).getStatus()) + .isEqualTo(FirebaseInstallationsException.Status.SDK_INTERNAL_ERROR); + } + } + + @Test + public void testGetAuthToken_fidExists_successful() throws Exception { + when(mockPersistedFid.readPersistedFidEntryValue()).thenReturn(REGISTERED_FID_ENTRY); + FirebaseInstallations firebaseInstallations = + new FirebaseInstallations( + mockClock, executor, firebaseApp, backendClientReturnsOk, mockPersistedFid, mockUtils); + + InstallationTokenResult installationTokenResult = + Tasks.await( + firebaseInstallations.getAuthToken(FirebaseInstallationsApi.DO_NOT_FORCE_REFRESH)); + + assertWithMessage("Persisted Auth Token doesn't match") + .that(installationTokenResult.getToken()) + .isEqualTo(TEST_AUTH_TOKEN); + } + + @Test + public void testGetAuthToken_expiredAuthToken_fetchedNewTokenFromFIS() throws Exception { + when(mockPersistedFid.readPersistedFidEntryValue()).thenReturn(EXPIRED_AUTH_TOKEN_ENTRY); + FirebaseInstallations firebaseInstallations = + new FirebaseInstallations( + mockClock, executor, firebaseApp, backendClientReturnsOk, mockPersistedFid, mockUtils); + + InstallationTokenResult installationTokenResult = + Tasks.await( + firebaseInstallations.getAuthToken(FirebaseInstallationsApi.DO_NOT_FORCE_REFRESH)); + + assertWithMessage("Persisted Auth Token doesn't match") + .that(installationTokenResult.getToken()) + .isEqualTo(TEST_AUTH_TOKEN_2); + } + + @Test + public void testGetAuthToken_unregisteredFid_fetchedNewTokenFromFIS() throws Exception { + // Using mockPersistedFid to ensure the order of returning persistedFidEntry. This test + // validates that getAuthToken calls getId to ensure FID registration and returns a valid auth + // token. + when(mockPersistedFid.readPersistedFidEntryValue()) + .thenReturn(UNREGISTERED_FID_ENTRY, REGISTERED_FID_ENTRY); + FirebaseInstallations firebaseInstallations = + new FirebaseInstallations( + mockClock, executor, firebaseApp, backendClientReturnsOk, mockPersistedFid, mockUtils); + + InstallationTokenResult installationTokenResult = + Tasks.await( + firebaseInstallations.getAuthToken(FirebaseInstallationsApi.DO_NOT_FORCE_REFRESH)); + + assertWithMessage("Persisted Auth Token doesn't match") + .that(installationTokenResult.getToken()) + .isEqualTo(TEST_AUTH_TOKEN); + } + + @Test + public void testGetAuthToken_serverError_failure() throws Exception { + when(mockPersistedFid.readPersistedFidEntryValue()).thenReturn(REGISTERED_FID_ENTRY); + when(backendClientReturnsError.generateAuthToken( + anyString(), anyString(), anyString(), anyString())) + .thenThrow( + new FirebaseInstallationServiceException( + "Server Error", FirebaseInstallationServiceException.Status.SERVER_ERROR)); + FirebaseInstallations firebaseInstallations = + new FirebaseInstallations( + mockClock, + executor, + firebaseApp, + backendClientReturnsError, + mockPersistedFid, + mockUtils); + + // Expect exception + try { + Tasks.await(firebaseInstallations.getAuthToken(FirebaseInstallationsApi.FORCE_REFRESH)); + fail("getAuthToken() failed due to Server Error."); + } catch (ExecutionException expected) { + assertWithMessage("Exception class doesn't match") + .that(expected) + .hasCauseThat() + .isInstanceOf(FirebaseInstallationsException.class); + assertWithMessage("Exception status doesn't match") + .that(((FirebaseInstallationsException) expected.getCause()).getStatus()) + .isEqualTo(FirebaseInstallationsException.Status.SDK_INTERNAL_ERROR); + } + } + + @Test + public void testGetAuthToken_multipleCallsDoNotForceRefresh_fetchedNewTokenOnce() + throws Exception { + // Using mockPersistedFid to ensure the order of returning persistedFidEntry to 2 tasks + // triggered simultaneously. Task2 waits for Task1 to complete. On Task1 completion, task2 reads + // the UPDATED_AUTH_TOKEN_FID_ENTRY by Task1 on execution. + when(mockPersistedFid.readPersistedFidEntryValue()) + .thenReturn( + EXPIRED_AUTH_TOKEN_ENTRY, + EXPIRED_AUTH_TOKEN_ENTRY, + EXPIRED_AUTH_TOKEN_ENTRY, + UPDATED_AUTH_TOKEN_FID_ENTRY); + FirebaseInstallations firebaseInstallations = + new FirebaseInstallations( + mockClock, executor, firebaseApp, backendClientReturnsOk, mockPersistedFid, mockUtils); + + // Call getAuthToken multiple times with DO_NOT_FORCE_REFRESH option + Task task1 = + firebaseInstallations.getAuthToken(FirebaseInstallationsApi.DO_NOT_FORCE_REFRESH); + Task task2 = + firebaseInstallations.getAuthToken(FirebaseInstallationsApi.DO_NOT_FORCE_REFRESH); + + Tasks.await(Tasks.whenAllComplete(task1, task2)); + + assertWithMessage("Persisted Auth Token doesn't match") + .that(task1.getResult().getToken()) + .isEqualTo(TEST_AUTH_TOKEN_2); + assertWithMessage("Persisted Auth Token doesn't match") + .that(task2.getResult().getToken()) + .isEqualTo(TEST_AUTH_TOKEN_2); + verify(backendClientReturnsOk, times(1)) + .generateAuthToken(TEST_API_KEY, TEST_FID_1, TEST_PROJECT_ID, TEST_REFRESH_TOKEN); + } + + @Test + public void testGetAuthToken_multipleCallsForceRefresh_fetchedNewTokenTwice() throws Exception { + when(mockPersistedFid.readPersistedFidEntryValue()).thenReturn(REGISTERED_FID_ENTRY); + // Use a mock ServiceClient for network calls with delay(1000ms) to ensure first task is not + // completed before the second task starts. Hence, we can test multiple calls to getAuthToken() + // and verify one task waits for another task to complete. + + doAnswer( + AdditionalAnswers.answersWithDelay( + 1000, + (unused) -> + InstallationTokenResult.builder() + .setToken(TEST_AUTH_TOKEN_3) + .setTokenExpirationInSecs(TEST_TOKEN_EXPIRATION_TIMESTAMP) + .build())) + .doAnswer( + AdditionalAnswers.answersWithDelay( + 1000, + (unused) -> + InstallationTokenResult.builder() + .setToken(TEST_AUTH_TOKEN_4) + .setTokenExpirationInSecs(TEST_TOKEN_EXPIRATION_TIMESTAMP) + .build())) + .when(backendClientReturnsOk) + .generateAuthToken(anyString(), anyString(), anyString(), anyString()); + + FirebaseInstallations firebaseInstallations = + new FirebaseInstallations( + mockClock, executor, firebaseApp, backendClientReturnsOk, mockPersistedFid, mockUtils); + + // Call getAuthToken multiple times with FORCE_REFRESH option. + Task task1 = + firebaseInstallations.getAuthToken(FirebaseInstallationsApi.FORCE_REFRESH); + Task task2 = + firebaseInstallations.getAuthToken(FirebaseInstallationsApi.FORCE_REFRESH); + Tasks.await(Tasks.whenAllComplete(task1, task2)); + + // As we cannot ensure which task got executed first, verifying with both expected values + assertWithMessage("Persisted Auth Token doesn't match") + .that(task1.getResult().getToken()) + .isEqualTo(TEST_AUTH_TOKEN_3); + assertWithMessage("Persisted Auth Token doesn't match") + .that(task2.getResult().getToken()) + .isEqualTo(TEST_AUTH_TOKEN_4); + verify(backendClientReturnsOk, times(2)) + .generateAuthToken(TEST_API_KEY, TEST_FID_1, TEST_PROJECT_ID, TEST_REFRESH_TOKEN); + } } diff --git a/firebase-installations/src/androidTest/java/com/google/firebase/installations/FisAndroidTestConstants.java b/firebase-installations/src/androidTest/java/com/google/firebase/installations/FisAndroidTestConstants.java index 97f3989f1de..e77a2b1155b 100644 --- a/firebase-installations/src/androidTest/java/com/google/firebase/installations/FisAndroidTestConstants.java +++ b/firebase-installations/src/androidTest/java/com/google/firebase/installations/FisAndroidTestConstants.java @@ -20,6 +20,11 @@ public final class FisAndroidTestConstants { public static final String TEST_PROJECT_ID = "777777777777"; public static final String TEST_AUTH_TOKEN = "fis.auth.token"; + public static final String TEST_AUTH_TOKEN_2 = "fis.auth.token2"; + public static final String TEST_AUTH_TOKEN_3 = "fis.auth.token3"; + public static final String TEST_AUTH_TOKEN_4 = "fis.auth.token4"; + + public static final String TEST_API_KEY = "apiKey"; public static final String TEST_REFRESH_TOKEN = "1:test-refresh-token"; @@ -27,6 +32,7 @@ public final class FisAndroidTestConstants { public static final String TEST_APP_ID_2 = "1:987654321:android:abcdef"; public static final long TEST_TOKEN_EXPIRATION_TIMESTAMP = 1000L; + public static final long TEST_TOKEN_EXPIRATION_TIMESTAMP_2 = 2000L; public static final long TEST_CREATION_TIMESTAMP_1 = 2000L; public static final long TEST_CREATION_TIMESTAMP_2 = 2000L; diff --git a/firebase-installations/src/main/java/com/google/firebase/installations/AwaitListener.java b/firebase-installations/src/main/java/com/google/firebase/installations/AwaitListener.java new file mode 100644 index 00000000000..c7ecab4396a --- /dev/null +++ b/firebase-installations/src/main/java/com/google/firebase/installations/AwaitListener.java @@ -0,0 +1,38 @@ +// Copyright 2019 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.installations; + +import androidx.annotation.NonNull; +import com.google.android.gms.tasks.OnCompleteListener; +import com.google.android.gms.tasks.Task; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; + +final class AwaitListener implements OnCompleteListener { + private final CountDownLatch latch = new CountDownLatch(1); + + public void onSuccess() { + latch.countDown(); + } + + public boolean await(long timeout, TimeUnit unit) throws InterruptedException { + return latch.await(timeout, unit); + } + + @Override + public void onComplete(@NonNull Task task) { + latch.countDown(); + } +} diff --git a/firebase-installations/src/main/java/com/google/firebase/installations/FirebaseInstallations.java b/firebase-installations/src/main/java/com/google/firebase/installations/FirebaseInstallations.java index 4af7d5578a0..4072bdc1c06 100644 --- a/firebase-installations/src/main/java/com/google/firebase/installations/FirebaseInstallations.java +++ b/firebase-installations/src/main/java/com/google/firebase/installations/FirebaseInstallations.java @@ -29,8 +29,8 @@ import com.google.firebase.installations.remote.FirebaseInstallationServiceClient; import com.google.firebase.installations.remote.FirebaseInstallationServiceException; import com.google.firebase.installations.remote.InstallationResponse; -import java.util.concurrent.Executor; -import java.util.concurrent.SynchronousQueue; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.LinkedBlockingQueue; import java.util.concurrent.ThreadPoolExecutor; import java.util.concurrent.TimeUnit; @@ -50,15 +50,18 @@ public class FirebaseInstallations implements FirebaseInstallationsApi { private final FirebaseApp firebaseApp; private final FirebaseInstallationServiceClient serviceClient; private final PersistedFid persistedFid; - private final Executor executor; + private final ExecutorService executor; private final Clock clock; private final Utils utils; + private static final long AUTH_TOKEN_EXPIRATION_BUFFER_IN_SECS = 3600L; // 1 hour + private static final long AWAIT_TIMEOUT_IN_SECS = 10L; + /** package private constructor. */ FirebaseInstallations(FirebaseApp firebaseApp) { this( DefaultClock.getInstance(), - new ThreadPoolExecutor(0, 1, 30L, TimeUnit.SECONDS, new SynchronousQueue<>()), + new ThreadPoolExecutor(0, 1, 30L, TimeUnit.SECONDS, new LinkedBlockingQueue<>()), firebaseApp, new FirebaseInstallationServiceClient(), new PersistedFid(firebaseApp), @@ -67,7 +70,7 @@ public class FirebaseInstallations implements FirebaseInstallationsApi { FirebaseInstallations( Clock clock, - Executor executor, + ExecutorService executor, FirebaseApp firebaseApp, FirebaseInstallationServiceClient serviceClient, PersistedFid persistedFid, @@ -110,16 +113,39 @@ public static FirebaseInstallations getInstance(@NonNull FirebaseApp app) { @NonNull @Override public Task getId() { + return getId(null); + } + + /** + * Returns a globally unique identifier of this Firebase app installation.Also, updates the {@link + * AwaitListener} when the FID registration is complete. + */ + private Task getId(AwaitListener awaitListener) { return Tasks.call(executor, this::getPersistedFid) .continueWith(orElse(this::createAndPersistNewFid)) - .onSuccessTask(this::registerFidIfNecessary); + .onSuccessTask( + persistedFidEntry -> registerFidIfNecessary(persistedFidEntry, awaitListener)); } - /** Returns a auth token(public key) of this Firebase app installation. */ + /** + * Returns a valid authentication token for the Firebase installation. Generates a new token if + * one doesn't exist, is expired or about to expire. + * + *

Should only be called if the Firebase Installation is registered. + * + * @param authTokenOption Options to get FIS Auth Token either by force refreshing or not. Accepts + * {@link AuthTokenOption} values. Default value of AuthTokenOption = DO_NOT_FORCE_REFRESH. + */ @NonNull @Override - public Task getAuthToken(boolean forceRefresh) { - return Tasks.forResult(InstallationTokenResult.builder().build()); + public synchronized Task getAuthToken( + @AuthTokenOption int authTokenOption) { + AwaitListener awaitListener = new AwaitListener(); + return getId(awaitListener) + .continueWith( + executor, + awaitFidRegistration( + () -> refreshAuthTokenIfNecessary(authTokenOption), awaitListener)); } /** @@ -145,6 +171,12 @@ String getName() { return firebaseApp.getName(); } + /** + * Returns the {@link PersistedFidEntry} from shared prefs. + * + * @throws {@link FirebaseInstallationsException} when shared pref is empty or {@link + * PersistedFidEntry} is in error state. + */ private PersistedFidEntry getPersistedFid() throws FirebaseInstallationsException { PersistedFidEntry persistedFidEntry = persistedFid.readPersistedFidEntryValue(); if (persistedFidMissingOrInErrorState(persistedFidEntry)) { @@ -169,6 +201,17 @@ private static Continuation orElse(@NonNull Supplier supplier) { }; } + @NonNull + private static Continuation awaitFidRegistration( + @NonNull Supplier supplier, AwaitListener listener) { + return t -> { + // Waiting for Task that registers FID on the FIS Servers + listener.await(AWAIT_TIMEOUT_IN_SECS, TimeUnit.SECONDS); + return supplier.get(); + }; + } + + /** Creates a random FID and persists it in the shared prefs with UNREGISTERED status. */ private PersistedFidEntry createAndPersistNewFid() throws FirebaseInstallationsException { String fid = utils.createRandomFid(); persistFid(fid); @@ -191,17 +234,45 @@ private void persistFid(String fid) throws FirebaseInstallationsException { } } - private Task registerFidIfNecessary(PersistedFidEntry persistedFidEntry) { + /** + * Registers the FID with FIS servers if FID is in UNREGISTERED state. + * + *

Updates FID registration status to PENDING to avoid multiple network calls to FIS Servers. + */ + private Task registerFidIfNecessary( + PersistedFidEntry persistedFidEntry, AwaitListener listener) { String fid = persistedFidEntry.getFirebaseInstallationId(); // Check if the fid is unregistered if (persistedFidEntry.getRegistrationStatus() == RegistrationStatus.UNREGISTERED) { updatePersistedFidWithPendingStatus(fid); - Tasks.call(executor, () -> registerAndSaveFid(persistedFidEntry)); + executeFidRegistration(persistedFidEntry, listener); + } else { + updateAwaitListenerIfRegisteredFid(persistedFidEntry, listener); } + return Tasks.forResult(fid); } + private void updateAwaitListenerIfRegisteredFid( + PersistedFidEntry persistedFidEntry, AwaitListener listener) { + if (listener != null + && persistedFidEntry.getRegistrationStatus() == RegistrationStatus.REGISTERED) { + listener.onSuccess(); + } + } + + /** + * Registers the FID with FIS servers in a background thread and updates the listener on + * completion. + */ + private void executeFidRegistration(PersistedFidEntry persistedFidEntry, AwaitListener listener) { + Task task = Tasks.call(executor, () -> registerAndSaveFid(persistedFidEntry)); + if (listener != null) { + task.addOnCompleteListener(listener); + } + } + private void updatePersistedFidWithPendingStatus(String fid) { persistedFid.insertOrUpdatePersistedFidEntry( PersistedFidEntry.builder() @@ -214,7 +285,7 @@ private void updatePersistedFidWithPendingStatus(String fid) { private Void registerAndSaveFid(PersistedFidEntry persistedFidEntry) throws FirebaseInstallationsException { try { - long creationTime = TimeUnit.MILLISECONDS.toSeconds(clock.currentTimeMillis()); + long creationTime = currentTimeInSecs(); InstallationResponse installationResponse = serviceClient.createFirebaseInstallation( @@ -228,8 +299,7 @@ private Void registerAndSaveFid(PersistedFidEntry persistedFidEntry) .setRegistrationStatus(RegistrationStatus.REGISTERED) .setAuthToken(installationResponse.getAuthToken().getToken()) .setRefreshToken(installationResponse.getRefreshToken()) - .setExpiresInSecs( - installationResponse.getAuthToken().getTokenExpirationTimestampMillis()) + .setExpiresInSecs(installationResponse.getAuthToken().getTokenExpirationInSecs()) .setTokenCreationEpochInSecs(creationTime) .build()); @@ -244,6 +314,92 @@ private Void registerAndSaveFid(PersistedFidEntry persistedFidEntry) } return null; } + + private InstallationTokenResult refreshAuthTokenIfNecessary(int authTokenOption) + throws FirebaseInstallationsException { + + PersistedFidEntry persistedFidEntry = persistedFid.readPersistedFidEntryValue(); + + if (!isPersistedFidRegistered(persistedFidEntry)) { + throw new FirebaseInstallationsException( + "Firebase Installation is not registered.", + FirebaseInstallationsException.Status.SDK_INTERNAL_ERROR); + } + + switch (authTokenOption) { + case FORCE_REFRESH: + return fetchAuthTokenFromServer(persistedFidEntry); + case DO_NOT_FORCE_REFRESH: + return getValidAuthToken(persistedFidEntry); + default: + throw new FirebaseInstallationsException( + "Incorrect refreshAuthTokenOption.", + FirebaseInstallationsException.Status.SDK_INTERNAL_ERROR); + } + } + + /** + * Returns a {@link InstallationTokenResult} created from the {@link PersistedFidEntry} if the + * auth token is valid else generates a new auth token by calling the FIS servers. + */ + private InstallationTokenResult getValidAuthToken(PersistedFidEntry persistedFidEntry) + throws FirebaseInstallationsException { + + return isAuthTokenExpired(persistedFidEntry) + ? fetchAuthTokenFromServer(persistedFidEntry) + : InstallationTokenResult.builder() + .setToken(persistedFidEntry.getAuthToken()) + .setTokenExpirationInSecs(persistedFidEntry.getExpiresInSecs()) + .build(); + } + + private boolean isPersistedFidRegistered(PersistedFidEntry persistedFidEntry) { + return persistedFidEntry != null + && persistedFidEntry.getRegistrationStatus() == RegistrationStatus.REGISTERED; + } + + /** Calls the FIS servers to generate an auth token for this Firebase installation. */ + private InstallationTokenResult fetchAuthTokenFromServer(PersistedFidEntry persistedFidEntry) + throws FirebaseInstallationsException { + try { + long creationTime = currentTimeInSecs(); + InstallationTokenResult tokenResult = + serviceClient.generateAuthToken( + /*apiKey= */ firebaseApp.getOptions().getApiKey(), + /*fid= */ persistedFidEntry.getFirebaseInstallationId(), + /*projectID= */ firebaseApp.getOptions().getProjectId(), + /*refreshToken= */ persistedFidEntry.getRefreshToken()); + + persistedFid.insertOrUpdatePersistedFidEntry( + PersistedFidEntry.builder() + .setFirebaseInstallationId(persistedFidEntry.getFirebaseInstallationId()) + .setRegistrationStatus(RegistrationStatus.REGISTERED) + .setAuthToken(tokenResult.getToken()) + .setRefreshToken(persistedFidEntry.getRefreshToken()) + .setExpiresInSecs(tokenResult.getTokenExpirationInSecs()) + .setTokenCreationEpochInSecs(creationTime) + .build()); + + return tokenResult; + } catch (FirebaseInstallationServiceException exception) { + throw new FirebaseInstallationsException( + "Failed to generate auth token for a Firebase Installation.", + FirebaseInstallationsException.Status.SDK_INTERNAL_ERROR); + } + } + + /** + * Checks if the FIS Auth token is expired or going to expire in next 1 hour + * (AUTH_TOKEN_EXPIRATION_BUFFER_IN_SECS). + */ + private boolean isAuthTokenExpired(PersistedFidEntry persistedFidEntry) { + return (persistedFidEntry.getTokenCreationEpochInSecs() + persistedFidEntry.getExpiresInSecs() + > currentTimeInSecs() + AUTH_TOKEN_EXPIRATION_BUFFER_IN_SECS); + } + + private long currentTimeInSecs() { + return TimeUnit.MILLISECONDS.toSeconds(clock.currentTimeMillis()); + } } interface Supplier { diff --git a/firebase-installations/src/main/java/com/google/firebase/installations/local/PersistedFid.java b/firebase-installations/src/main/java/com/google/firebase/installations/local/PersistedFid.java index 177c94fd0ad..1da104fa350 100644 --- a/firebase-installations/src/main/java/com/google/firebase/installations/local/PersistedFid.java +++ b/firebase-installations/src/main/java/com/google/firebase/installations/local/PersistedFid.java @@ -73,7 +73,7 @@ public PersistedFid(@NonNull FirebaseApp firebaseApp) { } @Nullable - public synchronized PersistedFidEntry readPersistedFidEntryValue() { + public PersistedFidEntry readPersistedFidEntryValue() { String fid = prefs.getString(getSharedPreferencesKey(FIREBASE_INSTALLATION_ID_KEY), null); int status = prefs.getInt(getSharedPreferencesKey(PERSISTED_STATUS_KEY), -1); String authToken = prefs.getString(getSharedPreferencesKey(AUTH_TOKEN_KEY), null); @@ -99,32 +99,36 @@ public synchronized PersistedFidEntry readPersistedFidEntryValue() { } @NonNull - public synchronized boolean insertOrUpdatePersistedFidEntry( - @NonNull PersistedFidEntry entryValue) { - SharedPreferences.Editor editor = prefs.edit(); - editor.putString( - getSharedPreferencesKey(FIREBASE_INSTALLATION_ID_KEY), - entryValue.getFirebaseInstallationId()); - editor.putInt( - getSharedPreferencesKey(PERSISTED_STATUS_KEY), - entryValue.getRegistrationStatus().ordinal()); - editor.putString(getSharedPreferencesKey(AUTH_TOKEN_KEY), entryValue.getAuthToken()); - editor.putString(getSharedPreferencesKey(REFRESH_TOKEN_KEY), entryValue.getRefreshToken()); - editor.putLong( - getSharedPreferencesKey(TOKEN_CREATION_TIME_IN_SECONDS_KEY), - entryValue.getTokenCreationEpochInSecs()); - editor.putLong(getSharedPreferencesKey(EXPIRES_IN_SECONDS_KEY), entryValue.getExpiresInSecs()); - return editor.commit(); + public boolean insertOrUpdatePersistedFidEntry(@NonNull PersistedFidEntry entryValue) { + synchronized (prefs) { + SharedPreferences.Editor editor = prefs.edit(); + editor.putString( + getSharedPreferencesKey(FIREBASE_INSTALLATION_ID_KEY), + entryValue.getFirebaseInstallationId()); + editor.putInt( + getSharedPreferencesKey(PERSISTED_STATUS_KEY), + entryValue.getRegistrationStatus().ordinal()); + editor.putString(getSharedPreferencesKey(AUTH_TOKEN_KEY), entryValue.getAuthToken()); + editor.putString(getSharedPreferencesKey(REFRESH_TOKEN_KEY), entryValue.getRefreshToken()); + editor.putLong( + getSharedPreferencesKey(TOKEN_CREATION_TIME_IN_SECONDS_KEY), + entryValue.getTokenCreationEpochInSecs()); + editor.putLong( + getSharedPreferencesKey(EXPIRES_IN_SECONDS_KEY), entryValue.getExpiresInSecs()); + return editor.commit(); + } } @NonNull - public synchronized boolean clear() { - SharedPreferences.Editor editor = prefs.edit(); - for (String k : FID_PREF_KEYS) { - editor.remove(getSharedPreferencesKey(k)); + public boolean clear() { + synchronized (prefs) { + SharedPreferences.Editor editor = prefs.edit(); + for (String k : FID_PREF_KEYS) { + editor.remove(getSharedPreferencesKey(k)); + } + editor.commit(); + return editor.commit(); } - editor.commit(); - return editor.commit(); } private String getSharedPreferencesKey(String key) { diff --git a/firebase-installations/src/main/java/com/google/firebase/installations/remote/FirebaseInstallationServiceClient.java b/firebase-installations/src/main/java/com/google/firebase/installations/remote/FirebaseInstallationServiceClient.java index c0105a8607b..53754fc5806 100644 --- a/firebase-installations/src/main/java/com/google/firebase/installations/remote/FirebaseInstallationServiceClient.java +++ b/firebase-installations/src/main/java/com/google/firebase/installations/remote/FirebaseInstallationServiceClient.java @@ -21,6 +21,7 @@ import java.io.InputStreamReader; import java.net.URL; import java.nio.charset.Charset; +import java.util.concurrent.TimeUnit; import java.util.zip.GZIPOutputStream; import javax.net.ssl.HttpsURLConnection; import org.json.JSONException; @@ -243,7 +244,8 @@ private InstallationResponse readCreateResponse(HttpsURLConnection conn) throws if (key.equals("token")) { installationTokenResult.setToken(reader.nextString()); } else if (key.equals("expiresIn")) { - installationTokenResult.setTokenExpirationTimestampMillis(reader.nextLong()); + installationTokenResult.setTokenExpirationInSecs( + TimeUnit.MILLISECONDS.toSeconds(reader.nextLong())); } else { reader.skipValue(); } @@ -271,7 +273,7 @@ private InstallationTokenResult readGenerateAuthTokenResponse(HttpsURLConnection if (name.equals("token")) { builder.setToken(reader.nextString()); } else if (name.equals("expiresIn")) { - builder.setTokenExpirationTimestampMillis(reader.nextLong()); + builder.setTokenExpirationInSecs(TimeUnit.MILLISECONDS.toSeconds(reader.nextLong())); } else { reader.skipValue(); }