diff --git a/firebase-installations/api.txt b/firebase-installations/api.txt index 4acee522a18..b4fd32edc8a 100644 --- a/firebase-installations/api.txt +++ b/firebase-installations/api.txt @@ -17,9 +17,12 @@ package com.google.firebase.installations { } public enum FirebaseInstallationsException.Status { - enum_constant public static final com.google.firebase.installations.FirebaseInstallationsException.Status AUTHENTICATION_ERROR; - enum_constant public static final com.google.firebase.installations.FirebaseInstallationsException.Status CLIENT_ERROR; - enum_constant public static final com.google.firebase.installations.FirebaseInstallationsException.Status SDK_INTERNAL_ERROR; + enum_constant public static final com.google.firebase.installations.FirebaseInstallationsException.Status BAD_CONFIG; + } + + public class RandomFidGenerator { + ctor public RandomFidGenerator(); + method @NonNull public String createRandomFid(); } } @@ -33,12 +36,13 @@ package com.google.firebase.installations.local { public class PersistedInstallation { ctor public PersistedInstallation(@NonNull FirebaseApp); - method @NonNull public boolean clear(); - method @NonNull public boolean insertOrUpdatePersistedInstallationEntry(@NonNull com.google.firebase.installations.local.PersistedInstallationEntry); + method public void clearForTesting(); + method @NonNull public com.google.firebase.installations.local.PersistedInstallationEntry insertOrUpdatePersistedInstallationEntry(@NonNull com.google.firebase.installations.local.PersistedInstallationEntry); method @NonNull public com.google.firebase.installations.local.PersistedInstallationEntry readPersistedInstallationEntryValue(); } public enum PersistedInstallation.RegistrationStatus { + enum_constant public static final com.google.firebase.installations.local.PersistedInstallation.RegistrationStatus ATTEMPT_MIGRATION; enum_constant public static final com.google.firebase.installations.local.PersistedInstallation.RegistrationStatus NOT_GENERATED; enum_constant public static final com.google.firebase.installations.local.PersistedInstallation.RegistrationStatus REGISTERED; enum_constant public static final com.google.firebase.installations.local.PersistedInstallation.RegistrationStatus REGISTER_ERROR; @@ -59,7 +63,15 @@ package com.google.firebase.installations.local { method public boolean isNotGenerated(); method public boolean isRegistered(); method public boolean isUnregistered(); + method public boolean shouldAttemptMigration(); method @NonNull public abstract com.google.firebase.installations.local.PersistedInstallationEntry.Builder toBuilder(); + method @NonNull public com.google.firebase.installations.local.PersistedInstallationEntry withAuthToken(@NonNull String, long, long); + method @NonNull public com.google.firebase.installations.local.PersistedInstallationEntry withClearedAuthToken(); + method @NonNull public com.google.firebase.installations.local.PersistedInstallationEntry withFisError(@NonNull String); + method @NonNull public com.google.firebase.installations.local.PersistedInstallationEntry withNoGeneratedFid(); + method @NonNull public com.google.firebase.installations.local.PersistedInstallationEntry withRegisteredFid(@NonNull String, @NonNull String, long, @Nullable String, long); + method @NonNull public com.google.firebase.installations.local.PersistedInstallationEntry withUnregisteredFid(@NonNull String); + field @NonNull public static com.google.firebase.installations.local.PersistedInstallationEntry INSTANCE; } public abstract static class PersistedInstallationEntry.Builder { @@ -107,8 +119,8 @@ package com.google.firebase.installations.remote { } public enum InstallationResponse.ResponseCode { + enum_constant public static final com.google.firebase.installations.remote.InstallationResponse.ResponseCode BAD_CONFIG; enum_constant public static final com.google.firebase.installations.remote.InstallationResponse.ResponseCode OK; - enum_constant public static final com.google.firebase.installations.remote.InstallationResponse.ResponseCode SERVER_ERROR; } public abstract class TokenResult { @@ -117,7 +129,6 @@ package com.google.firebase.installations.remote { method @Nullable public abstract com.google.firebase.installations.remote.TokenResult.ResponseCode getResponseCode(); method @Nullable public abstract String getToken(); method @NonNull public abstract long getTokenExpirationTimestamp(); - method public boolean isSuccessful(); method @NonNull public abstract com.google.firebase.installations.remote.TokenResult.Builder toBuilder(); } @@ -130,9 +141,9 @@ package com.google.firebase.installations.remote { } public enum TokenResult.ResponseCode { - enum_constant public static final com.google.firebase.installations.remote.TokenResult.ResponseCode FID_ERROR; + enum_constant public static final com.google.firebase.installations.remote.TokenResult.ResponseCode AUTH_ERROR; + enum_constant public static final com.google.firebase.installations.remote.TokenResult.ResponseCode BAD_CONFIG; enum_constant public static final com.google.firebase.installations.remote.TokenResult.ResponseCode OK; - enum_constant public static final com.google.firebase.installations.remote.TokenResult.ResponseCode REFRESH_TOKEN_ERROR; } } diff --git a/firebase-installations/src/androidTest/java/com/google/firebase/installations/FakeCalendar.java b/firebase-installations/src/androidTest/java/com/google/firebase/installations/FakeCalendar.java new file mode 100644 index 00000000000..3190d5911e8 --- /dev/null +++ b/firebase-installations/src/androidTest/java/com/google/firebase/installations/FakeCalendar.java @@ -0,0 +1,69 @@ +// 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 java.util.Calendar; + +public class FakeCalendar extends Calendar { + private long timeInMillis; + + public FakeCalendar(long initialTimeInMillis) { + timeInMillis = initialTimeInMillis; + } + + public long getTimeInMillis() { + return timeInMillis; + } + + public void setTimeInMillis(long timeInMillis) { + this.timeInMillis = timeInMillis; + } + + public void advanceTimeBySeconds(long deltaSeconds) { + timeInMillis += (deltaSeconds * 1000L); + } + + @Override + protected void computeTime() {} + + @Override + protected void computeFields() {} + + @Override + public void add(int i, int i1) {} + + @Override + public void roll(int i, boolean b) {} + + @Override + public int getMinimum(int i) { + return 0; + } + + @Override + public int getMaximum(int i) { + return 0; + } + + @Override + public int getGreatestMinimum(int i) { + return 0; + } + + @Override + public int getLeastMaximum(int i) { + return 0; + } +} 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 1f8bb1de203..4fec8c4cce5 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,16 +15,12 @@ package com.google.firebase.installations; import static com.google.common.truth.Truth.assertWithMessage; -import static com.google.firebase.installations.FisAndroidTestConstants.DEFAULT_PERSISTED_INSTALLATION_ENTRY; -import static com.google.firebase.installations.FisAndroidTestConstants.INVALID_TEST_FID; -import static com.google.firebase.installations.FisAndroidTestConstants.SERVER_ERROR_INSTALLATION_RESPONSE; 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_INSTALLATION_RESPONSE; @@ -33,18 +29,19 @@ 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 com.google.firebase.installations.FisAndroidTestConstants.TEST_TOKEN_RESULT; import static com.google.firebase.installations.local.PersistedInstallationEntrySubject.assertThat; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; import static org.junit.Assert.fail; -import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.matches; import static org.mockito.Mockito.doAnswer; -import static org.mockito.Mockito.doNothing; import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.never; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyZeroInteractions; import static org.mockito.Mockito.when; import androidx.test.core.app.ApplicationProvider; @@ -54,12 +51,16 @@ import com.google.firebase.FirebaseApp; import com.google.firebase.FirebaseException; import com.google.firebase.FirebaseOptions; +import com.google.firebase.installations.FirebaseInstallationsException.Status; import com.google.firebase.installations.local.IidStore; import com.google.firebase.installations.local.PersistedInstallation; import com.google.firebase.installations.local.PersistedInstallation.RegistrationStatus; import com.google.firebase.installations.local.PersistedInstallationEntry; import com.google.firebase.installations.remote.FirebaseInstallationServiceClient; +import com.google.firebase.installations.remote.InstallationResponse; +import com.google.firebase.installations.remote.InstallationResponse.ResponseCode; import com.google.firebase.installations.remote.TokenResult; +import java.io.IOException; import java.util.concurrent.ExecutionException; import java.util.concurrent.ExecutorService; import java.util.concurrent.LinkedBlockingQueue; @@ -86,13 +87,9 @@ public class FirebaseInstallationsInstrumentedTest { private FirebaseApp firebaseApp; private ExecutorService executor; private PersistedInstallation persistedInstallation; - @Mock private FirebaseInstallationServiceClient backendClientReturnsOk; - @Mock private FirebaseInstallationServiceClient backendClientReturnsError; - @Mock private PersistedInstallation persistedInstallationReturnsError; - @Mock private Utils mockUtils; - @Mock private PersistedInstallation mockPersistedInstallation; - @Mock private FirebaseInstallationServiceClient mockClient; + @Mock private FirebaseInstallationServiceClient mockBackend; @Mock private IidStore mockIidStore; + @Mock private RandomFidGenerator mockFidGenerator; private static final PersistedInstallationEntry REGISTERED_INSTALLATION_ENTRY = PersistedInstallationEntry.builder() @@ -104,61 +101,16 @@ public class FirebaseInstallationsInstrumentedTest { .setRegistrationStatus(PersistedInstallation.RegistrationStatus.REGISTERED) .build(); - private static final PersistedInstallationEntry REGISTERED_IID_ENTRY = - PersistedInstallationEntry.builder() - .setFirebaseInstallationId(TEST_INSTANCE_ID_1) - .setAuthToken(TEST_AUTH_TOKEN) - .setRefreshToken(TEST_REFRESH_TOKEN) - .setTokenCreationEpochInSecs(TEST_CREATION_TIMESTAMP_2) - .setExpiresInSecs(TEST_TOKEN_EXPIRATION_TIMESTAMP) - .setRegistrationStatus(PersistedInstallation.RegistrationStatus.REGISTERED) - .build(); - - private static final PersistedInstallationEntry EXPIRED_AUTH_TOKEN_ENTRY = - PersistedInstallationEntry.builder() - .setFirebaseInstallationId(TEST_FID_1) - .setAuthToken(TEST_AUTH_TOKEN) - .setRefreshToken(TEST_REFRESH_TOKEN) - .setTokenCreationEpochInSecs(TEST_CREATION_TIMESTAMP_1) - .setExpiresInSecs(TEST_TOKEN_EXPIRATION_TIMESTAMP_2) - .setRegistrationStatus(PersistedInstallation.RegistrationStatus.REGISTERED) - .build(); - - private static final PersistedInstallationEntry UNREGISTERED_INSTALLATION_ENTRY = - PersistedInstallationEntry.builder() - .setFirebaseInstallationId(TEST_FID_1) - .setAuthToken("") - .setRefreshToken("") - .setTokenCreationEpochInSecs(TEST_CREATION_TIMESTAMP_1) - .setExpiresInSecs(0) - .setRegistrationStatus(PersistedInstallation.RegistrationStatus.UNREGISTERED) - .build(); - - private static final PersistedInstallationEntry INVALID_INSTALLATION_ENTRY = - PersistedInstallationEntry.builder() - .setFirebaseInstallationId(INVALID_TEST_FID) - .setAuthToken("") - .setRefreshToken("") - .setTokenCreationEpochInSecs(TEST_CREATION_TIMESTAMP_1) - .setExpiresInSecs(0) - .setRegistrationStatus(PersistedInstallation.RegistrationStatus.UNREGISTERED) - .build(); - - private static final PersistedInstallationEntry UPDATED_AUTH_TOKEN_ENTRY = - PersistedInstallationEntry.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(PersistedInstallation.RegistrationStatus.REGISTERED) - .build(); + private FirebaseInstallations firebaseInstallations; + private Utils utils; + private FakeCalendar fakeCalendar; @Before - public void setUp() throws FirebaseException { + public void setUp() throws FirebaseException, IOException { MockitoAnnotations.initMocks(this); FirebaseApp.clearInstancesForTest(); executor = new ThreadPoolExecutor(0, 1, 30L, TimeUnit.SECONDS, new LinkedBlockingQueue<>()); + fakeCalendar = new FakeCalendar(5000000L); firebaseApp = FirebaseApp.initializeApp( ApplicationProvider.getApplicationContext(), @@ -168,69 +120,188 @@ public void setUp() throws FirebaseException { .setApiKey(TEST_API_KEY) .build()); persistedInstallation = new PersistedInstallation(firebaseApp); + persistedInstallation.clearForTesting(); - when(backendClientReturnsOk.createFirebaseInstallation( - anyString(), anyString(), anyString(), anyString())) - .thenReturn(TEST_INSTALLATION_RESPONSE); - // Mocks successful auth token generation - when(backendClientReturnsOk.generateAuthToken( - anyString(), anyString(), anyString(), anyString())) - .thenReturn(TEST_TOKEN_RESULT); + utils = new Utils(fakeCalendar); + firebaseInstallations = + new FirebaseInstallations( + executor, + firebaseApp, + mockBackend, + persistedInstallation, + utils, + mockIidStore, + mockFidGenerator); - when(persistedInstallationReturnsError.insertOrUpdatePersistedInstallationEntry(any())) - .thenReturn(false); - when(persistedInstallationReturnsError.readPersistedInstallationEntryValue()) - .thenReturn(DEFAULT_PERSISTED_INSTALLATION_ENTRY); + when(mockFidGenerator.createRandomFid()).thenReturn(TEST_FID_1); + } - when(backendClientReturnsError.createFirebaseInstallation( - anyString(), anyString(), anyString(), anyString())) - .thenThrow(new FirebaseException("SDK Error")); + @After + public void cleanUp() { + persistedInstallation.clearForTesting(); + try { + executor.awaitTermination(250, TimeUnit.MILLISECONDS); + } catch (InterruptedException e) { - when(mockUtils.createRandomFid()).thenReturn(TEST_FID_1); - when(mockUtils.currentTimeInSecs()).thenReturn(TEST_CREATION_TIMESTAMP_2); + } + } - // Mocks success on FIS deletion - doNothing() - .when(backendClientReturnsOk) - .deleteFirebaseInstallation(anyString(), anyString(), anyString(), anyString()); - // Mocks server error on FIS deletion - doThrow(new FirebaseException("Server Error")) - .when(backendClientReturnsError) - .deleteFirebaseInstallation(anyString(), anyString(), anyString(), anyString()); + /** + * Check the id generation process when there is no network. There are three cases: + * + * + */ + @Test + public void testGetId_noNetwork_noIid() throws Exception { + when(mockBackend.createFirebaseInstallation(anyString(), anyString(), anyString(), anyString())) + .thenThrow(new IOException()); + when(mockBackend.generateAuthToken(anyString(), anyString(), anyString(), anyString())) + .thenThrow(new IOException()); + when(mockIidStore.readIid()).thenReturn(null); + + // Do the actual getId() call under test. Confirm that it returns a generated FID and + // and that the FID was written to storage. + // Confirm both that it returns the expected ID, as does reading the prefs from storage. + assertWithMessage("getId Task failed.") + .that(Tasks.await(firebaseInstallations.getId())) + .isEqualTo(TEST_FID_1); + PersistedInstallationEntry entryValue = + persistedInstallation.readPersistedInstallationEntryValue(); + assertThat(entryValue).hasFid(TEST_FID_1); + + // Waiting for Task that registers FID on the FIS Servers + executor.awaitTermination(500, TimeUnit.MILLISECONDS); + + // The storage should still have the same ID and the status should indicate that the + // fid is registered. + PersistedInstallationEntry updatedInstallationEntry = + persistedInstallation.readPersistedInstallationEntryValue(); + assertThat(updatedInstallationEntry).hasFid(TEST_FID_1); + assertThat(updatedInstallationEntry).hasRegistrationStatus(RegistrationStatus.UNREGISTERED); } - @After - public void cleanUp() throws Exception { - persistedInstallation.clear(); + @Test + public void testGetId_noNetwork_iidPresent() throws Exception { + when(mockBackend.createFirebaseInstallation(anyString(), anyString(), anyString(), anyString())) + .thenThrow(new IOException()); + when(mockBackend.generateAuthToken(anyString(), anyString(), anyString(), anyString())) + .thenThrow(new IOException()); + when(mockIidStore.readIid()).thenReturn(TEST_INSTANCE_ID_1); + + // Do the actual getId() call under test. Confirm that it returns a generated FID and + // and that the FID was written to storage. + // Confirm both that it returns the expected ID, as does reading the prefs from storage. + assertWithMessage("getId Task failed.") + .that(Tasks.await(firebaseInstallations.getId())) + .isEqualTo(TEST_INSTANCE_ID_1); + PersistedInstallationEntry entryValue = + persistedInstallation.readPersistedInstallationEntryValue(); + assertThat(entryValue).hasFid(TEST_INSTANCE_ID_1); + + // Waiting for Task that registers FID on the FIS Servers + executor.awaitTermination(500, TimeUnit.MILLISECONDS); + + // The storage should still have the same ID and the status should indicate that the + // fid is registered. + PersistedInstallationEntry updatedInstallationEntry = + persistedInstallation.readPersistedInstallationEntryValue(); + assertThat(updatedInstallationEntry).hasFid(TEST_INSTANCE_ID_1); + assertThat(updatedInstallationEntry).hasRegistrationStatus(RegistrationStatus.UNREGISTERED); } - private FirebaseInstallations getFirebaseInstallations() { - return new FirebaseInstallations( - executor, - firebaseApp, - backendClientReturnsOk, - persistedInstallation, - mockUtils, - mockIidStore); + @Test + public void testGetId_noNetwork_fidAlreadyGenerated() throws Exception { + when(mockBackend.createFirebaseInstallation(anyString(), anyString(), anyString(), anyString())) + .thenThrow(new IOException()); + when(mockBackend.generateAuthToken(anyString(), anyString(), anyString(), anyString())) + .thenThrow(new IOException()); + + persistedInstallation.insertOrUpdatePersistedInstallationEntry( + PersistedInstallationEntry.INSTANCE.withUnregisteredFid("generatedFid")); + + // Do the actual getId() call under test. Confirm that it returns the already generated FID. + // Confirm both that it returns the expected ID, as does reading the prefs from storage. + assertWithMessage("getId Task failed.") + .that(Tasks.await(firebaseInstallations.getId())) + .isEqualTo("generatedFid"); + + // Waiting for Task that registers FID on the FIS Servers + executor.awaitTermination(500, TimeUnit.MILLISECONDS); + + // The storage should still have the same ID and the status should indicate that the + // fid is registered. + PersistedInstallationEntry updatedInstallationEntry = + persistedInstallation.readPersistedInstallationEntryValue(); + assertThat(updatedInstallationEntry).hasFid("generatedFid"); + assertThat(updatedInstallationEntry).hasRegistrationStatus(RegistrationStatus.UNREGISTERED); } + /** + * Checks that if we have a registered fid then the fid is returned and no backend calls are made. + */ @Test - public void testGetId_PersistedInstallationOk_BackendOk() throws Exception { - when(mockUtils.isAuthTokenExpired(REGISTERED_IID_ENTRY)).thenReturn(/*isValid*/ false); - FirebaseInstallations firebaseInstallations = getFirebaseInstallations(); + public void testGetId_ValidIdAndToken_NoBackendCalls() throws Exception { + persistedInstallation.insertOrUpdatePersistedInstallationEntry( + PersistedInstallationEntry.INSTANCE.withRegisteredFid( + TEST_FID_1, + TEST_REFRESH_TOKEN, + utils.currentTimeInSecs(), + TEST_AUTH_TOKEN, + TEST_TOKEN_EXPIRATION_TIMESTAMP)); // No exception, means success. assertWithMessage("getId Task failed.") .that(Tasks.await(firebaseInstallations.getId())) - .isNotEmpty(); - PersistedInstallationEntry entryValue = + .isEqualTo(TEST_FID_1); + + // getId() returns fid immediately but registers fid asynchronously. Waiting for half a second + // while we mock fid registration. We dont send an actual request to FIS in tests. + executor.awaitTermination(500, TimeUnit.MILLISECONDS); + + // check that the mockClient didn't get invoked at all, since the fid is already registered + // and the authtoken is present and not expired + verifyZeroInteractions(mockBackend); + + // check that the fid is still the expected one and is registered + PersistedInstallationEntry updatedInstallationEntry = persistedInstallation.readPersistedInstallationEntryValue(); - assertThat(entryValue).hasFid(TEST_FID_1); + assertThat(updatedInstallationEntry).hasFid(TEST_FID_1); + assertThat(updatedInstallationEntry).hasRegistrationStatus(RegistrationStatus.REGISTERED); + } + + /** + * Checks that if we have an unregistered fid that the fid gets registered with the backend and no + * other calls are made. + */ + @Test + public void testGetId_UnRegisteredId_IssueCreateIdCall() throws Exception { + when(mockBackend.createFirebaseInstallation( + anyString(), matches(TEST_FID_1), anyString(), anyString())) + .thenReturn(TEST_INSTALLATION_RESPONSE); + persistedInstallation.insertOrUpdatePersistedInstallationEntry( + PersistedInstallationEntry.INSTANCE.withUnregisteredFid(TEST_FID_1)); + + // No exception, means success. + assertWithMessage("getId Task failed.") + .that(Tasks.await(firebaseInstallations.getId())) + .isEqualTo(TEST_FID_1); // getId() returns fid immediately but registers fid asynchronously. Waiting for half a second // while we mock fid registration. We dont send an actual request to FIS in tests. executor.awaitTermination(500, TimeUnit.MILLISECONDS); + // check that the mockClient didn't get invoked at all, since the fid is already registered + // and the authtoken is present and not expired + verify(mockBackend) + .createFirebaseInstallation(anyString(), matches(TEST_FID_1), anyString(), anyString()); + verify(mockBackend, never()) + .generateAuthToken(anyString(), anyString(), anyString(), anyString()); + + // check that the fid is still the expected one and is registered PersistedInstallationEntry updatedInstallationEntry = persistedInstallation.readPersistedInstallationEntryValue(); assertThat(updatedInstallationEntry).hasFid(TEST_FID_1); @@ -240,16 +311,14 @@ public void testGetId_PersistedInstallationOk_BackendOk() throws Exception { @Test public void testGetId_migrateIid_successful() throws Exception { when(mockIidStore.readIid()).thenReturn(TEST_INSTANCE_ID_1); - when(mockUtils.isAuthTokenExpired(REGISTERED_INSTALLATION_ENTRY)).thenReturn(/*isValid*/ false); - when(backendClientReturnsOk.createFirebaseInstallation( - anyString(), anyString(), anyString(), anyString())) + when(mockBackend.createFirebaseInstallation(anyString(), anyString(), anyString(), anyString())) .thenReturn(TEST_INSTALLATION_RESPONSE_WITH_IID); - FirebaseInstallations firebaseInstallations = getFirebaseInstallations(); - // No exception, means success. + // Do the actual getId() call under test. + // Confirm both that it returns the expected ID, as does reading the prefs from storage. assertWithMessage("getId Task failed.") .that(Tasks.await(firebaseInstallations.getId())) - .isNotEmpty(); + .isEqualTo(TEST_INSTANCE_ID_1); PersistedInstallationEntry entryValue = persistedInstallation.readPersistedInstallationEntryValue(); assertThat(entryValue).hasFid(TEST_INSTANCE_ID_1); @@ -257,6 +326,8 @@ public void testGetId_migrateIid_successful() throws Exception { // Waiting for Task that registers FID on the FIS Servers executor.awaitTermination(500, TimeUnit.MILLISECONDS); + // The storage should still have the same ID and the status should indicate that the + // fid si registered. PersistedInstallationEntry updatedInstallationEntry = persistedInstallation.readPersistedInstallationEntryValue(); assertThat(updatedInstallationEntry).hasFid(TEST_INSTANCE_ID_1); @@ -265,11 +336,9 @@ public void testGetId_migrateIid_successful() throws Exception { @Test public void testGetId_multipleCalls_sameFIDReturned() throws Exception { - when(mockUtils.isAuthTokenExpired(REGISTERED_INSTALLATION_ENTRY)).thenReturn(/*isValid*/ false); - when(backendClientReturnsOk.createFirebaseInstallation( - anyString(), anyString(), anyString(), anyString())) + when(mockIidStore.readIid()).thenReturn(null); + when(mockBackend.createFirebaseInstallation(anyString(), anyString(), anyString(), anyString())) .thenReturn(TEST_INSTALLATION_RESPONSE); - FirebaseInstallations firebaseInstallations = getFirebaseInstallations(); // Call getId multiple times Task task1 = firebaseInstallations.getId(); @@ -284,7 +353,7 @@ public void testGetId_multipleCalls_sameFIDReturned() throws Exception { assertWithMessage("Persisted Fid of Task2 doesn't match.") .that(task2.getResult()) .isEqualTo(TEST_FID_1); - verify(backendClientReturnsOk, times(1)) + verify(mockBackend, times(1)) .createFirebaseInstallation(TEST_API_KEY, TEST_FID_1, TEST_PROJECT_ID, TEST_APP_ID_1); PersistedInstallationEntry updatedInstallationEntry = persistedInstallation.readPersistedInstallationEntryValue(); @@ -292,203 +361,172 @@ public void testGetId_multipleCalls_sameFIDReturned() throws Exception { assertThat(updatedInstallationEntry).hasRegistrationStatus(RegistrationStatus.REGISTERED); } + /** + * Checks that if the server rejects a FID during registration the SDK will use the fid in the + * response as the new fid. + */ @Test - public void testGetId_invalidFid_storesValidFidFromResponse() throws Exception { + public void testGetId_unregistered_replacesFidWithResponse() throws Exception { // Update local storage with installation entry that has invalid fid. - persistedInstallation.insertOrUpdatePersistedInstallationEntry(INVALID_INSTALLATION_ENTRY); - when(mockUtils.isAuthTokenExpired(REGISTERED_INSTALLATION_ENTRY)).thenReturn(/*isValid*/ false); - FirebaseInstallations firebaseInstallations = getFirebaseInstallations(); + persistedInstallation.insertOrUpdatePersistedInstallationEntry( + PersistedInstallationEntry.INSTANCE.withUnregisteredFid("tobereplaced")); + when(mockBackend.createFirebaseInstallation(anyString(), anyString(), anyString(), anyString())) + .thenReturn(TEST_INSTALLATION_RESPONSE); - // No exception, means success. + // The first call will return the existing FID, "tobereplaced" assertWithMessage("getId Task failed.") .that(Tasks.await(firebaseInstallations.getId())) - .isNotEmpty(); - PersistedInstallationEntry entryValue = - persistedInstallation.readPersistedInstallationEntryValue(); - assertThat(entryValue).hasFid(INVALID_TEST_FID); - - // Waiting for Task that registers FID on the FIS Servers - executor.awaitTermination(500, TimeUnit.MILLISECONDS); - - PersistedInstallationEntry updatedInstallationEntry = - persistedInstallation.readPersistedInstallationEntryValue(); - // After FID registration is complete, installation entry is updated with valid fid. - assertThat(updatedInstallationEntry).hasFid(TEST_FID_1); - assertThat(updatedInstallationEntry).hasRegistrationStatus(RegistrationStatus.REGISTERED); - } - - @Test - public void testGetId_PersistedInstallationOk_BackendError() throws Exception { - FirebaseInstallations firebaseInstallations = - new FirebaseInstallations( - executor, - firebaseApp, - backendClientReturnsError, - persistedInstallation, - mockUtils, - mockIidStore); - - Tasks.await(firebaseInstallations.getId()); - - PersistedInstallationEntry entryValue = - persistedInstallation.readPersistedInstallationEntryValue(); - assertThat(entryValue).hasFid(TEST_FID_1); + .isEqualTo("tobereplaced"); // Waiting for Task that registers FID on the FIS Servers executor.awaitTermination(500, TimeUnit.MILLISECONDS); - PersistedInstallationEntry updatedInstallationEntry = - persistedInstallation.readPersistedInstallationEntryValue(); - assertThat(updatedInstallationEntry).hasFid(TEST_FID_1); - assertThat(updatedInstallationEntry).hasRegistrationStatus(RegistrationStatus.REGISTER_ERROR); + // The next call should return the FID that was returned by the server + assertWithMessage("getId Task failed.") + .that(Tasks.await(firebaseInstallations.getId())) + .isEqualTo(TEST_FID_1); } + /** + * A registration that fails with a SERVER_ERROR will cause the FID to be put into the error + * state. + */ @Test public void testGetId_ServerError_UnregisteredFID() throws Exception { - // Mocking server error on FIS createFirebaseInstallation, returns empty InstallationResponse - when(backendClientReturnsOk.createFirebaseInstallation( - anyString(), anyString(), anyString(), anyString())) - .thenReturn(SERVER_ERROR_INSTALLATION_RESPONSE); + // start with an unregistered fid + persistedInstallation.insertOrUpdatePersistedInstallationEntry( + PersistedInstallationEntry.INSTANCE.withUnregisteredFid(TEST_FID_1)); - FirebaseInstallations firebaseInstallations = getFirebaseInstallations(); - - Tasks.await(firebaseInstallations.getId()); + // have the server return a server error for the registration + when(mockBackend.createFirebaseInstallation(anyString(), anyString(), anyString(), anyString())) + .thenReturn( + InstallationResponse.builder().setResponseCode(ResponseCode.BAD_CONFIG).build()); - PersistedInstallationEntry entryValue = - persistedInstallation.readPersistedInstallationEntryValue(); - assertThat(entryValue).hasFid(TEST_FID_1); + // do a getId(), the unregistered TEST_FID_1 should be returned + assertWithMessage("getId Task failed.") + .that(Tasks.await(firebaseInstallations.getId())) + .isEqualTo(TEST_FID_1); - // Waiting for Task that registers FID on the FIS Servers + // Waiting for Task that registers FID on the FIS Servers. executor.awaitTermination(500, TimeUnit.MILLISECONDS); + // We expect that the server error will cause the FID to be put into the error state. + // There is nothing more we can do. PersistedInstallationEntry updatedInstallationEntry = persistedInstallation.readPersistedInstallationEntryValue(); assertThat(updatedInstallationEntry).hasFid(TEST_FID_1); - assertThat(updatedInstallationEntry).hasRegistrationStatus(RegistrationStatus.UNREGISTERED); - } - - @Test - public void testGetId_PersistedInstallationError_BackendOk() throws InterruptedException { - FirebaseInstallations firebaseInstallations = - new FirebaseInstallations( - executor, - firebaseApp, - backendClientReturnsOk, - persistedInstallationReturnsError, - mockUtils, - mockIidStore); - - // Expect exception - try { - Tasks.await(firebaseInstallations.getId()); - 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.CLIENT_ERROR); - } + assertThat(updatedInstallationEntry).hasRegistrationStatus(RegistrationStatus.REGISTER_ERROR); } + /** + * A registration that fails with an IOException will not cause the FID to be put into the error + * state. + */ @Test public void testGetId_fidRegistrationUncheckedException_statusUpdated() throws Exception { - // Mocking unchecked exception on FIS createFirebaseInstallation - when(mockClient.createFirebaseInstallation(anyString(), anyString(), anyString(), anyString())) - .thenAnswer( - invocation -> { - throw new InterruptedException(); - }); - when(mockUtils.isAuthTokenExpired(REGISTERED_INSTALLATION_ENTRY)).thenReturn(/*isValid*/ false); - - FirebaseInstallations firebaseInstallations = - new FirebaseInstallations( - executor, firebaseApp, mockClient, persistedInstallation, mockUtils, mockIidStore); + // set initial state to having an unregistered FID + persistedInstallation.insertOrUpdatePersistedInstallationEntry( + PersistedInstallationEntry.INSTANCE.withUnregisteredFid(TEST_FID_1)); - Tasks.await(firebaseInstallations.getId()); + // Mocking unchecked exception on FIS createFirebaseInstallation + when(mockBackend.createFirebaseInstallation(anyString(), anyString(), anyString(), anyString())) + .thenThrow(new IOException()); - PersistedInstallationEntry entryValue = - persistedInstallation.readPersistedInstallationEntryValue(); - assertThat(entryValue).hasFid(TEST_FID_1); + String fid = Tasks.await(firebaseInstallations.getId()); + assertEquals("fid doesn't match expected", TEST_FID_1, fid); // Waiting for Task that registers FID on the FIS Servers executor.awaitTermination(500, TimeUnit.MILLISECONDS); - // Validate that registration status is REGISTER_ERROR + // We expect that the IOException will cause the request to fail, but it will not + // cause the FID to be put into the error state because we expect this to eventually succeed. PersistedInstallationEntry updatedInstallationEntry = persistedInstallation.readPersistedInstallationEntryValue(); assertThat(updatedInstallationEntry).hasFid(TEST_FID_1); - assertThat(updatedInstallationEntry).hasRegistrationStatus(RegistrationStatus.REGISTER_ERROR); + assertThat(updatedInstallationEntry).hasRegistrationStatus(RegistrationStatus.UNREGISTERED); } @Test public void testGetId_expiredAuthTokenUncheckedException_statusUpdated() throws Exception { - // Update local storage with installation entry that has auth token expired. - persistedInstallation.insertOrUpdatePersistedInstallationEntry(EXPIRED_AUTH_TOKEN_ENTRY); + // Start with a registered FID + persistedInstallation.insertOrUpdatePersistedInstallationEntry( + PersistedInstallationEntry.INSTANCE.withRegisteredFid( + TEST_FID_1, + TEST_REFRESH_TOKEN, + utils.currentTimeInSecs(), + TEST_AUTH_TOKEN, + TEST_TOKEN_EXPIRATION_TIMESTAMP)); + + // Move the time forward by the token expiration time. + fakeCalendar.advanceTimeBySeconds(TEST_TOKEN_EXPIRATION_TIMESTAMP); + // Mocking unchecked exception on FIS generateAuthToken - when(mockClient.generateAuthToken(anyString(), anyString(), anyString(), anyString())) - .thenAnswer( - invocation -> { - throw new InterruptedException(); - }); - when(mockUtils.isAuthTokenExpired(EXPIRED_AUTH_TOKEN_ENTRY)).thenReturn(/*isExpired*/ true); - - FirebaseInstallations firebaseInstallations = - new FirebaseInstallations( - executor, firebaseApp, mockClient, persistedInstallation, mockUtils, mockIidStore); + when(mockBackend.generateAuthToken(anyString(), anyString(), anyString(), anyString())) + .thenThrow(new IOException()); assertWithMessage("getId Task failed") .that(Tasks.await(firebaseInstallations.getId())) - .isNotEmpty(); - PersistedInstallationEntry entryValue = - persistedInstallation.readPersistedInstallationEntryValue(); - assertThat(entryValue).hasFid(TEST_FID_1); + .isEqualTo(TEST_FID_1); // Waiting for Task that generates auth token with the FIS Servers executor.awaitTermination(500, TimeUnit.MILLISECONDS); - // Validate that registration status is REGISTER_ERROR + // Validate that registration status is still REGISTER PersistedInstallationEntry updatedInstallationEntry = persistedInstallation.readPersistedInstallationEntryValue(); assertThat(updatedInstallationEntry).hasFid(TEST_FID_1); - assertThat(updatedInstallationEntry).hasRegistrationStatus(RegistrationStatus.REGISTER_ERROR); + assertThat(updatedInstallationEntry).hasRegistrationStatus(RegistrationStatus.REGISTERED); } + /** + * The FID is successfully registered but the token is expired. A getId will cause the token to be + * refreshed in the background. + */ @Test public void testGetId_expiredAuthToken_refreshesAuthToken() throws Exception { - // Update local storage with installation entry that has auth token expired. - persistedInstallation.insertOrUpdatePersistedInstallationEntry(EXPIRED_AUTH_TOKEN_ENTRY); - when(mockUtils.isAuthTokenExpired(EXPIRED_AUTH_TOKEN_ENTRY)).thenReturn(/*isExpired*/ true); + // Start with a registered FID + persistedInstallation.insertOrUpdatePersistedInstallationEntry( + PersistedInstallationEntry.INSTANCE.withRegisteredFid( + TEST_FID_1, + TEST_REFRESH_TOKEN, + utils.currentTimeInSecs(), + TEST_AUTH_TOKEN, + TEST_TOKEN_EXPIRATION_TIMESTAMP)); + + // Make the server generateAuthToken() call return a refreshed token + when(mockBackend.generateAuthToken(anyString(), anyString(), anyString(), anyString())) + .thenReturn(TEST_TOKEN_RESULT); - FirebaseInstallations firebaseInstallations = getFirebaseInstallations(); + // Move the time forward by the token expiration time. + fakeCalendar.advanceTimeBySeconds(TEST_TOKEN_EXPIRATION_TIMESTAMP); + // Get the ID, which should cause the SDK to realize that the auth token is expired and + // kick off a refresh of the token. assertWithMessage("getId Task failed") .that(Tasks.await(firebaseInstallations.getId())) - .isNotEmpty(); - PersistedInstallationEntry entryValue = - persistedInstallation.readPersistedInstallationEntryValue(); - assertThat(entryValue).hasFid(TEST_FID_1); + .isEqualTo(TEST_FID_1); // Waiting for Task that registers FID on the FIS Servers executor.awaitTermination(500, TimeUnit.MILLISECONDS); - // Validate that Persisted FID has a refreshed auth token now - PersistedInstallationEntry updatedInstallationEntry = - persistedInstallation.readPersistedInstallationEntryValue(); - assertThat(updatedInstallationEntry).hasAuthToken(TEST_AUTH_TOKEN_2); - verify(backendClientReturnsOk, never()) + // Check that the token has been refreshed + assertWithMessage("auth token is not what is expected after the refresh") + .that( + Tasks.await( + firebaseInstallations.getToken(FirebaseInstallationsApi.DO_NOT_FORCE_REFRESH)) + .getToken()) + .isEqualTo(TEST_AUTH_TOKEN_2); + + verify(mockBackend, never()) .createFirebaseInstallation(TEST_API_KEY, TEST_FID_1, TEST_PROJECT_ID, TEST_APP_ID_1); - verify(backendClientReturnsOk, times(1)) + verify(mockBackend, times(1)) .generateAuthToken(TEST_API_KEY, TEST_FID_1, TEST_PROJECT_ID, TEST_REFRESH_TOKEN); } @Test public void testGetAuthToken_fidDoesNotExist_successful() throws Exception { - when(mockUtils.isAuthTokenExpired(REGISTERED_INSTALLATION_ENTRY)).thenReturn(/*isValid*/ false); - FirebaseInstallations firebaseInstallations = getFirebaseInstallations(); - + when(mockBackend.createFirebaseInstallation(anyString(), anyString(), anyString(), anyString())) + .thenReturn(TEST_INSTALLATION_RESPONSE); Tasks.await(firebaseInstallations.getToken(FirebaseInstallationsApi.DO_NOT_FORCE_REFRESH)); PersistedInstallationEntry entryValue = @@ -496,47 +534,15 @@ public void testGetAuthToken_fidDoesNotExist_successful() throws Exception { assertThat(entryValue).hasAuthToken(TEST_AUTH_TOKEN); } - @Test - public void testGetAuthToken_PersistedInstallationError_failure() throws Exception { - when(mockUtils.isAuthTokenExpired(REGISTERED_INSTALLATION_ENTRY)).thenReturn(/*isValid*/ false); - FirebaseInstallations firebaseInstallations = - new FirebaseInstallations( - executor, - firebaseApp, - backendClientReturnsOk, - persistedInstallationReturnsError, - mockUtils, - mockIidStore); - - // Expect exception - try { - Tasks.await(firebaseInstallations.getToken(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.CLIENT_ERROR); - } - } - @Test public void testGetAuthToken_fidExists_successful() throws Exception { - when(mockPersistedInstallation.readPersistedInstallationEntryValue()) - .thenReturn(REGISTERED_INSTALLATION_ENTRY); - when(mockUtils.isAuthTokenExpired(REGISTERED_INSTALLATION_ENTRY)).thenReturn(/*isValid*/ false); - - FirebaseInstallations firebaseInstallations = - new FirebaseInstallations( - executor, - firebaseApp, - backendClientReturnsOk, - mockPersistedInstallation, - mockUtils, - mockIidStore); + persistedInstallation.insertOrUpdatePersistedInstallationEntry( + PersistedInstallationEntry.INSTANCE.withRegisteredFid( + TEST_FID_1, + TEST_REFRESH_TOKEN, + utils.currentTimeInSecs(), + TEST_AUTH_TOKEN, + TEST_TOKEN_EXPIRATION_TIMESTAMP)); InstallationTokenResult installationTokenResult = Tasks.await(firebaseInstallations.getToken(FirebaseInstallationsApi.DO_NOT_FORCE_REFRESH)); @@ -544,17 +550,21 @@ public void testGetAuthToken_fidExists_successful() throws Exception { assertWithMessage("Persisted Auth Token doesn't match") .that(installationTokenResult.getToken()) .isEqualTo(TEST_AUTH_TOKEN); - verify(backendClientReturnsOk, never()) + verify(mockBackend, never()) .generateAuthToken(TEST_API_KEY, TEST_FID_1, TEST_PROJECT_ID, TEST_REFRESH_TOKEN); } @Test - public void testGetToken_expiredAuthToken_fetchedNewTokenFromFIS() throws Exception { - persistedInstallation.insertOrUpdatePersistedInstallationEntry(EXPIRED_AUTH_TOKEN_ENTRY); - when(mockUtils.isAuthTokenExpired(EXPIRED_AUTH_TOKEN_ENTRY)).thenReturn(/*isExpired*/ true); - when(mockUtils.isAuthTokenExpired(UPDATED_AUTH_TOKEN_ENTRY)).thenReturn(/*isValid*/ false); + public void testGetAuthToken_expiredAuthToken_fetchedNewTokenFromFIS() throws Exception { + // start with a registered FID and valid auth token + persistedInstallation.insertOrUpdatePersistedInstallationEntry(REGISTERED_INSTALLATION_ENTRY); + + // Move the time forward by the token expiration time. + fakeCalendar.advanceTimeBySeconds(TEST_TOKEN_EXPIRATION_TIMESTAMP); - FirebaseInstallations firebaseInstallations = getFirebaseInstallations(); + // have the server respond with a new token + when(mockBackend.generateAuthToken(anyString(), anyString(), anyString(), anyString())) + .thenReturn(TEST_TOKEN_RESULT); InstallationTokenResult installationTokenResult = Tasks.await(firebaseInstallations.getToken(FirebaseInstallationsApi.DO_NOT_FORCE_REFRESH)); @@ -562,18 +572,16 @@ public void testGetToken_expiredAuthToken_fetchedNewTokenFromFIS() throws Except assertWithMessage("Persisted Auth Token doesn't match") .that(installationTokenResult.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 testGetToken_unregisteredFid_fetchedNewTokenFromFIS() throws Exception { // Update local storage with a unregistered installation entry to validate that getToken // calls getId to ensure FID registration and returns a valid auth token. - persistedInstallation.insertOrUpdatePersistedInstallationEntry(UNREGISTERED_INSTALLATION_ENTRY); - when(mockUtils.isAuthTokenExpired(REGISTERED_INSTALLATION_ENTRY)).thenReturn(/*isValid*/ false); - - FirebaseInstallations firebaseInstallations = getFirebaseInstallations(); + persistedInstallation.insertOrUpdatePersistedInstallationEntry( + PersistedInstallationEntry.INSTANCE.withUnregisteredFid(TEST_FID_1)); + when(mockBackend.createFirebaseInstallation(anyString(), anyString(), anyString(), anyString())) + .thenReturn(TEST_INSTALLATION_RESPONSE); InstallationTokenResult installationTokenResult = Tasks.await(firebaseInstallations.getToken(FirebaseInstallationsApi.DO_NOT_FORCE_REFRESH)); @@ -581,68 +589,63 @@ public void testGetToken_unregisteredFid_fetchedNewTokenFromFIS() throws Excepti assertWithMessage("Persisted Auth Token doesn't match") .that(installationTokenResult.getToken()) .isEqualTo(TEST_AUTH_TOKEN); - verify(backendClientReturnsOk, times(1)) - .createFirebaseInstallation(TEST_API_KEY, TEST_FID_1, TEST_PROJECT_ID, TEST_APP_ID_1); } @Test - public void testGetToken_fidError_persistedInstallationCleared() throws Exception { - // Update local storage with an expired installation entry to ensure that generate auth token - // is called. - persistedInstallation.insertOrUpdatePersistedInstallationEntry(EXPIRED_AUTH_TOKEN_ENTRY); + public void testGetAuthToken_authError_persistedInstallationCleared() throws Exception { + persistedInstallation.insertOrUpdatePersistedInstallationEntry( + PersistedInstallationEntry.INSTANCE.withRegisteredFid( + TEST_FID_1, + TEST_REFRESH_TOKEN, + utils.currentTimeInSecs(), + TEST_AUTH_TOKEN, + TEST_TOKEN_EXPIRATION_TIMESTAMP)); + // Mocks error during auth token generation - when(backendClientReturnsOk.generateAuthToken( - anyString(), anyString(), anyString(), anyString())) + when(mockBackend.generateAuthToken(anyString(), anyString(), anyString(), anyString())) .thenReturn( - TokenResult.builder().setResponseCode(TokenResult.ResponseCode.FID_ERROR).build()); - when(mockUtils.isAuthTokenExpired(REGISTERED_INSTALLATION_ENTRY)) - .thenReturn(/* isExpired*/ true, /*isValid*/ false); - - FirebaseInstallations firebaseInstallations = getFirebaseInstallations(); + TokenResult.builder().setResponseCode(TokenResult.ResponseCode.AUTH_ERROR).build()); // Expect exception try { Tasks.await(firebaseInstallations.getToken(FirebaseInstallationsApi.FORCE_REFRESH)); - fail("getToken() failed due to Server Error."); + fail("the getAuthToken() call should have failed due to Auth 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.AUTHENTICATION_ERROR); + .isInstanceOf(IOException.class); } - verify(backendClientReturnsOk, times(1)) - .generateAuthToken(TEST_API_KEY, TEST_FID_1, TEST_PROJECT_ID, TEST_REFRESH_TOKEN); - PersistedInstallationEntry updatedInstallationEntry = - persistedInstallation.readPersistedInstallationEntryValue(); - assertThat(updatedInstallationEntry).hasRegistrationStatus(RegistrationStatus.NOT_GENERATED); + assertTrue(persistedInstallation.readPersistedInstallationEntryValue().isNotGenerated()); } + // /** + // * Check that a call to generateAuthToken(FORCE_REFRESH) fails if the backend client call + // * fails. + // */ @Test - public void testGetToken_serverError_failure() throws Exception { - when(mockPersistedInstallation.readPersistedInstallationEntryValue()) - .thenReturn(REGISTERED_INSTALLATION_ENTRY); - when(backendClientReturnsError.generateAuthToken( - anyString(), anyString(), anyString(), anyString())) - .thenThrow(new FirebaseException("Server Error")); - when(mockUtils.isAuthTokenExpired(REGISTERED_INSTALLATION_ENTRY)).thenReturn(/*isValid*/ false); - - FirebaseInstallations firebaseInstallations = - new FirebaseInstallations( - executor, - firebaseApp, - backendClientReturnsError, - mockPersistedInstallation, - mockUtils, - mockIidStore); + public void testGetAuthToken_serverError_failure() throws Exception { + // start the test with a registered FID + persistedInstallation.insertOrUpdatePersistedInstallationEntry( + PersistedInstallationEntry.INSTANCE.withRegisteredFid( + TEST_FID_1, + TEST_REFRESH_TOKEN, + utils.currentTimeInSecs(), + TEST_AUTH_TOKEN, + TEST_TOKEN_EXPIRATION_TIMESTAMP)); + + // have the backend fail when generateAuthToken is invoked. + when(mockBackend.generateAuthToken(anyString(), anyString(), anyString(), anyString())) + .thenReturn( + TokenResult.builder().setResponseCode(TokenResult.ResponseCode.BAD_CONFIG).build()); - // Expect exception + // Make the forced getAuthToken call, which should fail. try { Tasks.await(firebaseInstallations.getToken(FirebaseInstallationsApi.FORCE_REFRESH)); - fail("getToken() failed due to Server Error."); + fail( + "getAuthToken() succeeded but should have failed due to the BAD_CONFIG error " + + "returned by the network call."); } catch (ExecutionException expected) { assertWithMessage("Exception class doesn't match") .that(expected) @@ -650,20 +653,28 @@ public void testGetToken_serverError_failure() throws Exception { .isInstanceOf(FirebaseInstallationsException.class); assertWithMessage("Exception status doesn't match") .that(((FirebaseInstallationsException) expected.getCause()).getStatus()) - .isEqualTo(FirebaseInstallationsException.Status.SDK_INTERNAL_ERROR); + .isEqualTo(Status.BAD_CONFIG); } } @Test - public void testGetToken_multipleCallsDoNotForceRefresh_fetchedNewTokenOnce() throws Exception { - // Update local storage with a EXPIRED_AUTH_TOKEN_ENTRY to validate the flow of multiple tasks - // triggered simultaneously. Task2 waits for Task1 to complete. On task1 completion, task2 reads - // the UPDATED_AUTH_TOKEN_FID_ENTRY generated by Task1. - persistedInstallation.insertOrUpdatePersistedInstallationEntry(EXPIRED_AUTH_TOKEN_ENTRY); - when(mockUtils.isAuthTokenExpired(EXPIRED_AUTH_TOKEN_ENTRY)).thenReturn(/*isExpired*/ true); - when(mockUtils.isAuthTokenExpired(UPDATED_AUTH_TOKEN_ENTRY)).thenReturn(/*isValid*/ false); + public void testGetAuthToken_multipleCallsDoNotForceRefresh_fetchedNewTokenOnce() + throws Exception { + // start with a valid fid and authtoken + persistedInstallation.insertOrUpdatePersistedInstallationEntry( + PersistedInstallationEntry.INSTANCE.withRegisteredFid( + TEST_FID_1, + TEST_REFRESH_TOKEN, + utils.currentTimeInSecs(), + TEST_AUTH_TOKEN, + TEST_TOKEN_EXPIRATION_TIMESTAMP)); + + // Make the server generateAuthToken() call return a refreshed token + when(mockBackend.generateAuthToken(anyString(), anyString(), anyString(), anyString())) + .thenReturn(TEST_TOKEN_RESULT); - FirebaseInstallations firebaseInstallations = getFirebaseInstallations(); + // expire the authtoken by advancing the clock + fakeCalendar.advanceTimeBySeconds(TEST_TOKEN_EXPIRATION_TIMESTAMP); // Call getToken multiple times with DO_NOT_FORCE_REFRESH option Task task1 = @@ -679,13 +690,21 @@ public void testGetToken_multipleCallsDoNotForceRefresh_fetchedNewTokenOnce() th assertWithMessage("Persisted Auth Token doesn't match") .that(task2.getResult().getToken()) .isEqualTo(TEST_AUTH_TOKEN_2); - verify(backendClientReturnsOk, times(1)) + verify(mockBackend, times(1)) .generateAuthToken(TEST_API_KEY, TEST_FID_1, TEST_PROJECT_ID, TEST_REFRESH_TOKEN); } @Test - public void testGetToken_multipleCallsForceRefresh_fetchedNewTokenTwice() throws Exception { - persistedInstallation.insertOrUpdatePersistedInstallationEntry(REGISTERED_INSTALLATION_ENTRY); + public void testGetAuthToken_multipleCallsForceRefresh_fetchedNewTokenTwice() throws Exception { + // start with a valid fid and authtoken + persistedInstallation.insertOrUpdatePersistedInstallationEntry( + PersistedInstallationEntry.INSTANCE.withRegisteredFid( + TEST_FID_1, + TEST_REFRESH_TOKEN, + utils.currentTimeInSecs(), + TEST_AUTH_TOKEN, + TEST_TOKEN_EXPIRATION_TIMESTAMP)); + // Use a mock ServiceClient for network calls with delay(500ms) to ensure first task is not // completed before the second task starts. Hence, we can test multiple calls to getToken() // and verify one task waits for another task to complete. @@ -708,11 +727,8 @@ public void testGetToken_multipleCallsForceRefresh_fetchedNewTokenTwice() throws .setTokenExpirationTimestamp(TEST_TOKEN_EXPIRATION_TIMESTAMP) .setResponseCode(TokenResult.ResponseCode.OK) .build())) - .when(backendClientReturnsOk) + .when(mockBackend) .generateAuthToken(anyString(), anyString(), anyString(), anyString()); - when(mockUtils.isAuthTokenExpired(any())).thenReturn(/*isValid*/ false); - - FirebaseInstallations firebaseInstallations = getFirebaseInstallations(); // Call getToken multiple times with FORCE_REFRESH option. Task task1 = @@ -728,7 +744,7 @@ public void testGetToken_multipleCallsForceRefresh_fetchedNewTokenTwice() throws assertWithMessage("Persisted Auth Token doesn't match") .that(task2.getResult().getToken()) .isEqualTo(TEST_AUTH_TOKEN_3); - verify(backendClientReturnsOk, times(1)) + verify(mockBackend, times(1)) .generateAuthToken(TEST_API_KEY, TEST_FID_1, TEST_PROJECT_ID, TEST_REFRESH_TOKEN); PersistedInstallationEntry updatedInstallationEntry = persistedInstallation.readPersistedInstallationEntryValue(); @@ -739,62 +755,60 @@ public void testGetToken_multipleCallsForceRefresh_fetchedNewTokenTwice() throws public void testDelete_registeredFID_successful() throws Exception { // Update local storage with a registered installation entry persistedInstallation.insertOrUpdatePersistedInstallationEntry(REGISTERED_INSTALLATION_ENTRY); - FirebaseInstallations firebaseInstallations = getFirebaseInstallations(); + when(mockBackend.createFirebaseInstallation(anyString(), anyString(), anyString(), anyString())) + .thenReturn(TEST_INSTALLATION_RESPONSE); Tasks.await(firebaseInstallations.delete()); PersistedInstallationEntry entryValue = persistedInstallation.readPersistedInstallationEntryValue(); - assertThat(entryValue).isEqualTo(DEFAULT_PERSISTED_INSTALLATION_ENTRY); - verify(backendClientReturnsOk, times(1)) + assertEquals(entryValue.getRegistrationStatus(), RegistrationStatus.NOT_GENERATED); + verify(mockBackend, times(1)) .deleteFirebaseInstallation(TEST_API_KEY, TEST_FID_1, TEST_PROJECT_ID, TEST_REFRESH_TOKEN); } @Test public void testDelete_unregisteredFID_successful() throws Exception { // Update local storage with a unregistered installation entry - persistedInstallation.insertOrUpdatePersistedInstallationEntry(UNREGISTERED_INSTALLATION_ENTRY); - FirebaseInstallations firebaseInstallations = getFirebaseInstallations(); + persistedInstallation.insertOrUpdatePersistedInstallationEntry( + PersistedInstallationEntry.INSTANCE.withUnregisteredFid(TEST_FID_1)); Tasks.await(firebaseInstallations.delete()); PersistedInstallationEntry entryValue = persistedInstallation.readPersistedInstallationEntryValue(); - assertThat(entryValue).isEqualTo(DEFAULT_PERSISTED_INSTALLATION_ENTRY); - verify(backendClientReturnsOk, never()) - .deleteFirebaseInstallation(TEST_API_KEY, TEST_FID_1, TEST_PROJECT_ID, TEST_REFRESH_TOKEN); + assertEquals(entryValue.getRegistrationStatus(), RegistrationStatus.NOT_GENERATED); + verify(mockBackend, never()) + .deleteFirebaseInstallation(anyString(), anyString(), anyString(), anyString()); } @Test public void testDelete_emptyPersistedFidEntry_successful() throws Exception { - FirebaseInstallations firebaseInstallations = getFirebaseInstallations(); + persistedInstallation.insertOrUpdatePersistedInstallationEntry( + PersistedInstallationEntry.INSTANCE.withNoGeneratedFid()); Tasks.await(firebaseInstallations.delete()); PersistedInstallationEntry entryValue = persistedInstallation.readPersistedInstallationEntryValue(); - assertThat(entryValue).isEqualTo(DEFAULT_PERSISTED_INSTALLATION_ENTRY); - verify(backendClientReturnsOk, never()) - .deleteFirebaseInstallation(TEST_API_KEY, TEST_FID_1, TEST_PROJECT_ID, TEST_REFRESH_TOKEN); + assertThat(entryValue).hasRegistrationStatus(RegistrationStatus.NOT_GENERATED); + verify(mockBackend, never()) + .deleteFirebaseInstallation(anyString(), anyString(), anyString(), anyString()); } @Test - public void testDelete_serverError_failure() throws Exception { + public void testDelete_serverError_badConfig() throws Exception { // Update local storage with a registered installation entry persistedInstallation.insertOrUpdatePersistedInstallationEntry(REGISTERED_INSTALLATION_ENTRY); - FirebaseInstallations firebaseInstallations = - new FirebaseInstallations( - executor, - firebaseApp, - backendClientReturnsError, - persistedInstallation, - mockUtils, - mockIidStore); + + doThrow(new FirebaseException("Server Error")) + .when(mockBackend) + .deleteFirebaseInstallation(anyString(), anyString(), anyString(), anyString()); // Expect exception try { Tasks.await(firebaseInstallations.delete()); - fail("delete() failed due to Server Error."); + fail("firebaseInstallations.delete() failed due to Server Error."); } catch (ExecutionException expected) { assertWithMessage("Exception class doesn't match") .that(expected) @@ -802,7 +816,31 @@ public void testDelete_serverError_failure() throws Exception { .isInstanceOf(FirebaseInstallationsException.class); assertWithMessage("Exception status doesn't match") .that(((FirebaseInstallationsException) expected.getCause()).getStatus()) - .isEqualTo(FirebaseInstallationsException.Status.SDK_INTERNAL_ERROR); + .isEqualTo(Status.BAD_CONFIG); + PersistedInstallationEntry entryValue = + persistedInstallation.readPersistedInstallationEntryValue(); + assertThat(entryValue).isEqualTo(REGISTERED_INSTALLATION_ENTRY); + } + } + + @Test + public void testDelete_networkError() throws Exception { + // Update local storage with a registered installation entry + persistedInstallation.insertOrUpdatePersistedInstallationEntry(REGISTERED_INSTALLATION_ENTRY); + + doThrow(new IOException()) + .when(mockBackend) + .deleteFirebaseInstallation(anyString(), anyString(), anyString(), anyString()); + + // Expect exception + try { + Tasks.await(firebaseInstallations.delete()); + fail("firebaseInstallations.delete() failed due to a Network Error."); + } catch (ExecutionException expected) { + assertWithMessage("Exception class doesn't match") + .that(expected) + .hasCauseThat() + .isInstanceOf(IOException.class); PersistedInstallationEntry entryValue = persistedInstallation.readPersistedInstallationEntryValue(); assertThat(entryValue).isEqualTo(REGISTERED_INSTALLATION_ENTRY); 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 eb4a0f47f85..769ea8ec5a9 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 @@ -21,8 +21,6 @@ public final class FisAndroidTestConstants { public static final String TEST_FID_1 = "cccccccccccccccccccccc"; - // Invalid FID. - public static final String INVALID_TEST_FID = "invalid"; public static final String TEST_PROJECT_ID = "777777777777"; @@ -38,8 +36,7 @@ public final class FisAndroidTestConstants { public static final String TEST_APP_ID_1 = "1:123456789:android:abcdef"; 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_TOKEN_EXPIRATION_TIMESTAMP = 4000L; public static final long TEST_CREATION_TIMESTAMP_1 = 2000L; public static final long TEST_CREATION_TIMESTAMP_2 = 2L; @@ -80,7 +77,4 @@ public final class FisAndroidTestConstants { .setTokenExpirationTimestamp(TEST_TOKEN_EXPIRATION_TIMESTAMP) .setResponseCode(TokenResult.ResponseCode.OK) .build(); - - public static final InstallationResponse SERVER_ERROR_INSTALLATION_RESPONSE = - InstallationResponse.builder().setResponseCode(ResponseCode.SERVER_ERROR).build(); } diff --git a/firebase-installations/src/androidTest/java/com/google/firebase/installations/local/PersistedInstallationTest.java b/firebase-installations/src/androidTest/java/com/google/firebase/installations/local/PersistedInstallationTest.java index a27e1885924..495dcd38991 100644 --- a/firebase-installations/src/androidTest/java/com/google/firebase/installations/local/PersistedInstallationTest.java +++ b/firebase-installations/src/androidTest/java/com/google/firebase/installations/local/PersistedInstallationTest.java @@ -24,7 +24,6 @@ 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.local.PersistedInstallationEntrySubject.assertThat; -import static org.junit.Assert.assertTrue; import androidx.test.core.app.ApplicationProvider; import androidx.test.runner.AndroidJUnit4; @@ -62,9 +61,9 @@ public void setUp() { } @After - public void cleanUp() throws Exception { - persistedInstallation0.clear(); - persistedInstallation1.clear(); + public void cleanUp() { + persistedInstallation0.clearForTesting(); + persistedInstallation1.clearForTesting(); } @Test @@ -77,17 +76,16 @@ public void testReadPersistedInstallationEntry_Null() { @Test public void testUpdateAndReadPersistedInstallationEntry_successful() throws Exception { - // Insert Persisted Installation Entry with Unregistered status in Shared Prefs - assertTrue( - persistedInstallation0.insertOrUpdatePersistedInstallationEntry( - PersistedInstallationEntry.builder() - .setFirebaseInstallationId(TEST_FID_1) - .setAuthToken(TEST_AUTH_TOKEN) - .setRefreshToken(TEST_REFRESH_TOKEN) - .setRegistrationStatus(PersistedInstallation.RegistrationStatus.UNREGISTERED) - .setTokenCreationEpochInSecs(TEST_CREATION_TIMESTAMP_1) - .setExpiresInSecs(TEST_TOKEN_EXPIRATION_TIMESTAMP) - .build())); + // Write the Persisted Installation Entry with Unregistered status to storage. + persistedInstallation0.insertOrUpdatePersistedInstallationEntry( + PersistedInstallationEntry.builder() + .setFirebaseInstallationId(TEST_FID_1) + .setAuthToken(TEST_AUTH_TOKEN) + .setRefreshToken(TEST_REFRESH_TOKEN) + .setRegistrationStatus(PersistedInstallation.RegistrationStatus.UNREGISTERED) + .setTokenCreationEpochInSecs(TEST_CREATION_TIMESTAMP_1) + .setExpiresInSecs(TEST_TOKEN_EXPIRATION_TIMESTAMP) + .build()); PersistedInstallationEntry entryValue = persistedInstallation0.readPersistedInstallationEntryValue(); @@ -99,17 +97,16 @@ public void testUpdateAndReadPersistedInstallationEntry_successful() throws Exce assertThat(entryValue).hasTokenExpirationTimestamp(TEST_TOKEN_EXPIRATION_TIMESTAMP); assertThat(entryValue).hasCreationTimestamp(TEST_CREATION_TIMESTAMP_1); - // Update Persisted Fid Entry with Registered status in Shared Prefs - assertTrue( - persistedInstallation0.insertOrUpdatePersistedInstallationEntry( - PersistedInstallationEntry.builder() - .setFirebaseInstallationId(TEST_FID_1) - .setAuthToken(TEST_AUTH_TOKEN) - .setRefreshToken(TEST_REFRESH_TOKEN) - .setRegistrationStatus(PersistedInstallation.RegistrationStatus.REGISTERED) - .setTokenCreationEpochInSecs(TEST_CREATION_TIMESTAMP_2) - .setExpiresInSecs(TEST_TOKEN_EXPIRATION_TIMESTAMP) - .build())); + // Write the Persisted Fid Entry with Registered status to storage. + persistedInstallation0.insertOrUpdatePersistedInstallationEntry( + PersistedInstallationEntry.builder() + .setFirebaseInstallationId(TEST_FID_1) + .setAuthToken(TEST_AUTH_TOKEN) + .setRefreshToken(TEST_REFRESH_TOKEN) + .setRegistrationStatus(PersistedInstallation.RegistrationStatus.REGISTERED) + .setTokenCreationEpochInSecs(TEST_CREATION_TIMESTAMP_2) + .setExpiresInSecs(TEST_TOKEN_EXPIRATION_TIMESTAMP) + .build()); entryValue = persistedInstallation0.readPersistedInstallationEntryValue(); // Validate update was successful 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 09d5a67d67e..3dc316a7b8e 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 @@ -19,23 +19,27 @@ import androidx.annotation.Nullable; import androidx.annotation.VisibleForTesting; import com.google.android.gms.common.internal.Preconditions; -import com.google.android.gms.common.util.DefaultClock; import com.google.android.gms.tasks.Task; import com.google.android.gms.tasks.TaskCompletionSource; import com.google.android.gms.tasks.Tasks; import com.google.firebase.FirebaseApp; import com.google.firebase.FirebaseException; import com.google.firebase.heartbeatinfo.HeartBeatInfo; +import com.google.firebase.installations.FirebaseInstallationsException.Status; import com.google.firebase.installations.local.IidStore; import com.google.firebase.installations.local.PersistedInstallation; -import com.google.firebase.installations.local.PersistedInstallation.RegistrationStatus; import com.google.firebase.installations.local.PersistedInstallationEntry; import com.google.firebase.installations.remote.FirebaseInstallationServiceClient; import com.google.firebase.installations.remote.InstallationResponse; -import com.google.firebase.installations.remote.InstallationResponse.ResponseCode; import com.google.firebase.installations.remote.TokenResult; import com.google.firebase.platforminfo.UserAgentPublisher; +import java.io.File; +import java.io.IOException; +import java.io.RandomAccessFile; +import java.nio.channels.FileChannel; +import java.nio.channels.FileLock; import java.util.ArrayList; +import java.util.Calendar; import java.util.Iterator; import java.util.List; import java.util.concurrent.ExecutorService; @@ -55,21 +59,23 @@ * */ public class FirebaseInstallations implements FirebaseInstallationsApi { - private final FirebaseApp firebaseApp; private final FirebaseInstallationServiceClient serviceClient; private final PersistedInstallation persistedInstallation; private final ExecutorService executor; private final Utils utils; private final IidStore iidStore; + private final RandomFidGenerator fidGenerator; private final Object lock = new Object(); - @GuardedBy("lock") - private boolean shouldRefreshAuthToken; - @GuardedBy("lock") private final List listeners = new ArrayList<>(); + /* used for thread-level synchronization of generating and persisting fids */ + private final Object lockGenerateFid = new Object(); + /* file used for process-level syncronization of generating and persisting fids */ + private static final String LOCKFILE_NAME_GENERATE_FID = "generatefid.lock"; + /** package private constructor. */ FirebaseInstallations( FirebaseApp firebaseApp, @@ -81,8 +87,9 @@ public class FirebaseInstallations implements FirebaseInstallationsApi { new FirebaseInstallationServiceClient( firebaseApp.getApplicationContext(), publisher, heartbeatInfo), new PersistedInstallation(firebaseApp), - new Utils(DefaultClock.getInstance()), - new IidStore()); + new Utils(Calendar.getInstance()), + new IidStore(), + new RandomFidGenerator()); } FirebaseInstallations( @@ -91,13 +98,15 @@ public class FirebaseInstallations implements FirebaseInstallationsApi { FirebaseInstallationServiceClient serviceClient, PersistedInstallation persistedInstallation, Utils utils, - IidStore iidStore) { + IidStore iidStore, + RandomFidGenerator fidGenerator) { this.firebaseApp = firebaseApp; this.serviceClient = serviceClient; this.executor = executor; this.persistedInstallation = persistedInstallation; this.utils = utils; this.iidStore = iidStore; + this.fidGenerator = fidGenerator; } /** @@ -143,7 +152,7 @@ String getName() { @Override public Task getId() { Task task = addGetIdListener(); - executor.execute(this::doRegistration); + executor.execute(this::doGetId); return task; } @@ -159,8 +168,12 @@ public Task getId() { @NonNull @Override public Task getToken(@AuthTokenOption int authTokenOption) { - Task task = addGetAuthTokenListener(authTokenOption); - executor.execute(this::doRegistration); + Task task = addGetAuthTokenListener(); + if (authTokenOption == FORCE_REFRESH) { + executor.execute(this::doGetAuthTokenForceRefresh); + } else { + executor.execute(this::doGetAuthTokenWithoutForceRefresh); + } return task; } @@ -184,15 +197,11 @@ private Task addGetIdListener() { return taskCompletionSource.getTask(); } - private Task addGetAuthTokenListener( - @AuthTokenOption int authTokenOption) { + private Task addGetAuthTokenListener() { TaskCompletionSource taskCompletionSource = new TaskCompletionSource<>(); StateListener l = new GetAuthTokenListener(utils, taskCompletionSource); synchronized (lock) { - if (authTokenOption == FORCE_REFRESH) { - shouldRefreshAuthToken = true; - } listeners.add(l); } return taskCompletionSource.getTask(); @@ -203,8 +212,7 @@ private void triggerOnStateReached(PersistedInstallationEntry persistedInstallat Iterator it = listeners.iterator(); while (it.hasNext()) { StateListener l = it.next(); - boolean doneListening = - l.onStateReached(persistedInstallationEntry, shouldRefreshAuthToken); + boolean doneListening = l.onStateReached(persistedInstallationEntry); if (doneListening) { it.remove(); } @@ -212,13 +220,12 @@ private void triggerOnStateReached(PersistedInstallationEntry persistedInstallat } } - private void triggerOnException( - PersistedInstallationEntry persistedInstallationEntry, Exception exception) { + private void triggerOnException(PersistedInstallationEntry prefs, Exception exception) { synchronized (lock) { Iterator it = listeners.iterator(); while (it.hasNext()) { StateListener l = it.next(); - boolean doneListening = l.onException(persistedInstallationEntry, exception); + boolean doneListening = l.onException(prefs, exception); if (doneListening) { it.remove(); } @@ -226,175 +233,199 @@ private void triggerOnException( } } - private final void doRegistration() { - try { - PersistedInstallationEntry persistedInstallationEntry = - persistedInstallation.readPersistedInstallationEntryValue(); + private final void doGetId() { + doRegistrationInternal(false); + } - // New FID needs to be created - if (persistedInstallationEntry.isNotGenerated()) { + private final void doGetAuthTokenWithoutForceRefresh() { + doRegistrationInternal(false); + } - // For a default firebase installation read the existing iid. For other custom firebase - // installations create a new fid - String fid = readExistingIidOrCreateFid(); - persistFid(fid); - persistedInstallationEntry = persistedInstallation.readPersistedInstallationEntryValue(); - } + private final void doGetAuthTokenForceRefresh() { + doRegistrationInternal(true); + } - if (persistedInstallationEntry.isErrored()) { - throw new FirebaseInstallationsException( - persistedInstallationEntry.getFisError(), - FirebaseInstallationsException.Status.SDK_INTERNAL_ERROR); - } + /** + * Logic for handling get id and the two forms of get auth token. This handles all the work, + * including creating a new FID if one hasn't been generated yet and making the network calls to + * create an installation and to retrieve a new auth token. Also contains the error handling for + * when the server says that credentials are bad and that a new Fid needs to be generated. + * + * @param forceRefresh true if this is for a getAuthToken call and if the caller wants to fetch a + * new auth token from the server even if an unexpired auth token exists on the client. + */ + private final void doRegistrationInternal(boolean forceRefresh) { + PersistedInstallationEntry prefs = getPrefsWithGeneratedIdMultiProcessSafe(); - triggerOnStateReached(persistedInstallationEntry); + // Since the caller wants to force an authtoken refresh remove the authtoken from the + // prefs we are working with, so the following steps know a new token is required. + if (forceRefresh) { + prefs = prefs.withClearedAuthToken(); + } - // FID needs to be registered - if (persistedInstallationEntry.isUnregistered()) { - registerAndSaveFid(persistedInstallationEntry); - persistedInstallationEntry = persistedInstallation.readPersistedInstallationEntryValue(); - // Newly registered Fid will have valid auth token. No refresh required. - synchronized (lock) { - shouldRefreshAuthToken = false; - } + triggerOnStateReached(prefs); + + // There are two possible cleanup steps to perform at this stage: the FID may need to + // be registered with the server or the FID is registered but we need a fresh authtoken. + // Registering will also result in a fresh authtoken. Do the appropriate step here. + try { + if (prefs.isErrored() || prefs.isUnregistered()) { + prefs = registerFidWithServer(prefs); + } else if (forceRefresh || utils.isAuthTokenExpired(prefs)) { + prefs = fetchAuthTokenFromServer(prefs); + } else { + // nothing more to do, get out now + return; } + } catch (IOException e) { + triggerOnException(prefs, e); + return; + } - // Don't notify the listeners at this point; we might as well make ure the auth token is up - // to date before letting them know. + // Store the prefs to persist the result of the previous step. + persistedInstallation.insertOrUpdatePersistedInstallationEntry(prefs); + + // Let the caller know about the result. + if (prefs.isErrored()) { + triggerOnException(prefs, new FirebaseInstallationsException(Status.BAD_CONFIG)); + } else if (prefs.isNotGenerated()) { + // If there is no fid it means the call failed with an auth error. Simulate an + // IOException so that the caller knows to try again. + triggerOnException(prefs, new IOException("cleared fid due to auth error")); + } else { + triggerOnStateReached(prefs); + } + } - boolean needRefresh = utils.isAuthTokenExpired(persistedInstallationEntry); - if (!needRefresh) { - synchronized (lock) { - needRefresh = shouldRefreshAuthToken; + /** + * Loads the prefs, generating a new ID if necessary. This operation is made cross-process and + * cross-thread safe by wrapping all the processing first in a java synchronization block and + * wrapping that in a cross-process lock created using FileLocks. + * + *

If a FID does not yet exist it generate a new FID, either from an existing IID or generated + * randomly. If an IID exists and this is the first time a FID has been generated for this + * installation, the IID will be used as the FID. If the FID is ever cleared then the next time a + * FID is generated the IID is ignored and a FID is generated randomly. + * + * @return a new version of the prefs that includes the new FID. These prefs will have already + * been persisted. + */ + private PersistedInstallationEntry getPrefsWithGeneratedIdMultiProcessSafe() { + FileLock fileLock = getCrossProcessLock(); + try { + synchronized (lockGenerateFid) { + PersistedInstallationEntry prefs = + persistedInstallation.readPersistedInstallationEntryValue(); + // Check if a new FID needs to be created + if (prefs.isNotGenerated()) { + // For a default firebase installation read the existing iid. For other custom firebase + // installations create a new fid + + // Only one single thread from one single process can execute this block + // at any given time. + String fid = readExistingIidOrCreateFid(prefs); + prefs = + persistedInstallation.insertOrUpdatePersistedInstallationEntry( + prefs.withUnregisteredFid(fid)); } + return prefs; } - TokenResult tokenResult = null; - // Refresh Auth token if needed - if (needRefresh) { - tokenResult = fetchAuthTokenFromServer(persistedInstallationEntry); - persistedInstallationEntry = persistedInstallation.readPersistedInstallationEntryValue(); - synchronized (lock) { - shouldRefreshAuthToken = false; - } - } + } finally { + releaseCrossProcessLock(fileLock); + } + } - // If tokenResult is not null and is not successful, it was cleared due to authentication - // error during auth token generation. - if (tokenResult != null && !tokenResult.isSuccessful()) { - triggerOnException( - persistedInstallationEntry, - new FirebaseInstallationsException( - "Failed to generate auth token for this Firebase Installation. Call getId() " - + "to recreate a new Fid and a valid auth token.", - FirebaseInstallationsException.Status.AUTHENTICATION_ERROR)); - return; - } + /** Use file locking to acquire a lock that will also block other processes. */ + private FileLock getCrossProcessLock() { + try { + File file = + new File(firebaseApp.getApplicationContext().getFilesDir(), LOCKFILE_NAME_GENERATE_FID); + FileChannel channel = new RandomAccessFile(file, "rw").getChannel(); + // Use the file channel to create a lock on the file. + // This method blocks until it can retrieve the lock. + return channel.lock(); + } catch (IOException e) { + throw new IllegalStateException("exception while using file locks, should never happen", e); + } + } - triggerOnStateReached(persistedInstallationEntry); - } catch (Exception e) { - PersistedInstallationEntry persistedInstallationEntry = - persistedInstallation.readPersistedInstallationEntryValue(); - PersistedInstallationEntry errorInstallationEntry = - persistedInstallationEntry - .toBuilder() - .setFisError(e.getMessage()) - .setRegistrationStatus(RegistrationStatus.REGISTER_ERROR) - .build(); - persistedInstallation.insertOrUpdatePersistedInstallationEntry(errorInstallationEntry); - triggerOnException(errorInstallationEntry, e); + /** Release a previously acquired lock. */ + private void releaseCrossProcessLock(FileLock fileLock) { + try { + fileLock.release(); + } catch (IOException e) { + throw new IllegalStateException("exception while using file locks, should never happen", e); } } - private String readExistingIidOrCreateFid() { + private String readExistingIidOrCreateFid(PersistedInstallationEntry prefs) { // Check if this firebase app is the default (first initialized) instance - if (!firebaseApp.equals(FirebaseApp.getInstance())) { - return utils.createRandomFid(); + if (!firebaseApp.equals(FirebaseApp.getInstance()) || !prefs.shouldAttemptMigration()) { + return fidGenerator.createRandomFid(); } // For a default firebase installation, read the existing iid from shared prefs String fid = iidStore.readIid(); if (fid == null) { - fid = utils.createRandomFid(); + fid = fidGenerator.createRandomFid(); } return fid; } - private void persistFid(String fid) throws FirebaseInstallationsException { - boolean firstUpdateCacheResult = - persistedInstallation.insertOrUpdatePersistedInstallationEntry( - PersistedInstallationEntry.builder() - .setFirebaseInstallationId(fid) - .setRegistrationStatus(RegistrationStatus.UNREGISTERED) - .build()); - - if (!firstUpdateCacheResult) { - throw new FirebaseInstallationsException( - "Failed to update client side cache.", - FirebaseInstallationsException.Status.CLIENT_ERROR); + /** Registers the created Fid with FIS servers and update the persisted state. */ + private PersistedInstallationEntry registerFidWithServer(PersistedInstallationEntry prefs) + throws IOException { + InstallationResponse response = + serviceClient.createFirebaseInstallation( + /*apiKey= */ firebaseApp.getOptions().getApiKey(), + /*fid= */ prefs.getFirebaseInstallationId(), + /*projectID= */ firebaseApp.getOptions().getProjectId(), + /*appId= */ getApplicationId()); + + switch (response.getResponseCode()) { + case OK: + return prefs.withRegisteredFid( + response.getFid(), + response.getRefreshToken(), + utils.currentTimeInSecs(), + response.getAuthToken().getToken(), + response.getAuthToken().getTokenExpirationTimestamp()); + case BAD_CONFIG: + return prefs.withFisError("BAD CONFIG"); + default: + throw new IOException(); } } - /** Registers the created Fid with FIS servers and update the shared prefs. */ - private Void registerAndSaveFid(PersistedInstallationEntry persistedInstallationEntry) - throws FirebaseInstallationsException { - try { - long creationTime = utils.currentTimeInSecs(); - - InstallationResponse installationResponse = - serviceClient.createFirebaseInstallation( - /*apiKey= */ firebaseApp.getOptions().getApiKey(), - /*fid= */ persistedInstallationEntry.getFirebaseInstallationId(), - /*projectID= */ firebaseApp.getOptions().getProjectId(), - /*appId= */ getApplicationId()); - if (installationResponse.getResponseCode() == ResponseCode.OK) { - persistedInstallation.insertOrUpdatePersistedInstallationEntry( - PersistedInstallationEntry.builder() - .setFirebaseInstallationId(installationResponse.getFid()) - .setRegistrationStatus(RegistrationStatus.REGISTERED) - .setAuthToken(installationResponse.getAuthToken().getToken()) - .setRefreshToken(installationResponse.getRefreshToken()) - .setExpiresInSecs(installationResponse.getAuthToken().getTokenExpirationTimestamp()) - .setTokenCreationEpochInSecs(creationTime) - .build()); - } - - } catch (FirebaseException exception) { - throw new FirebaseInstallationsException( - exception.getMessage(), FirebaseInstallationsException.Status.SDK_INTERNAL_ERROR); - } - return null; - } - - /** Calls the FIS servers to generate an auth token for this Firebase installation. */ - private TokenResult fetchAuthTokenFromServer( - PersistedInstallationEntry persistedInstallationEntry) throws FirebaseInstallationsException { - try { - long creationTime = utils.currentTimeInSecs(); - TokenResult tokenResult = - serviceClient.generateAuthToken( - /*apiKey= */ firebaseApp.getOptions().getApiKey(), - /*fid= */ persistedInstallationEntry.getFirebaseInstallationId(), - /*projectID= */ firebaseApp.getOptions().getProjectId(), - /*refreshToken= */ persistedInstallationEntry.getRefreshToken()); - - if (tokenResult.isSuccessful()) { - persistedInstallation.insertOrUpdatePersistedInstallationEntry( - persistedInstallationEntry - .toBuilder() - .setRegistrationStatus(RegistrationStatus.REGISTERED) - .setAuthToken(tokenResult.getToken()) - .setExpiresInSecs(tokenResult.getTokenExpirationTimestamp()) - .setTokenCreationEpochInSecs(creationTime) - .build()); - } else { - persistedInstallation.clear(); - } - return tokenResult; - - } catch (FirebaseException exception) { - throw new FirebaseInstallationsException( - "Failed to generate auth token for a Firebase Installation.", - FirebaseInstallationsException.Status.SDK_INTERNAL_ERROR); + /** + * Calls the FIS servers to generate an auth token for this Firebase installation. Returns a + * PersistedInstallationEntry with the new authtoken. The authtoken in the returned + * PersistedInstallationEntry will be "expired" if the server refuses to generate an auth token + * for the fid. + */ + private PersistedInstallationEntry fetchAuthTokenFromServer( + @NonNull PersistedInstallationEntry prefs) throws IOException { + TokenResult tokenResult = + serviceClient.generateAuthToken( + /*apiKey= */ firebaseApp.getOptions().getApiKey(), + /*fid= */ prefs.getFirebaseInstallationId(), + /*projectID= */ firebaseApp.getOptions().getProjectId(), + /*refreshToken= */ prefs.getRefreshToken()); + + switch (tokenResult.getResponseCode()) { + case OK: + return prefs.withAuthToken( + tokenResult.getToken(), + tokenResult.getTokenExpirationTimestamp(), + utils.currentTimeInSecs()); + case BAD_CONFIG: + return prefs.withFisError("BAD CONFIG"); + case AUTH_ERROR: + // The the server refused to generate a new auth token due to bad credentials, clear the + // FID to force the generation of a new one. + return prefs.withNoGeneratedFid(); + default: + throw new IOException(); } } @@ -402,28 +433,24 @@ private TokenResult fetchAuthTokenFromServer( * Deletes the firebase installation id of the {@link FirebaseApp} from FIS servers and local * storage. */ - private Void deleteFirebaseInstallationId() throws FirebaseInstallationsException { - - PersistedInstallationEntry persistedInstallationEntry = - persistedInstallation.readPersistedInstallationEntryValue(); - - if (persistedInstallationEntry.isRegistered()) { + private Void deleteFirebaseInstallationId() throws FirebaseInstallationsException, IOException { + PersistedInstallationEntry entry = persistedInstallation.readPersistedInstallationEntryValue(); + if (entry.isRegistered()) { // Call the FIS servers to delete this Firebase Installation Id. try { serviceClient.deleteFirebaseInstallation( firebaseApp.getOptions().getApiKey(), - persistedInstallationEntry.getFirebaseInstallationId(), + entry.getFirebaseInstallationId(), firebaseApp.getOptions().getProjectId(), - persistedInstallationEntry.getRefreshToken()); + entry.getRefreshToken()); } catch (FirebaseException exception) { throw new FirebaseInstallationsException( - "Failed to delete a Firebase Installation.", - FirebaseInstallationsException.Status.SDK_INTERNAL_ERROR); + "Failed to delete a Firebase Installation.", Status.BAD_CONFIG); } } - persistedInstallation.clear(); + persistedInstallation.insertOrUpdatePersistedInstallationEntry(entry.withNoGeneratedFid()); return null; } } diff --git a/firebase-installations/src/main/java/com/google/firebase/installations/FirebaseInstallationsException.java b/firebase-installations/src/main/java/com/google/firebase/installations/FirebaseInstallationsException.java index 4309a782ffd..07683203570 100644 --- a/firebase-installations/src/main/java/com/google/firebase/installations/FirebaseInstallationsException.java +++ b/firebase-installations/src/main/java/com/google/firebase/installations/FirebaseInstallationsException.java @@ -19,13 +19,12 @@ /** The class for all Exceptions thrown by {@link FirebaseInstallations}. */ public class FirebaseInstallationsException extends FirebaseException { - // TODO(ankitagj): Improve exception handling and java doc public enum Status { - SDK_INTERNAL_ERROR, - - CLIENT_ERROR, - - AUTHENTICATION_ERROR + /** + * Indicates that the caller is misconfigured, usually with a bad or misconfigured API Key or + * Project. + */ + BAD_CONFIG, } @NonNull private final Status status; diff --git a/firebase-installations/src/main/java/com/google/firebase/installations/GetAuthTokenListener.java b/firebase-installations/src/main/java/com/google/firebase/installations/GetAuthTokenListener.java index bc36a20bac6..2c067a30507 100644 --- a/firebase-installations/src/main/java/com/google/firebase/installations/GetAuthTokenListener.java +++ b/firebase-installations/src/main/java/com/google/firebase/installations/GetAuthTokenListener.java @@ -28,12 +28,10 @@ public GetAuthTokenListener( } @Override - public boolean onStateReached( - PersistedInstallationEntry persistedInstallationEntry, boolean shouldRefreshAuthToken) { + public boolean onStateReached(PersistedInstallationEntry persistedInstallationEntry) { // AuthTokenListener state is reached when FID is registered and has a valid auth token if (persistedInstallationEntry.isRegistered() - && !utils.isAuthTokenExpired(persistedInstallationEntry) - && !shouldRefreshAuthToken) { + && !utils.isAuthTokenExpired(persistedInstallationEntry)) { resultTaskCompletionSource.setResult( InstallationTokenResult.builder() .setToken(persistedInstallationEntry.getAuthToken()) @@ -48,7 +46,9 @@ public boolean onStateReached( @Override public boolean onException( PersistedInstallationEntry persistedInstallationEntry, Exception exception) { - if (persistedInstallationEntry.isErrored() || persistedInstallationEntry.isNotGenerated()) { + if (persistedInstallationEntry.isErrored() + || persistedInstallationEntry.isNotGenerated() + || persistedInstallationEntry.isUnregistered()) { resultTaskCompletionSource.trySetException(exception); return true; } diff --git a/firebase-installations/src/main/java/com/google/firebase/installations/GetIdListener.java b/firebase-installations/src/main/java/com/google/firebase/installations/GetIdListener.java index 38134cc6217..44f534f7da1 100644 --- a/firebase-installations/src/main/java/com/google/firebase/installations/GetIdListener.java +++ b/firebase-installations/src/main/java/com/google/firebase/installations/GetIdListener.java @@ -25,9 +25,10 @@ public GetIdListener(TaskCompletionSource taskCompletionSource) { } @Override - public boolean onStateReached( - PersistedInstallationEntry persistedInstallationEntry, boolean unused) { - if (persistedInstallationEntry.isUnregistered() || persistedInstallationEntry.isRegistered()) { + public boolean onStateReached(PersistedInstallationEntry persistedInstallationEntry) { + if (persistedInstallationEntry.isUnregistered() + || persistedInstallationEntry.isRegistered() + || persistedInstallationEntry.isErrored()) { taskCompletionSource.trySetResult(persistedInstallationEntry.getFirebaseInstallationId()); return true; } diff --git a/firebase-installations/src/main/java/com/google/firebase/installations/RandomFidGenerator.java b/firebase-installations/src/main/java/com/google/firebase/installations/RandomFidGenerator.java new file mode 100644 index 00000000000..9931d20045c --- /dev/null +++ b/firebase-installations/src/main/java/com/google/firebase/installations/RandomFidGenerator.java @@ -0,0 +1,84 @@ +// 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 java.nio.ByteBuffer; +import java.nio.charset.Charset; +import java.util.UUID; + +public class RandomFidGenerator { + /** + * 1 Byte with the first 4 header-bits set to the identifying FID prefix 0111 (0x7). Use this + * constant to create FIDs or check the first byte of FIDs. This prefix is also used in legacy + * Instance-IDs + */ + private static final byte FID_4BIT_PREFIX = Byte.parseByte("01110000", 2); + + /** + * Byte mask to remove the 4 header-bits of a given Byte. Use this constant with Java's Binary AND + * Operator in order to remove the first 4 bits of a Byte and replacing it with the FID prefix. + */ + private static final byte REMOVE_PREFIX_MASK = Byte.parseByte("00001111", 2); + + /** Length of new-format FIDs as introduced in 2019. */ + private static final int FID_LENGTH = 22; + + /** + * Creates a random FID of valid format without checking if the FID is already in use by any + * Firebase Installation. + * + *

Note: Even though this method does not check with the FIS database if the returned FID is + * already in use, the probability of collision is extremely and negligibly small! + * + * @return random FID value + */ + @NonNull + public String createRandomFid() { + // A valid FID has exactly 22 base64 characters, which is 132 bits, or 16.5 bytes. + byte[] uuidBytes = getBytesFromUUID(UUID.randomUUID(), new byte[17]); + uuidBytes[16] = uuidBytes[0]; + uuidBytes[0] = (byte) ((REMOVE_PREFIX_MASK & uuidBytes[0]) | FID_4BIT_PREFIX); + return encodeFidBase64UrlSafe(uuidBytes); + } + + /** + * Converts a given byte-array (assumed to be an FID value) to base64-url-safe encoded + * String-representation. + * + *

Note: The returned String has at most 22 characters, the length of FIDs. Thus, it is + * recommended to deliver a byte-array containing at least 16.5 bytes. + * + * @param rawValue FID value to be encoded + * @return (22-character or shorter) String containing the base64-encoded value + */ + private static String encodeFidBase64UrlSafe(byte[] rawValue) { + return new String( + android.util.Base64.encode( + rawValue, + android.util.Base64.URL_SAFE + | android.util.Base64.NO_PADDING + | android.util.Base64.NO_WRAP), + Charset.defaultCharset()) + .substring(0, FID_LENGTH); + } + + private static byte[] getBytesFromUUID(UUID uuid, byte[] output) { + ByteBuffer bb = ByteBuffer.wrap(output); + bb.putLong(uuid.getMostSignificantBits()); + bb.putLong(uuid.getLeastSignificantBits()); + return bb.array(); + } +} diff --git a/firebase-installations/src/main/java/com/google/firebase/installations/StateListener.java b/firebase-installations/src/main/java/com/google/firebase/installations/StateListener.java index d9570835adf..8cd08bdb299 100644 --- a/firebase-installations/src/main/java/com/google/firebase/installations/StateListener.java +++ b/firebase-installations/src/main/java/com/google/firebase/installations/StateListener.java @@ -21,8 +21,7 @@ interface StateListener { * Returns {@code true} if the defined {@link PersistedInstallationEntry} state is reached, {@code * false} otherwise. */ - boolean onStateReached( - PersistedInstallationEntry persistedInstallationEntry, boolean shouldRefreshAuthToken); + boolean onStateReached(PersistedInstallationEntry persistedInstallationEntry); /** * Returns {@code true} if an exception is thrown while registering a Firebase Installation, diff --git a/firebase-installations/src/main/java/com/google/firebase/installations/Utils.java b/firebase-installations/src/main/java/com/google/firebase/installations/Utils.java index 22bf2576ea5..d2f748ce734 100644 --- a/firebase-installations/src/main/java/com/google/firebase/installations/Utils.java +++ b/firebase-installations/src/main/java/com/google/firebase/installations/Utils.java @@ -14,99 +14,37 @@ package com.google.firebase.installations; -import androidx.annotation.NonNull; -import com.google.android.gms.common.util.Clock; +import android.text.TextUtils; import com.google.firebase.installations.local.PersistedInstallationEntry; -import java.nio.ByteBuffer; -import java.nio.charset.Charset; -import java.util.UUID; +import java.util.Calendar; import java.util.concurrent.TimeUnit; /** Util methods used for {@link FirebaseInstallations} */ class Utils { + public static final long AUTH_TOKEN_EXPIRATION_BUFFER_IN_SECS = TimeUnit.HOURS.toSeconds(1); + private final Calendar calendar; - private final Clock clock; - /** - * 1 Byte with the first 4 header-bits set to the identifying FID prefix 0111 (0x7). Use this - * constant to create FIDs or check the first byte of FIDs. This prefix is also used in legacy - * Instance-IDs - */ - public static final byte FID_4BIT_PREFIX = Byte.parseByte("01110000", 2); - - /** - * Byte mask to remove the 4 header-bits of a given Byte. Use this constant with Java's Binary AND - * Operator in order to remove the first 4 bits of a Byte and replacing it with the FID prefix. - */ - public static final byte REMOVE_PREFIX_MASK = Byte.parseByte("00001111", 2); - - /** Length of new-format FIDs as introduced in 2019. */ - public static final int FID_LENGTH = 22; - - private static final long AUTH_TOKEN_EXPIRATION_BUFFER_IN_SECS = TimeUnit.HOURS.toSeconds(1); - - Utils(Clock clock) { - this.clock = clock; + Utils(Calendar calendar) { + this.calendar = calendar; } /** * Checks if the FIS Auth token is expired or going to expire in next 1 hour {@link * #AUTH_TOKEN_EXPIRATION_BUFFER_IN_SECS}. */ - public boolean isAuthTokenExpired(PersistedInstallationEntry persistedInstallationEntry) { - return persistedInstallationEntry.isRegistered() - && persistedInstallationEntry.getTokenCreationEpochInSecs() - + persistedInstallationEntry.getExpiresInSecs() - < currentTimeInSecs() + AUTH_TOKEN_EXPIRATION_BUFFER_IN_SECS; + public boolean isAuthTokenExpired(PersistedInstallationEntry entry) { + if (TextUtils.isEmpty(entry.getAuthToken())) { + return true; + } + if ((entry.getTokenCreationEpochInSecs() + entry.getExpiresInSecs()) + < (currentTimeInSecs() + AUTH_TOKEN_EXPIRATION_BUFFER_IN_SECS)) { + return true; + } + return false; } /** Returns current time in seconds. */ public long currentTimeInSecs() { - return TimeUnit.MILLISECONDS.toSeconds(clock.currentTimeMillis()); - } - - /** - * Creates a random FID of valid format without checking if the FID is already in use by any - * Firebase Installation. - * - *

Note: Even though this method does not check with the FIS database if the returned FID is - * already in use, the probability of collision is extremely and negligibly small! - * - * @return random FID value - */ - @NonNull - public String createRandomFid() { - // A valid FID has exactly 22 base64 characters, which is 132 bits, or 16.5 bytes. - byte[] uuidBytes = getBytesFromUUID(UUID.randomUUID(), new byte[17]); - uuidBytes[16] = uuidBytes[0]; - uuidBytes[0] = (byte) ((REMOVE_PREFIX_MASK & uuidBytes[0]) | FID_4BIT_PREFIX); - return encodeFidBase64UrlSafe(uuidBytes); - } - - /** - * Converts a given byte-array (assumed to be an FID value) to base64-url-safe encoded - * String-representation. - * - *

Note: The returned String has at most 22 characters, the length of FIDs. Thus, it is - * recommended to deliver a byte-array containing at least 16.5 bytes. - * - * @param rawValue FID value to be encoded - * @return (22-character or shorter) String containing the base64-encoded value - */ - private static String encodeFidBase64UrlSafe(byte[] rawValue) { - return new String( - android.util.Base64.encode( - rawValue, - android.util.Base64.URL_SAFE - | android.util.Base64.NO_PADDING - | android.util.Base64.NO_WRAP), - Charset.defaultCharset()) - .substring(0, FID_LENGTH); - } - - private static byte[] getBytesFromUUID(UUID uuid, byte[] output) { - ByteBuffer bb = ByteBuffer.wrap(output); - bb.putLong(uuid.getMostSignificantBits()); - bb.putLong(uuid.getLeastSignificantBits()); - return bb.array(); + return TimeUnit.MILLISECONDS.toSeconds(calendar.getTimeInMillis()); } } diff --git a/firebase-installations/src/main/java/com/google/firebase/installations/local/PersistedInstallation.java b/firebase-installations/src/main/java/com/google/firebase/installations/local/PersistedInstallation.java index fdee274945d..d3786c4b6e4 100644 --- a/firebase-installations/src/main/java/com/google/firebase/installations/local/PersistedInstallation.java +++ b/firebase-installations/src/main/java/com/google/firebase/installations/local/PersistedInstallation.java @@ -14,23 +14,33 @@ package com.google.firebase.installations.local; -import android.content.Context; -import android.content.SharedPreferences; -import androidx.annotation.GuardedBy; import androidx.annotation.NonNull; import com.google.firebase.FirebaseApp; -import java.util.Arrays; -import java.util.List; +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.IOException; +import org.json.JSONException; +import org.json.JSONObject; /** * A layer that locally persists a few Firebase Installation attributes on top the Firebase * Installation API. */ public class PersistedInstallation { + private final File dataFile; + @NonNull private final FirebaseApp firebaseApp; + // Registration Status of each persisted fid entry - // NOTE: never change the ordinal of the enum values because the enum values are stored in shared - // prefs as their ordinal numbers. + // NOTE: never change the ordinal of the enum values because the enum values are written to + // local storage as their ordinal numbers. public enum RegistrationStatus { + /** + * {@link PersistedInstallationEntry} legacy registration status. Next state: UNREGISTERED - A + * new FID is created and persisted locally before registering with FIS servers. + */ + ATTEMPT_MIGRATION, /** * {@link PersistedInstallationEntry} default registration status. Next state: UNREGISTERED - A * new FID is created and persisted locally before registering with FIS servers. @@ -55,8 +65,7 @@ public enum RegistrationStatus { REGISTER_ERROR, } - private static final String SHARED_PREFS_NAME = "PersistedInstallation"; - + private static final String SETTINGS_FILE_NAME_PREFIX = "PersistedInstallation"; private static final String FIREBASE_INSTALLATION_ID_KEY = "Fid"; private static final String AUTH_TOKEN_KEY = "AuthToken"; private static final String REFRESH_TOKEN_KEY = "RefreshToken"; @@ -65,94 +74,103 @@ public enum RegistrationStatus { private static final String PERSISTED_STATUS_KEY = "Status"; private static final String FIS_ERROR_KEY = "FisError"; - private static final List FID_PREF_KEYS = - Arrays.asList( - FIREBASE_INSTALLATION_ID_KEY, - AUTH_TOKEN_KEY, - REFRESH_TOKEN_KEY, - TOKEN_CREATION_TIME_IN_SECONDS_KEY, - EXPIRES_IN_SECONDS_KEY, - PERSISTED_STATUS_KEY, - FIS_ERROR_KEY); - - @GuardedBy("prefs") - private final SharedPreferences prefs; - - private final String persistenceKey; - public PersistedInstallation(@NonNull FirebaseApp firebaseApp) { // Different FirebaseApp in the same Android application should have the same application // context and same dir path - prefs = - firebaseApp - .getApplicationContext() - .getSharedPreferences(SHARED_PREFS_NAME, Context.MODE_PRIVATE); - persistenceKey = firebaseApp.getPersistenceKey(); + dataFile = + new File( + firebaseApp.getApplicationContext().getFilesDir(), + SETTINGS_FILE_NAME_PREFIX + "." + firebaseApp.getPersistenceKey() + ".json"); + this.firebaseApp = firebaseApp; } @NonNull public PersistedInstallationEntry readPersistedInstallationEntryValue() { - synchronized (prefs) { - 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); - String refreshToken = prefs.getString(getSharedPreferencesKey(REFRESH_TOKEN_KEY), null); - long tokenCreationTime = - prefs.getLong(getSharedPreferencesKey(TOKEN_CREATION_TIME_IN_SECONDS_KEY), 0); - long expiresIn = prefs.getLong(getSharedPreferencesKey(EXPIRES_IN_SECONDS_KEY), 0); - String fisError = prefs.getString(getSharedPreferencesKey(FIS_ERROR_KEY), null); + JSONObject json = readJSONFromFile(); - if (fid == null || !(status >= 0 && status < RegistrationStatus.values().length)) { - return PersistedInstallationEntry.builder().build(); - } - return PersistedInstallationEntry.builder() - .setFirebaseInstallationId(fid) - .setRegistrationStatus(RegistrationStatus.values()[status]) - .setAuthToken(authToken) - .setRefreshToken(refreshToken) - .setTokenCreationEpochInSecs(tokenCreationTime) - .setExpiresInSecs(expiresIn) - .setFisError(fisError) - .build(); - } + String fid = json.optString(FIREBASE_INSTALLATION_ID_KEY, null); + int status = json.optInt(PERSISTED_STATUS_KEY, RegistrationStatus.ATTEMPT_MIGRATION.ordinal()); + String authToken = json.optString(AUTH_TOKEN_KEY, null); + String refreshToken = json.optString(REFRESH_TOKEN_KEY, null); + long tokenCreationTime = json.optLong(TOKEN_CREATION_TIME_IN_SECONDS_KEY, 0); + long expiresIn = json.optLong(EXPIRES_IN_SECONDS_KEY, 0); + String fisError = json.optString(FIS_ERROR_KEY, null); + + PersistedInstallationEntry prefs = + PersistedInstallationEntry.builder() + .setFirebaseInstallationId(fid) + .setRegistrationStatus(RegistrationStatus.values()[status]) + .setAuthToken(authToken) + .setRefreshToken(refreshToken) + .setTokenCreationEpochInSecs(tokenCreationTime) + .setExpiresInSecs(expiresIn) + .setFisError(fisError) + .build(); + return prefs; } - @NonNull - public boolean insertOrUpdatePersistedInstallationEntry( - @NonNull PersistedInstallationEntry 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()); - editor.putString(getSharedPreferencesKey(FIS_ERROR_KEY), entryValue.getFisError()); - return editor.commit(); + private JSONObject readJSONFromFile() { + final ByteArrayOutputStream baos = new ByteArrayOutputStream(); + final byte[] tmpBuf = new byte[16 * 1024]; + try (FileInputStream fis = new FileInputStream(dataFile)) { + while (true) { + int numRead = fis.read(tmpBuf, 0, tmpBuf.length); + if (numRead < 0) { + break; + } + baos.write(tmpBuf, 0, numRead); + } + return new JSONObject(baos.toString()); + } catch (IOException | JSONException e) { + return new JSONObject(); } } + /** + * Write the prefs to a JSON object, serialize them into a JSON string and write the bytes to a + * temp file. After writing and closing the temp file, rename it over to the actual + * SETTINGS_FILE_NAME. + */ @NonNull - public boolean clear() { - synchronized (prefs) { - SharedPreferences.Editor editor = prefs.edit(); - for (String k : FID_PREF_KEYS) { - editor.remove(getSharedPreferencesKey(k)); + public PersistedInstallationEntry insertOrUpdatePersistedInstallationEntry( + @NonNull PersistedInstallationEntry prefs) { + try { + // Write the prefs into a JSON object + JSONObject json = new JSONObject(); + json.put(FIREBASE_INSTALLATION_ID_KEY, prefs.getFirebaseInstallationId()); + json.put(PERSISTED_STATUS_KEY, prefs.getRegistrationStatus().ordinal()); + json.put(AUTH_TOKEN_KEY, prefs.getAuthToken()); + json.put(REFRESH_TOKEN_KEY, prefs.getRefreshToken()); + json.put(TOKEN_CREATION_TIME_IN_SECONDS_KEY, prefs.getTokenCreationEpochInSecs()); + json.put(EXPIRES_IN_SECONDS_KEY, prefs.getExpiresInSecs()); + json.put(FIS_ERROR_KEY, prefs.getFisError()); + File tmpFile = + File.createTempFile( + SETTINGS_FILE_NAME_PREFIX, "tmp", firebaseApp.getApplicationContext().getFilesDir()); + + // Werialize the JSON object into a string and write the bytes to a temp file + FileOutputStream fos = new FileOutputStream(tmpFile); + fos.write(json.toString().getBytes("UTF-8")); + fos.close(); + + // Snapshot the temp file to the actual file + if (!tmpFile.renameTo(dataFile)) { + throw new IOException("unable to rename the tmpfile to " + SETTINGS_FILE_NAME_PREFIX); } - editor.commit(); - return editor.commit(); + } catch (JSONException | IOException e) { + // This should only happen when the storage is full or the system is corrupted. + // There isn't a lot we can do when this happens, other than crash the process. It is a + // bit nicer to eat the error and hope that the user clears some storage space on their + // device. } + + // Return the prefs that were written to make it easy for the caller to use them in a + // future step (e.g. for chaining calls). + return prefs; } - private String getSharedPreferencesKey(String key) { - return String.format("%s|%s", persistenceKey, key); + /** Sets the state to ATTEMPT_MIGRATION. */ + public void clearForTesting() { + dataFile.delete(); } } diff --git a/firebase-installations/src/main/java/com/google/firebase/installations/local/PersistedInstallationEntry.java b/firebase-installations/src/main/java/com/google/firebase/installations/local/PersistedInstallationEntry.java index aa2ffee2e58..f58f49a6afb 100644 --- a/firebase-installations/src/main/java/com/google/firebase/installations/local/PersistedInstallationEntry.java +++ b/firebase-installations/src/main/java/com/google/firebase/installations/local/PersistedInstallationEntry.java @@ -17,6 +17,7 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; import com.google.auto.value.AutoValue; +import com.google.firebase.installations.local.PersistedInstallation.RegistrationStatus; /** * This class represents a persisted fid entry in {@link PersistedInstallation}, which contains a @@ -44,6 +45,9 @@ public abstract class PersistedInstallationEntry { @Nullable public abstract String getFisError(); + @NonNull + public static PersistedInstallationEntry INSTANCE = PersistedInstallationEntry.builder().build(); + public boolean isRegistered() { return getRegistrationStatus() == PersistedInstallation.RegistrationStatus.REGISTERED; } @@ -57,7 +61,65 @@ public boolean isUnregistered() { } public boolean isNotGenerated() { - return getRegistrationStatus() == PersistedInstallation.RegistrationStatus.NOT_GENERATED; + return getRegistrationStatus() == PersistedInstallation.RegistrationStatus.NOT_GENERATED + || getRegistrationStatus() == RegistrationStatus.ATTEMPT_MIGRATION; + } + + public boolean shouldAttemptMigration() { + return getRegistrationStatus() == RegistrationStatus.ATTEMPT_MIGRATION; + } + + @NonNull + public PersistedInstallationEntry withUnregisteredFid(@NonNull String fid) { + return toBuilder() + .setFirebaseInstallationId(fid) + .setRegistrationStatus(RegistrationStatus.UNREGISTERED) + .build(); + } + + @NonNull + public PersistedInstallationEntry withRegisteredFid( + @NonNull String fid, + @NonNull String refreshToken, + long creationTime, + @Nullable String authToken, + long authTokenExpiration) { + return toBuilder() + .setFirebaseInstallationId(fid) + .setRegistrationStatus(RegistrationStatus.REGISTERED) + .setAuthToken(authToken) + .setRefreshToken(refreshToken) + .setExpiresInSecs(authTokenExpiration) + .setTokenCreationEpochInSecs(creationTime) + .build(); + } + + @NonNull + public PersistedInstallationEntry withFisError(@NonNull String message) { + return toBuilder() + .setFisError(message) + .setRegistrationStatus(RegistrationStatus.REGISTER_ERROR) + .build(); + } + + @NonNull + public PersistedInstallationEntry withNoGeneratedFid() { + return toBuilder().setRegistrationStatus(RegistrationStatus.NOT_GENERATED).build(); + } + + @NonNull + public PersistedInstallationEntry withAuthToken( + @NonNull String authToken, long authTokenExpiration, long creationTime) { + return toBuilder() + .setAuthToken(authToken) + .setExpiresInSecs(authTokenExpiration) + .setTokenCreationEpochInSecs(creationTime) + .build(); + } + + @NonNull + public PersistedInstallationEntry withClearedAuthToken() { + return toBuilder().setAuthToken(null).build(); } @NonNull @@ -68,7 +130,7 @@ public boolean isNotGenerated() { public static PersistedInstallationEntry.Builder builder() { return new AutoValue_PersistedInstallationEntry.Builder() .setTokenCreationEpochInSecs(0) - .setRegistrationStatus(PersistedInstallation.RegistrationStatus.NOT_GENERATED) + .setRegistrationStatus(RegistrationStatus.ATTEMPT_MIGRATION) .setExpiresInSecs(0); } 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 a981635c02c..bb392f9472e 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 @@ -29,6 +29,8 @@ import com.google.firebase.FirebaseException; import com.google.firebase.heartbeatinfo.HeartBeatInfo; import com.google.firebase.heartbeatinfo.HeartBeatInfo.HeartBeat; +import com.google.firebase.installations.FirebaseInstallationsException; +import com.google.firebase.installations.FirebaseInstallationsException.Status; import com.google.firebase.installations.remote.InstallationResponse.ResponseCode; import com.google.firebase.platforminfo.UserAgentPublisher; import java.io.BufferedReader; @@ -65,9 +67,6 @@ public class FirebaseInstallationServiceClient { private static final String HEART_BEAT_HEADER = "x-firebase-client-log-type"; private static final String USER_AGENT_HEADER = "x-firebase-client"; - private static final String INTERNAL_SERVER_ERROR_MESSAGE = "There was an internal server error."; - private static final String NETWORK_ERROR_MESSAGE = "The server returned an unexpected error: %s"; - private static final String X_ANDROID_PACKAGE_HEADER_KEY = "X-Android-Package"; private static final String X_ANDROID_CERT_HEADER_KEY = "X-Android-Cert"; @@ -102,57 +101,60 @@ public FirebaseInstallationServiceClient( * @param projectID Project Id * @param appId the identifier of a Firebase application * @return {@link InstallationResponse} generated from the response body + *

    + *
  • 400: return response with status BAD_CONFIG + *
  • 403: return response with status BAD_CONFIG + *
  • 403: return response with status BAD_CONFIG + *
  • 429: throw IOException + *
  • 500: throw IOException + *
*/ @NonNull public InstallationResponse createFirebaseInstallation( @NonNull String apiKey, @NonNull String fid, @NonNull String projectID, @NonNull String appId) - throws FirebaseException { + throws IOException { String resourceName = String.format(CREATE_REQUEST_RESOURCE_NAME_FORMAT, projectID); - try { - int retryCount = 0; - URL url = - new URL( - String.format( - "https://%s/%s/%s?key=%s", - FIREBASE_INSTALLATIONS_API_DOMAIN, - FIREBASE_INSTALLATIONS_API_VERSION, - resourceName, - apiKey)); - while (retryCount <= MAX_RETRIES) { - HttpsURLConnection httpsURLConnection = openHttpsURLConnection(url); - httpsURLConnection.setRequestMethod("POST"); - httpsURLConnection.setDoOutput(true); - - GZIPOutputStream gzipOutputStream = - new GZIPOutputStream(httpsURLConnection.getOutputStream()); - try { - gzipOutputStream.write( - buildCreateFirebaseInstallationRequestBody(fid, appId).toString().getBytes("UTF-8")); - } catch (JSONException e) { - throw new IllegalStateException(e); - } finally { - gzipOutputStream.close(); - } + int retryCount = 0; + URL url = + new URL( + String.format( + "https://%s/%s/%s?key=%s", + FIREBASE_INSTALLATIONS_API_DOMAIN, + FIREBASE_INSTALLATIONS_API_VERSION, + resourceName, + apiKey)); + while (retryCount <= MAX_RETRIES) { + HttpsURLConnection httpsURLConnection = openHttpsURLConnection(url); + httpsURLConnection.setRequestMethod("POST"); + httpsURLConnection.setDoOutput(true); + + GZIPOutputStream gzipOutputStream = + new GZIPOutputStream(httpsURLConnection.getOutputStream()); + try { + gzipOutputStream.write( + buildCreateFirebaseInstallationRequestBody(fid, appId).toString().getBytes("UTF-8")); + } catch (JSONException e) { + throw new IllegalStateException(e); + } finally { + gzipOutputStream.close(); + } - int httpResponseCode = httpsURLConnection.getResponseCode(); + int httpResponseCode = httpsURLConnection.getResponseCode(); - if (httpResponseCode == 200) { - return readCreateResponse(httpsURLConnection); - } - // Usually the FIS server recovers from errors: retry one time before giving up. - if (httpResponseCode >= 500 && httpResponseCode < 600) { - retryCount++; - continue; - } + if (httpResponseCode == 200) { + return readCreateResponse(httpsURLConnection); + } - // Unrecoverable server response or unknown error - throw new FirebaseException(readErrorResponse(httpsURLConnection)); + if (httpResponseCode == 429 || (httpResponseCode >= 500 && httpResponseCode < 600)) { + retryCount++; + continue; } - // Return empty installation response with SERVER_ERROR response code after max retries - return InstallationResponse.builder().setResponseCode(ResponseCode.SERVER_ERROR).build(); - } catch (IOException e) { - throw new FirebaseException(String.format(NETWORK_ERROR_MESSAGE, e.getMessage())); + + // Return empty installation response with BAD_CONFIG response code after max retries + return InstallationResponse.builder().setResponseCode(ResponseCode.BAD_CONFIG).build(); } + + throw new IOException(); } private static JSONObject buildCreateFirebaseInstallationRequestBody(String fid, String appId) @@ -161,6 +163,7 @@ private static JSONObject buildCreateFirebaseInstallationRequestBody(String fid, firebaseInstallationData.put("fid", fid); firebaseInstallationData.put("appId", appId); firebaseInstallationData.put("authVersion", FIREBASE_INSTALLATION_AUTH_VERSION); + firebaseInstallationData.put("sdkVersion", "t.1.1.0"); return firebaseInstallationData; } @@ -178,32 +181,39 @@ public void deleteFirebaseInstallation( @NonNull String fid, @NonNull String projectID, @NonNull String refreshToken) - throws FirebaseException { + throws FirebaseException, IOException { String resourceName = String.format(DELETE_REQUEST_RESOURCE_NAME_FORMAT, projectID, fid); - try { - URL url = - new URL( - String.format( - "https://%s/%s/%s?key=%s", - FIREBASE_INSTALLATIONS_API_DOMAIN, - FIREBASE_INSTALLATIONS_API_VERSION, - resourceName, - apiKey)); - + URL url = + new URL( + String.format( + "https://%s/%s/%s?key=%s", + FIREBASE_INSTALLATIONS_API_DOMAIN, + FIREBASE_INSTALLATIONS_API_VERSION, + resourceName, + apiKey)); + + int retryCount = 0; + while (retryCount <= MAX_RETRIES) { HttpsURLConnection httpsURLConnection = openHttpsURLConnection(url); httpsURLConnection.setRequestMethod("DELETE"); httpsURLConnection.addRequestProperty("Authorization", "FIS_v2 " + refreshToken); int httpResponseCode = httpsURLConnection.getResponseCode(); - switch (httpResponseCode) { - case 200: - return; - default: - throw new FirebaseException(readErrorResponse(httpsURLConnection)); + + if (httpResponseCode == 200 || httpResponseCode == 401 || httpResponseCode == 404) { + return; } - } catch (IOException e) { - throw new FirebaseException(String.format(NETWORK_ERROR_MESSAGE, e.getMessage())); + + if (httpResponseCode == 429 || (httpResponseCode >= 500 && httpResponseCode < 600)) { + retryCount++; + continue; + } + + throw new FirebaseInstallationsException( + "bad config while trying to delete FID", Status.BAD_CONFIG); } + + throw new IOException(); } /** @@ -214,6 +224,14 @@ public void deleteFirebaseInstallation( * @param fid Firebase Installation Identifier * @param projectID Project Id * @param refreshToken a token used to authenticate FIS requests + *
    + *
  • 400: return response with status BAD_CONFIG + *
  • 401: return response with status INVALID_AUTH + *
  • 403: return response with status BAD_CONFIG + *
  • 404: return response with status INVALID_AUTH + *
  • 429: throw IOException + *
  • 500: throw IOException + *
*/ @NonNull public TokenResult generateAuthToken( @@ -221,53 +239,41 @@ public TokenResult generateAuthToken( @NonNull String fid, @NonNull String projectID, @NonNull String refreshToken) - throws FirebaseException { + throws IOException { String resourceName = String.format(GENERATE_AUTH_TOKEN_REQUEST_RESOURCE_NAME_FORMAT, projectID, fid); - try { - int retryCount = 0; - URL url = - new URL( - String.format( - "https://%s/%s/%s?key=%s", - FIREBASE_INSTALLATIONS_API_DOMAIN, - FIREBASE_INSTALLATIONS_API_VERSION, - resourceName, - apiKey)); - while (retryCount <= MAX_RETRIES) { - HttpsURLConnection httpsURLConnection = openHttpsURLConnection(url); - httpsURLConnection.setRequestMethod("POST"); - httpsURLConnection.addRequestProperty("Authorization", "FIS_v2 " + refreshToken); - - int httpResponseCode = httpsURLConnection.getResponseCode(); - - if (httpResponseCode == 200) { - return readGenerateAuthTokenResponse(httpsURLConnection); - } + int retryCount = 0; + URL url = + new URL( + String.format( + "https://%s/%s/%s?key=%s", + FIREBASE_INSTALLATIONS_API_DOMAIN, + FIREBASE_INSTALLATIONS_API_VERSION, + resourceName, + apiKey)); + while (retryCount <= MAX_RETRIES) { + HttpsURLConnection httpsURLConnection = openHttpsURLConnection(url); + httpsURLConnection.setRequestMethod("POST"); + httpsURLConnection.addRequestProperty("Authorization", "FIS_v2 " + refreshToken); - if (httpResponseCode == 401) { - return TokenResult.builder() - .setResponseCode(TokenResult.ResponseCode.REFRESH_TOKEN_ERROR) - .build(); - } + int httpResponseCode = httpsURLConnection.getResponseCode(); - if (httpResponseCode == 404) { - return TokenResult.builder().setResponseCode(TokenResult.ResponseCode.FID_ERROR).build(); - } + if (httpResponseCode == 200) { + return readGenerateAuthTokenResponse(httpsURLConnection); + } - // Usually the FIS server recovers from errors: retry one time before giving up. - if (httpResponseCode >= 500 && httpResponseCode < 600) { - retryCount++; - continue; - } + if (httpResponseCode == 401 || httpResponseCode == 404) { + return TokenResult.builder().setResponseCode(TokenResult.ResponseCode.AUTH_ERROR).build(); + } - // Unrecoverable server response or unknown error - throw new FirebaseException(readErrorResponse(httpsURLConnection)); + if (httpResponseCode == 429 || (httpResponseCode >= 500 && httpResponseCode < 600)) { + retryCount++; + continue; } - throw new FirebaseException(INTERNAL_SERVER_ERROR_MESSAGE); - } catch (IOException e) { - throw new FirebaseException(String.format(NETWORK_ERROR_MESSAGE, e.getMessage())); + + return TokenResult.builder().setResponseCode(TokenResult.ResponseCode.BAD_CONFIG).build(); } + throw new IOException(); } private HttpsURLConnection openHttpsURLConnection(URL url) throws IOException { diff --git a/firebase-installations/src/main/java/com/google/firebase/installations/remote/InstallationResponse.java b/firebase-installations/src/main/java/com/google/firebase/installations/remote/InstallationResponse.java index 0c0463b6f49..213b4d19416 100644 --- a/firebase-installations/src/main/java/com/google/firebase/installations/remote/InstallationResponse.java +++ b/firebase-installations/src/main/java/com/google/firebase/installations/remote/InstallationResponse.java @@ -24,8 +24,9 @@ public abstract class InstallationResponse { public enum ResponseCode { // Returned on success OK, - // An error occurred on the server while processing this request(temporary) - SERVER_ERROR + // The request is invalid. Do not try again without fixing the request. Usually means + // a bad or misconfigured API Key or project is being used. + BAD_CONFIG, } @Nullable diff --git a/firebase-installations/src/main/java/com/google/firebase/installations/remote/TokenResult.java b/firebase-installations/src/main/java/com/google/firebase/installations/remote/TokenResult.java index a120e418ff0..1fd7a5d99fd 100644 --- a/firebase-installations/src/main/java/com/google/firebase/installations/remote/TokenResult.java +++ b/firebase-installations/src/main/java/com/google/firebase/installations/remote/TokenResult.java @@ -27,14 +27,10 @@ public enum ResponseCode { OK, // Auth token cannot be generated for this FID in the request. Because it is not // registered/found on the FIS server. Recreate a new fid to fetch a valid auth token. - FID_ERROR, + BAD_CONFIG, // Refresh token in this request in not accepted by the FIS server. Either it has been blocked // or changed. Recreate a new fid to fetch a valid auth token. - REFRESH_TOKEN_ERROR, - } - - public boolean isSuccessful() { - return getResponseCode() == ResponseCode.OK; + AUTH_ERROR, } /** A new FIS Auth-Token, created for this Firebase Installation. */