diff --git a/firebase-installations-interop/api.txt b/firebase-installations-interop/api.txt new file mode 100644 index 00000000000..99b8e8affa4 --- /dev/null +++ b/firebase-installations-interop/api.txt @@ -0,0 +1,22 @@ +// Signature format: 2.0 +package com.google.firebase.installations { + + public abstract class InstallationTokenResult { + ctor public InstallationTokenResult(); + method @NonNull public static com.google.firebase.installations.InstallationTokenResult.Builder builder(); + method @NonNull public abstract String getToken(); + method @NonNull public abstract long getTokenCreationTimestamp(); + method @NonNull public abstract long getTokenExpirationTimestamp(); + method @NonNull public abstract com.google.firebase.installations.InstallationTokenResult.Builder toBuilder(); + } + + public abstract static class InstallationTokenResult.Builder { + ctor public InstallationTokenResult.Builder(); + method @NonNull public abstract com.google.firebase.installations.InstallationTokenResult build(); + method @NonNull public abstract com.google.firebase.installations.InstallationTokenResult.Builder setToken(@NonNull String); + method @NonNull public abstract com.google.firebase.installations.InstallationTokenResult.Builder setTokenCreationTimestamp(long); + method @NonNull public abstract com.google.firebase.installations.InstallationTokenResult.Builder setTokenExpirationTimestamp(long); + } + +} + diff --git a/firebase-installations-interop/firebase-installations-interop.gradle b/firebase-installations-interop/firebase-installations-interop.gradle new file mode 100644 index 00000000000..5acad3e1434 --- /dev/null +++ b/firebase-installations-interop/firebase-installations-interop.gradle @@ -0,0 +1,46 @@ +// Copyright 2018 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. + + +plugins { + id 'firebase-library' +} + +firebaseLibrary.publishJavadoc = false + +android { + compileSdkVersion project.targetSdkVersion + defaultConfig { + minSdkVersion project.minSdkVersion + targetSdkVersion project.targetSdkVersion + versionName version + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + } + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } + testOptions { + unitTests { + includeAndroidResources = true + } + } +} + +dependencies { + implementation 'com.google.android.gms:play-services-tasks:17.0.0' + + compileOnly "com.google.auto.value:auto-value-annotations:1.6.5" + annotationProcessor "com.google.auto.value:auto-value:1.6.2" +} diff --git a/firebase-installations-interop/gradle.properties b/firebase-installations-interop/gradle.properties new file mode 100644 index 00000000000..752913a3eb5 --- /dev/null +++ b/firebase-installations-interop/gradle.properties @@ -0,0 +1 @@ +version=17.1.1 diff --git a/firebase-installations-interop/src/main/AndroidManifest.xml b/firebase-installations-interop/src/main/AndroidManifest.xml new file mode 100644 index 00000000000..7ae18eafe43 --- /dev/null +++ b/firebase-installations-interop/src/main/AndroidManifest.xml @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + diff --git a/firebase-installations-interop/src/main/java/com/google/firebase/installations/FirebaseInstallationsApi.java b/firebase-installations-interop/src/main/java/com/google/firebase/installations/FirebaseInstallationsApi.java new file mode 100644 index 00000000000..fb08cb808dc --- /dev/null +++ b/firebase-installations-interop/src/main/java/com/google/firebase/installations/FirebaseInstallationsApi.java @@ -0,0 +1,61 @@ +// 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 static java.lang.annotation.RetentionPolicy.SOURCE; + +import androidx.annotation.IntDef; +import com.google.android.gms.tasks.Task; +import java.lang.annotation.Retention; + +/** + * This is an interface of {@code FirebaseInstallations} that is only exposed to 2p via component + * injection. + * + * @hide + */ +public interface FirebaseInstallationsApi { + + /** Specifies the options to get a FIS AuthToken. */ + @IntDef({DO_NOT_FORCE_REFRESH, FORCE_REFRESH}) + @Retention(SOURCE) + @interface AuthTokenOption {} + /** + * AuthToken is not refreshed until requested by the developer or if one doesn't exist, is expired + * or about to expire. + */ + int DO_NOT_FORCE_REFRESH = 0; + /** + * AuthToken is forcefully refreshed on calling the {@link + * FirebaseInstallationsApi#getToken(int)}. + */ + int FORCE_REFRESH = 1; + + /** + * Async function that returns a globally unique identifier of this Firebase app installation. + * This is a url-safe base64 string of a 128-bit integer. + */ + Task getId(); + + /** Async function that returns a auth token(public key) of this Firebase app installation. */ + Task getToken(@AuthTokenOption int authTokenOption); + + /** + * Async function that deletes this Firebase app installation from Firebase backend. This call + * would possibly lead Firebase Notification, Firebase RemoteConfig, Firebase Predictions or + * Firebase In-App Messaging not function properly. + */ + Task delete(); +} diff --git a/firebase-installations-interop/src/main/java/com/google/firebase/installations/InstallationTokenResult.java b/firebase-installations-interop/src/main/java/com/google/firebase/installations/InstallationTokenResult.java new file mode 100644 index 00000000000..3dbc02c3e3e --- /dev/null +++ b/firebase-installations-interop/src/main/java/com/google/firebase/installations/InstallationTokenResult.java @@ -0,0 +1,62 @@ +// Copyright 2019 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.google.firebase.installations; + +import androidx.annotation.NonNull; +import com.google.auto.value.AutoValue; + +/** This class represents a set of values describing a FIS Auth Token Result. */ +@AutoValue +public abstract class InstallationTokenResult { + + /** A new FIS Auth-Token, created for this Firebase Installation. */ + @NonNull + public abstract String getToken(); + /** + * The amount of time, in seconds, before the auth-token expires for this Firebase Installation. + */ + @NonNull + public abstract long getTokenExpirationTimestamp(); + + /** + * The amount of time, in seconds, when the auth-token was created for this Firebase Installation. + */ + @NonNull + public abstract long getTokenCreationTimestamp(); + + @NonNull + public abstract Builder toBuilder(); + + /** Returns a default Builder object to create an InstallationResponse object */ + @NonNull + public static InstallationTokenResult.Builder builder() { + return new AutoValue_InstallationTokenResult.Builder(); + } + + @AutoValue.Builder + public abstract static class Builder { + @NonNull + public abstract Builder setToken(@NonNull String value); + + @NonNull + public abstract Builder setTokenExpirationTimestamp(long value); + + @NonNull + public abstract Builder setTokenCreationTimestamp(long value); + + @NonNull + public abstract InstallationTokenResult build(); + } +} diff --git a/firebase-installations/api.txt b/firebase-installations/api.txt new file mode 100644 index 00000000000..e2120cdf91f --- /dev/null +++ b/firebase-installations/api.txt @@ -0,0 +1,150 @@ +// Signature format: 2.0 +package com.google.firebase.installations { + + public class FirebaseInstallations implements com.google.firebase.installations.FirebaseInstallationsApi { + method @NonNull public com.google.android.gms.tasks.Task delete(); + method @NonNull public com.google.android.gms.tasks.Task getId(); + method @NonNull public static com.google.firebase.installations.FirebaseInstallations getInstance(); + method @NonNull public static com.google.firebase.installations.FirebaseInstallations getInstance(@NonNull com.google.firebase.FirebaseApp); + method @NonNull public com.google.android.gms.tasks.Task getToken(@com.google.firebase.installations.FirebaseInstallationsApi.AuthTokenOption int); + } + + public class FirebaseInstallationsException extends com.google.firebase.FirebaseException { + ctor public FirebaseInstallationsException(@NonNull com.google.firebase.installations.FirebaseInstallationsException.Status); + ctor public FirebaseInstallationsException(@NonNull String, @NonNull com.google.firebase.installations.FirebaseInstallationsException.Status); + ctor public FirebaseInstallationsException(@NonNull String, @NonNull com.google.firebase.installations.FirebaseInstallationsException.Status, @NonNull Throwable); + method @NonNull public com.google.firebase.installations.FirebaseInstallationsException.Status getStatus(); + } + + public enum FirebaseInstallationsException.Status { + enum_constant public static final com.google.firebase.installations.FirebaseInstallationsException.Status BAD_CONFIG; + } + + public class RandomFidGenerator { + ctor public RandomFidGenerator(); + method @NonNull public String createRandomFid(); + } + +} + +package com.google.firebase.installations.local { + + public class IidStore { + ctor public IidStore(); + method @Nullable public String readIid(); + } + + public class PersistedInstallation { + ctor public PersistedInstallation(@NonNull com.google.firebase.FirebaseApp); + 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; + enum_constant public static final com.google.firebase.installations.local.PersistedInstallation.RegistrationStatus UNREGISTERED; + } + + public abstract class PersistedInstallationEntry { + ctor public PersistedInstallationEntry(); + method @NonNull public static com.google.firebase.installations.local.PersistedInstallationEntry.Builder builder(); + method @Nullable public abstract String getAuthToken(); + method public abstract long getExpiresInSecs(); + method @Nullable public abstract String getFirebaseInstallationId(); + method @Nullable public abstract String getFisError(); + method @Nullable public abstract String getRefreshToken(); + method @NonNull public abstract com.google.firebase.installations.local.PersistedInstallation.RegistrationStatus getRegistrationStatus(); + method public abstract long getTokenCreationEpochInSecs(); + method public boolean isErrored(); + 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 { + ctor public PersistedInstallationEntry.Builder(); + method @NonNull public abstract com.google.firebase.installations.local.PersistedInstallationEntry build(); + method @NonNull public abstract com.google.firebase.installations.local.PersistedInstallationEntry.Builder setAuthToken(@Nullable String); + method @NonNull public abstract com.google.firebase.installations.local.PersistedInstallationEntry.Builder setExpiresInSecs(long); + method @NonNull public abstract com.google.firebase.installations.local.PersistedInstallationEntry.Builder setFirebaseInstallationId(@NonNull String); + method @NonNull public abstract com.google.firebase.installations.local.PersistedInstallationEntry.Builder setFisError(@Nullable String); + method @NonNull public abstract com.google.firebase.installations.local.PersistedInstallationEntry.Builder setRefreshToken(@Nullable String); + method @NonNull public abstract com.google.firebase.installations.local.PersistedInstallationEntry.Builder setRegistrationStatus(@NonNull com.google.firebase.installations.local.PersistedInstallation.RegistrationStatus); + method @NonNull public abstract com.google.firebase.installations.local.PersistedInstallationEntry.Builder setTokenCreationEpochInSecs(long); + } + +} + +package com.google.firebase.installations.remote { + + public class FirebaseInstallationServiceClient { + ctor public FirebaseInstallationServiceClient(@NonNull android.content.Context, @Nullable com.google.firebase.platforminfo.UserAgentPublisher, @Nullable com.google.firebase.heartbeatinfo.HeartBeatInfo); + method @NonNull public com.google.firebase.installations.remote.InstallationResponse createFirebaseInstallation(@NonNull String, @NonNull String, @NonNull String, @NonNull String) throws java.io.IOException; + method @NonNull public void deleteFirebaseInstallation(@NonNull String, @NonNull String, @NonNull String, @NonNull String) throws com.google.firebase.FirebaseException, java.io.IOException; + method @NonNull public com.google.firebase.installations.remote.TokenResult generateAuthToken(@NonNull String, @NonNull String, @NonNull String, @NonNull String) throws java.io.IOException; + } + + public abstract class InstallationResponse { + ctor public InstallationResponse(); + method @NonNull public static com.google.firebase.installations.remote.InstallationResponse.Builder builder(); + method @Nullable public abstract com.google.firebase.installations.remote.TokenResult getAuthToken(); + method @Nullable public abstract String getFid(); + method @Nullable public abstract String getRefreshToken(); + method @Nullable public abstract com.google.firebase.installations.remote.InstallationResponse.ResponseCode getResponseCode(); + method @Nullable public abstract String getUri(); + method @NonNull public abstract com.google.firebase.installations.remote.InstallationResponse.Builder toBuilder(); + } + + public abstract static class InstallationResponse.Builder { + ctor public InstallationResponse.Builder(); + method @NonNull public abstract com.google.firebase.installations.remote.InstallationResponse build(); + method @NonNull public abstract com.google.firebase.installations.remote.InstallationResponse.Builder setAuthToken(@NonNull com.google.firebase.installations.remote.TokenResult); + method @NonNull public abstract com.google.firebase.installations.remote.InstallationResponse.Builder setFid(@NonNull String); + method @NonNull public abstract com.google.firebase.installations.remote.InstallationResponse.Builder setRefreshToken(@NonNull String); + method @NonNull public abstract com.google.firebase.installations.remote.InstallationResponse.Builder setResponseCode(@NonNull com.google.firebase.installations.remote.InstallationResponse.ResponseCode); + method @NonNull public abstract com.google.firebase.installations.remote.InstallationResponse.Builder setUri(@NonNull String); + } + + 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; + } + + public abstract class TokenResult { + ctor public TokenResult(); + method @NonNull public static com.google.firebase.installations.remote.TokenResult.Builder builder(); + 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 @NonNull public abstract com.google.firebase.installations.remote.TokenResult.Builder toBuilder(); + } + + public abstract static class TokenResult.Builder { + ctor public TokenResult.Builder(); + method @NonNull public abstract com.google.firebase.installations.remote.TokenResult build(); + method @NonNull public abstract com.google.firebase.installations.remote.TokenResult.Builder setResponseCode(@NonNull com.google.firebase.installations.remote.TokenResult.ResponseCode); + method @NonNull public abstract com.google.firebase.installations.remote.TokenResult.Builder setToken(@NonNull String); + method @NonNull public abstract com.google.firebase.installations.remote.TokenResult.Builder setTokenExpirationTimestamp(long); + } + + public enum TokenResult.ResponseCode { + 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; + } + +} + diff --git a/firebase-installations/firebase-installations.gradle b/firebase-installations/firebase-installations.gradle new file mode 100644 index 00000000000..0ef070e8b6d --- /dev/null +++ b/firebase-installations/firebase-installations.gradle @@ -0,0 +1,65 @@ +// Copyright 2018 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. + + +plugins { + id 'firebase-library' +} + +android { + compileSdkVersion project.targetSdkVersion + defaultConfig { + minSdkVersion project.minSdkVersion + targetSdkVersion project.targetSdkVersion + multiDexEnabled true + versionName version + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + } + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } + testOptions { + unitTests { + includeAndroidResources = true + } + } +} + +dependencies { + implementation project(':firebase-common') + implementation project(':firebase-installations-interop') + + implementation 'androidx.appcompat:appcompat:1.0.2' + implementation 'androidx.multidex:multidex:2.0.1' + implementation 'com.google.android.gms:play-services-tasks:17.0.0' + + + compileOnly "com.google.auto.value:auto-value-annotations:1.6.5" + annotationProcessor "com.google.auto.value:auto-value:1.6.2" + + testImplementation 'androidx.test:core:1.2.0' + testImplementation 'junit:junit:4.12' + testImplementation "org.robolectric:robolectric:$robolectricVersion" + testImplementation "com.google.truth:truth:$googleTruthVersion" + + + androidTestImplementation 'androidx.test.ext:junit:1.1.1' + androidTestImplementation 'androidx.test:runner:1.2.0' + androidTestImplementation "com.google.truth:truth:$googleTruthVersion" + androidTestImplementation 'junit:junit:4.12' + androidTestImplementation "androidx.annotation:annotation:1.0.0" + androidTestImplementation 'org.mockito:mockito-core:2.25.0' + androidTestImplementation 'org.mockito:mockito-android:2.25.0' +} diff --git a/firebase-installations/gradle.properties b/firebase-installations/gradle.properties new file mode 100644 index 00000000000..752913a3eb5 --- /dev/null +++ b/firebase-installations/gradle.properties @@ -0,0 +1 @@ +version=17.1.1 diff --git a/firebase-installations/lint.xml b/firebase-installations/lint.xml new file mode 100644 index 00000000000..9b9bd90b534 --- /dev/null +++ b/firebase-installations/lint.xml @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/firebase-installations/src/androidTest/AndroidManifest.xml b/firebase-installations/src/androidTest/AndroidManifest.xml new file mode 100644 index 00000000000..f9fb55a7b56 --- /dev/null +++ b/firebase-installations/src/androidTest/AndroidManifest.xml @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + 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 new file mode 100644 index 00000000000..4fec8c4cce5 --- /dev/null +++ b/firebase-installations/src/androidTest/java/com/google/firebase/installations/FirebaseInstallationsInstrumentedTest.java @@ -0,0 +1,849 @@ +// 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 static com.google.common.truth.Truth.assertWithMessage; +import static com.google.firebase.installations.FisAndroidTestConstants.TEST_API_KEY; +import static com.google.firebase.installations.FisAndroidTestConstants.TEST_APP_ID_1; +import static com.google.firebase.installations.FisAndroidTestConstants.TEST_AUTH_TOKEN; +import static com.google.firebase.installations.FisAndroidTestConstants.TEST_AUTH_TOKEN_2; +import static com.google.firebase.installations.FisAndroidTestConstants.TEST_AUTH_TOKEN_3; +import static com.google.firebase.installations.FisAndroidTestConstants.TEST_AUTH_TOKEN_4; +import static com.google.firebase.installations.FisAndroidTestConstants.TEST_CREATION_TIMESTAMP_2; +import static com.google.firebase.installations.FisAndroidTestConstants.TEST_FID_1; +import static com.google.firebase.installations.FisAndroidTestConstants.TEST_INSTALLATION_RESPONSE; +import static com.google.firebase.installations.FisAndroidTestConstants.TEST_INSTALLATION_RESPONSE_WITH_IID; +import static com.google.firebase.installations.FisAndroidTestConstants.TEST_INSTANCE_ID_1; +import static com.google.firebase.installations.FisAndroidTestConstants.TEST_PROJECT_ID; +import static com.google.firebase.installations.FisAndroidTestConstants.TEST_REFRESH_TOKEN; +import static com.google.firebase.installations.FisAndroidTestConstants.TEST_TOKEN_EXPIRATION_TIMESTAMP; +import static com.google.firebase.installations.FisAndroidTestConstants.TEST_TOKEN_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.anyString; +import static org.mockito.ArgumentMatchers.matches; +import static org.mockito.Mockito.doAnswer; +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; +import androidx.test.runner.AndroidJUnit4; +import com.google.android.gms.tasks.Task; +import com.google.android.gms.tasks.Tasks; +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; +import java.util.concurrent.ThreadPoolExecutor; +import java.util.concurrent.TimeUnit; +import org.junit.After; +import org.junit.Before; +import org.junit.FixMethodOrder; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.MethodSorters; +import org.mockito.AdditionalAnswers; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; + +/** + * Instrumented test, which will execute on an Android device. + * + * @see Testing documentation + */ +@RunWith(AndroidJUnit4.class) +@FixMethodOrder(MethodSorters.NAME_ASCENDING) +public class FirebaseInstallationsInstrumentedTest { + private FirebaseApp firebaseApp; + private ExecutorService executor; + private PersistedInstallation persistedInstallation; + @Mock private FirebaseInstallationServiceClient mockBackend; + @Mock private IidStore mockIidStore; + @Mock private RandomFidGenerator mockFidGenerator; + + private static final PersistedInstallationEntry REGISTERED_INSTALLATION_ENTRY = + PersistedInstallationEntry.builder() + .setFirebaseInstallationId(TEST_FID_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 FirebaseInstallations firebaseInstallations; + private Utils utils; + private FakeCalendar fakeCalendar; + + @Before + 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(), + new FirebaseOptions.Builder() + .setApplicationId(TEST_APP_ID_1) + .setProjectId(TEST_PROJECT_ID) + .setApiKey(TEST_API_KEY) + .build()); + persistedInstallation = new PersistedInstallation(firebaseApp); + persistedInstallation.clearForTesting(); + + utils = new Utils(fakeCalendar); + firebaseInstallations = + new FirebaseInstallations( + executor, + firebaseApp, + mockBackend, + persistedInstallation, + utils, + mockIidStore, + mockFidGenerator); + + when(mockFidGenerator.createRandomFid()).thenReturn(TEST_FID_1); + } + + @After + public void cleanUp() { + persistedInstallation.clearForTesting(); + try { + executor.awaitTermination(250, TimeUnit.MILLISECONDS); + } catch (InterruptedException e) { + + } + } + + /** + * Check the id generation process when there is no network. There are three cases: + * + *
    + *
  • no iid -> generate a new fid + *
  • iid present -> make that iid into a fid + *
  • fid generated -> return that fid + *
+ */ + @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); + } + + @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); + } + + @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_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())) + .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(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); + assertThat(updatedInstallationEntry).hasRegistrationStatus(RegistrationStatus.REGISTERED); + } + + @Test + public void testGetId_migrateIid_successful() throws Exception { + when(mockIidStore.readIid()).thenReturn(TEST_INSTANCE_ID_1); + when(mockBackend.createFirebaseInstallation(anyString(), anyString(), anyString(), anyString())) + .thenReturn(TEST_INSTALLATION_RESPONSE_WITH_IID); + + // 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())) + .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 si registered. + PersistedInstallationEntry updatedInstallationEntry = + persistedInstallation.readPersistedInstallationEntryValue(); + assertThat(updatedInstallationEntry).hasFid(TEST_INSTANCE_ID_1); + assertThat(updatedInstallationEntry).hasRegistrationStatus(RegistrationStatus.REGISTERED); + } + + @Test + public void testGetId_multipleCalls_sameFIDReturned() throws Exception { + when(mockIidStore.readIid()).thenReturn(null); + when(mockBackend.createFirebaseInstallation(anyString(), anyString(), anyString(), anyString())) + .thenReturn(TEST_INSTALLATION_RESPONSE); + + // Call getId multiple times + Task task1 = firebaseInstallations.getId(); + Task task2 = firebaseInstallations.getId(); + Tasks.await(Tasks.whenAllComplete(task1, task2)); + // Waiting for Task that registers FID on the FIS Servers + executor.awaitTermination(500, TimeUnit.MILLISECONDS); + + assertWithMessage("Persisted Fid of Task1 doesn't match.") + .that(task1.getResult()) + .isEqualTo(TEST_FID_1); + assertWithMessage("Persisted Fid of Task2 doesn't match.") + .that(task2.getResult()) + .isEqualTo(TEST_FID_1); + verify(mockBackend, times(1)) + .createFirebaseInstallation(TEST_API_KEY, TEST_FID_1, TEST_PROJECT_ID, TEST_APP_ID_1); + PersistedInstallationEntry updatedInstallationEntry = + persistedInstallation.readPersistedInstallationEntryValue(); + assertThat(updatedInstallationEntry).hasFid(TEST_FID_1); + 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_unregistered_replacesFidWithResponse() throws Exception { + // Update local storage with installation entry that has invalid fid. + persistedInstallation.insertOrUpdatePersistedInstallationEntry( + PersistedInstallationEntry.INSTANCE.withUnregisteredFid("tobereplaced")); + when(mockBackend.createFirebaseInstallation(anyString(), anyString(), anyString(), anyString())) + .thenReturn(TEST_INSTALLATION_RESPONSE); + + // The first call will return the existing FID, "tobereplaced" + assertWithMessage("getId Task failed.") + .that(Tasks.await(firebaseInstallations.getId())) + .isEqualTo("tobereplaced"); + + // Waiting for Task that registers FID on the FIS Servers + executor.awaitTermination(500, TimeUnit.MILLISECONDS); + + // 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 { + // start with an unregistered fid + persistedInstallation.insertOrUpdatePersistedInstallationEntry( + PersistedInstallationEntry.INSTANCE.withUnregisteredFid(TEST_FID_1)); + + // 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()); + + // 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. + 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.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 { + // set initial state to having an unregistered FID + persistedInstallation.insertOrUpdatePersistedInstallationEntry( + PersistedInstallationEntry.INSTANCE.withUnregisteredFid(TEST_FID_1)); + + // Mocking unchecked exception on FIS createFirebaseInstallation + when(mockBackend.createFirebaseInstallation(anyString(), anyString(), anyString(), anyString())) + .thenThrow(new IOException()); + + 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); + + // 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.UNREGISTERED); + } + + @Test + public void testGetId_expiredAuthTokenUncheckedException_statusUpdated() throws Exception { + // 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(mockBackend.generateAuthToken(anyString(), anyString(), anyString(), anyString())) + .thenThrow(new IOException()); + + assertWithMessage("getId Task failed") + .that(Tasks.await(firebaseInstallations.getId())) + .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 still REGISTER + PersistedInstallationEntry updatedInstallationEntry = + persistedInstallation.readPersistedInstallationEntryValue(); + assertThat(updatedInstallationEntry).hasFid(TEST_FID_1); + 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 { + // 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); + + // 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())) + .isEqualTo(TEST_FID_1); + + // Waiting for Task that registers FID on the FIS Servers + executor.awaitTermination(500, TimeUnit.MILLISECONDS); + + // 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(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(mockBackend.createFirebaseInstallation(anyString(), anyString(), anyString(), anyString())) + .thenReturn(TEST_INSTALLATION_RESPONSE); + Tasks.await(firebaseInstallations.getToken(FirebaseInstallationsApi.DO_NOT_FORCE_REFRESH)); + + PersistedInstallationEntry entryValue = + persistedInstallation.readPersistedInstallationEntryValue(); + assertThat(entryValue).hasAuthToken(TEST_AUTH_TOKEN); + } + + @Test + public void testGetAuthToken_fidExists_successful() throws Exception { + 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)); + + assertWithMessage("Persisted Auth Token doesn't match") + .that(installationTokenResult.getToken()) + .isEqualTo(TEST_AUTH_TOKEN); + verify(mockBackend, never()) + .generateAuthToken(TEST_API_KEY, TEST_FID_1, TEST_PROJECT_ID, TEST_REFRESH_TOKEN); + } + + @Test + 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); + + // 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)); + + assertWithMessage("Persisted Auth Token doesn't match") + .that(installationTokenResult.getToken()) + .isEqualTo(TEST_AUTH_TOKEN_2); + } + + @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( + 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)); + + assertWithMessage("Persisted Auth Token doesn't match") + .that(installationTokenResult.getToken()) + .isEqualTo(TEST_AUTH_TOKEN); + } + + @Test + 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(mockBackend.generateAuthToken(anyString(), anyString(), anyString(), anyString())) + .thenReturn( + TokenResult.builder().setResponseCode(TokenResult.ResponseCode.AUTH_ERROR).build()); + + // Expect exception + try { + Tasks.await(firebaseInstallations.getToken(FirebaseInstallationsApi.FORCE_REFRESH)); + fail("the getAuthToken() call should have failed due to Auth Error."); + } catch (ExecutionException expected) { + assertWithMessage("Exception class doesn't match") + .that(expected) + .hasCauseThat() + .isInstanceOf(IOException.class); + } + + assertTrue(persistedInstallation.readPersistedInstallationEntryValue().isNotGenerated()); + } + + // /** + // * Check that a call to generateAuthToken(FORCE_REFRESH) fails if the backend client call + // * fails. + // */ + @Test + 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()); + + // Make the forced getAuthToken call, which should fail. + try { + Tasks.await(firebaseInstallations.getToken(FirebaseInstallationsApi.FORCE_REFRESH)); + 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) + .hasCauseThat() + .isInstanceOf(FirebaseInstallationsException.class); + assertWithMessage("Exception status doesn't match") + .that(((FirebaseInstallationsException) expected.getCause()).getStatus()) + .isEqualTo(Status.BAD_CONFIG); + } + } + + @Test + 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); + + // 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 = + firebaseInstallations.getToken(FirebaseInstallationsApi.DO_NOT_FORCE_REFRESH); + Task task2 = + firebaseInstallations.getToken(FirebaseInstallationsApi.DO_NOT_FORCE_REFRESH); + + Tasks.await(Tasks.whenAllComplete(task1, task2)); + + assertWithMessage("Persisted Auth Token doesn't match") + .that(task1.getResult().getToken()) + .isEqualTo(TEST_AUTH_TOKEN_2); + assertWithMessage("Persisted Auth Token doesn't match") + .that(task2.getResult().getToken()) + .isEqualTo(TEST_AUTH_TOKEN_2); + verify(mockBackend, times(1)) + .generateAuthToken(TEST_API_KEY, TEST_FID_1, TEST_PROJECT_ID, TEST_REFRESH_TOKEN); + } + + @Test + 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. + + doAnswer( + AdditionalAnswers.answersWithDelay( + 500, + (unused) -> + TokenResult.builder() + .setToken(TEST_AUTH_TOKEN_3) + .setTokenExpirationTimestamp(TEST_TOKEN_EXPIRATION_TIMESTAMP) + .setResponseCode(TokenResult.ResponseCode.OK) + .build())) + .doAnswer( + AdditionalAnswers.answersWithDelay( + 500, + (unused) -> + TokenResult.builder() + .setToken(TEST_AUTH_TOKEN_4) + .setTokenExpirationTimestamp(TEST_TOKEN_EXPIRATION_TIMESTAMP) + .setResponseCode(TokenResult.ResponseCode.OK) + .build())) + .when(mockBackend) + .generateAuthToken(anyString(), anyString(), anyString(), anyString()); + + // Call getToken multiple times with FORCE_REFRESH option. + Task task1 = + firebaseInstallations.getToken(FirebaseInstallationsApi.FORCE_REFRESH); + Task task2 = + firebaseInstallations.getToken(FirebaseInstallationsApi.FORCE_REFRESH); + Tasks.await(Tasks.whenAllComplete(task1, task2)); + + // As we cannot ensure which task got executed first, verifying with both expected values + assertWithMessage("Persisted Auth Token doesn't match") + .that(task1.getResult().getToken()) + .isEqualTo(TEST_AUTH_TOKEN_3); + assertWithMessage("Persisted Auth Token doesn't match") + .that(task2.getResult().getToken()) + .isEqualTo(TEST_AUTH_TOKEN_3); + verify(mockBackend, times(1)) + .generateAuthToken(TEST_API_KEY, TEST_FID_1, TEST_PROJECT_ID, TEST_REFRESH_TOKEN); + PersistedInstallationEntry updatedInstallationEntry = + persistedInstallation.readPersistedInstallationEntryValue(); + assertThat(updatedInstallationEntry).hasAuthToken(TEST_AUTH_TOKEN_3); + } + + @Test + public void testDelete_registeredFID_successful() throws Exception { + // Update local storage with a registered installation entry + persistedInstallation.insertOrUpdatePersistedInstallationEntry(REGISTERED_INSTALLATION_ENTRY); + when(mockBackend.createFirebaseInstallation(anyString(), anyString(), anyString(), anyString())) + .thenReturn(TEST_INSTALLATION_RESPONSE); + + Tasks.await(firebaseInstallations.delete()); + + PersistedInstallationEntry entryValue = + persistedInstallation.readPersistedInstallationEntryValue(); + 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( + PersistedInstallationEntry.INSTANCE.withUnregisteredFid(TEST_FID_1)); + + Tasks.await(firebaseInstallations.delete()); + + PersistedInstallationEntry entryValue = + persistedInstallation.readPersistedInstallationEntryValue(); + assertEquals(entryValue.getRegistrationStatus(), RegistrationStatus.NOT_GENERATED); + verify(mockBackend, never()) + .deleteFirebaseInstallation(anyString(), anyString(), anyString(), anyString()); + } + + @Test + public void testDelete_emptyPersistedFidEntry_successful() throws Exception { + persistedInstallation.insertOrUpdatePersistedInstallationEntry( + PersistedInstallationEntry.INSTANCE.withNoGeneratedFid()); + + Tasks.await(firebaseInstallations.delete()); + + PersistedInstallationEntry entryValue = + persistedInstallation.readPersistedInstallationEntryValue(); + assertThat(entryValue).hasRegistrationStatus(RegistrationStatus.NOT_GENERATED); + verify(mockBackend, never()) + .deleteFirebaseInstallation(anyString(), anyString(), anyString(), anyString()); + } + + @Test + public void testDelete_serverError_badConfig() throws Exception { + // Update local storage with a registered installation entry + persistedInstallation.insertOrUpdatePersistedInstallationEntry(REGISTERED_INSTALLATION_ENTRY); + + doThrow(new FirebaseException("Server Error")) + .when(mockBackend) + .deleteFirebaseInstallation(anyString(), anyString(), anyString(), anyString()); + + // Expect exception + try { + Tasks.await(firebaseInstallations.delete()); + fail("firebaseInstallations.delete() failed due to Server Error."); + } catch (ExecutionException expected) { + assertWithMessage("Exception class doesn't match") + .that(expected) + .hasCauseThat() + .isInstanceOf(FirebaseInstallationsException.class); + assertWithMessage("Exception status doesn't match") + .that(((FirebaseInstallationsException) expected.getCause()).getStatus()) + .isEqualTo(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 new file mode 100644 index 00000000000..769ea8ec5a9 --- /dev/null +++ b/firebase-installations/src/androidTest/java/com/google/firebase/installations/FisAndroidTestConstants.java @@ -0,0 +1,80 @@ +// 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 com.google.firebase.installations.local.PersistedInstallationEntry; +import com.google.firebase.installations.remote.InstallationResponse; +import com.google.firebase.installations.remote.InstallationResponse.ResponseCode; +import com.google.firebase.installations.remote.TokenResult; + +public final class FisAndroidTestConstants { + public static final String TEST_FID_1 = "cccccccccccccccccccccc"; + + public static final String TEST_PROJECT_ID = "777777777777"; + + public static final String TEST_AUTH_TOKEN = "fis.auth.token"; + public static final String TEST_AUTH_TOKEN_2 = "fis.auth.token2"; + public static final String TEST_AUTH_TOKEN_3 = "fis.auth.token3"; + public static final String TEST_AUTH_TOKEN_4 = "fis.auth.token4"; + + public static final String TEST_API_KEY = "apiKey"; + + public static final String TEST_REFRESH_TOKEN = "1:test-refresh-token"; + + 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 = 4000L; + + public static final long TEST_CREATION_TIMESTAMP_1 = 2000L; + public static final long TEST_CREATION_TIMESTAMP_2 = 2L; + + public static final String TEST_INSTANCE_ID_1 = "ccccccccccc"; + + public static final PersistedInstallationEntry DEFAULT_PERSISTED_INSTALLATION_ENTRY = + PersistedInstallationEntry.builder().build(); + public static final InstallationResponse TEST_INSTALLATION_RESPONSE = + InstallationResponse.builder() + .setUri("/projects/" + TEST_PROJECT_ID + "/installations/" + TEST_FID_1) + .setFid(TEST_FID_1) + .setRefreshToken(TEST_REFRESH_TOKEN) + .setAuthToken( + TokenResult.builder() + .setToken(TEST_AUTH_TOKEN) + .setTokenExpirationTimestamp(TEST_TOKEN_EXPIRATION_TIMESTAMP) + .build()) + .setResponseCode(ResponseCode.OK) + .build(); + + public static final InstallationResponse TEST_INSTALLATION_RESPONSE_WITH_IID = + InstallationResponse.builder() + .setUri("/projects/" + TEST_PROJECT_ID + "/installations/" + TEST_INSTANCE_ID_1) + .setFid(TEST_INSTANCE_ID_1) + .setRefreshToken(TEST_REFRESH_TOKEN) + .setAuthToken( + TokenResult.builder() + .setToken(TEST_AUTH_TOKEN) + .setTokenExpirationTimestamp(TEST_TOKEN_EXPIRATION_TIMESTAMP) + .build()) + .setResponseCode(ResponseCode.OK) + .build(); + + public static final TokenResult TEST_TOKEN_RESULT = + TokenResult.builder() + .setToken(TEST_AUTH_TOKEN_2) + .setTokenExpirationTimestamp(TEST_TOKEN_EXPIRATION_TIMESTAMP) + .setResponseCode(TokenResult.ResponseCode.OK) + .build(); +} diff --git a/firebase-installations/src/androidTest/java/com/google/firebase/installations/local/PersistedInstallationEntrySubject.java b/firebase-installations/src/androidTest/java/com/google/firebase/installations/local/PersistedInstallationEntrySubject.java new file mode 100644 index 00000000000..702b4684d2a --- /dev/null +++ b/firebase-installations/src/androidTest/java/com/google/firebase/installations/local/PersistedInstallationEntrySubject.java @@ -0,0 +1,88 @@ +// 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.local; + +import static com.google.common.truth.Truth.assertAbout; + +import com.google.common.truth.FailureMetadata; +import com.google.common.truth.Subject; +import com.google.firebase.installations.local.PersistedInstallation.RegistrationStatus; +import org.checkerframework.checker.nullness.compatqual.NullableDecl; + +public final class PersistedInstallationEntrySubject extends Subject { + + // User-defined entry point + public static PersistedInstallationEntrySubject assertThat( + @NullableDecl PersistedInstallationEntry persistedInstallationEntry) { + return assertAbout(PERSISTED_INSTALLATION_ENTRY_SUBJECT_FACTORY) + .that(persistedInstallationEntry); + } + + // Static method for getting the subject factory (for use with assertAbout()) + public static Subject.Factory + persistedInstallationEntry() { + return PERSISTED_INSTALLATION_ENTRY_SUBJECT_FACTORY; + } + + // Boiler-plate Subject.Factory for PersistedInstallationEntrySubject + private static final Subject.Factory< + PersistedInstallationEntrySubject, PersistedInstallationEntry> + PERSISTED_INSTALLATION_ENTRY_SUBJECT_FACTORY = PersistedInstallationEntrySubject::new; + + private final PersistedInstallationEntry actual; + + /** + * Constructor for use by subclasses. If you want to create an instance of this class itself, call + * {@link Subject#check(String, PersistedInstallationEntry ..) check(...)}{@code .that(actual)}. + * + * @param metadata + * @param actual + */ + protected PersistedInstallationEntrySubject( + FailureMetadata metadata, @NullableDecl PersistedInstallationEntry actual) { + super(metadata, actual); + this.actual = actual; + } + + // User-defined test assertion + + public void hasFid(String fid) { + check("getFirebaseInstallationId()").that(actual.getFirebaseInstallationId()).isEqualTo(fid); + } + + public void hasAuthToken(String authToken) { + check("getToken()").that(actual.getAuthToken()).isEqualTo(authToken); + } + + public void hasRefreshToken(String refreshToken) { + check("getRefreshToken()").that(actual.getRefreshToken()).isEqualTo(refreshToken); + } + + public void hasCreationTimestamp(long creationTimestamp) { + check("getTokenCreationEpochInSecs()") + .that(actual.getTokenCreationEpochInSecs()) + .isEqualTo(creationTimestamp); + } + + public void hasTokenExpirationTimestamp(long tokenExpirationTimestamp) { + check("getExpiresInSecs()").that(actual.getExpiresInSecs()).isEqualTo(tokenExpirationTimestamp); + } + + public void hasRegistrationStatus(RegistrationStatus registrationStatus) { + check("getRegistrationStatus()") + .that(actual.getRegistrationStatus()) + .isEqualTo(registrationStatus); + } +} 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 new file mode 100644 index 00000000000..495dcd38991 --- /dev/null +++ b/firebase-installations/src/androidTest/java/com/google/firebase/installations/local/PersistedInstallationTest.java @@ -0,0 +1,120 @@ +// 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.local; + +import static com.google.firebase.installations.FisAndroidTestConstants.DEFAULT_PERSISTED_INSTALLATION_ENTRY; +import static com.google.firebase.installations.FisAndroidTestConstants.TEST_APP_ID_1; +import static com.google.firebase.installations.FisAndroidTestConstants.TEST_APP_ID_2; +import static com.google.firebase.installations.FisAndroidTestConstants.TEST_AUTH_TOKEN; +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_REFRESH_TOKEN; +import static com.google.firebase.installations.FisAndroidTestConstants.TEST_TOKEN_EXPIRATION_TIMESTAMP; +import static com.google.firebase.installations.local.PersistedInstallationEntrySubject.assertThat; + +import androidx.test.core.app.ApplicationProvider; +import androidx.test.runner.AndroidJUnit4; +import com.google.firebase.FirebaseApp; +import com.google.firebase.FirebaseOptions; +import com.google.firebase.installations.local.PersistedInstallation.RegistrationStatus; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; + +/** Instrumented tests for {@link PersistedInstallation} */ +@RunWith(AndroidJUnit4.class) +public class PersistedInstallationTest { + + private FirebaseApp firebaseApp0; + private FirebaseApp firebaseApp1; + private PersistedInstallation persistedInstallation0; + private PersistedInstallation persistedInstallation1; + + @Before + public void setUp() { + FirebaseApp.clearInstancesForTest(); + firebaseApp0 = + FirebaseApp.initializeApp( + ApplicationProvider.getApplicationContext(), + new FirebaseOptions.Builder().setApplicationId(TEST_APP_ID_1).build()); + firebaseApp1 = + FirebaseApp.initializeApp( + ApplicationProvider.getApplicationContext(), + new FirebaseOptions.Builder().setApplicationId(TEST_APP_ID_2).build(), + "firebase_app_1"); + persistedInstallation0 = new PersistedInstallation(firebaseApp0); + persistedInstallation1 = new PersistedInstallation(firebaseApp1); + } + + @After + public void cleanUp() { + persistedInstallation0.clearForTesting(); + persistedInstallation1.clearForTesting(); + } + + @Test + public void testReadPersistedInstallationEntry_Null() { + assertThat(persistedInstallation0.readPersistedInstallationEntryValue()) + .isEqualTo(DEFAULT_PERSISTED_INSTALLATION_ENTRY); + assertThat(persistedInstallation1.readPersistedInstallationEntryValue()) + .isEqualTo(DEFAULT_PERSISTED_INSTALLATION_ENTRY); + } + + @Test + public void testUpdateAndReadPersistedInstallationEntry_successful() throws Exception { + // 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(); + + // Validate insertion was successful + assertThat(entryValue).hasFid(TEST_FID_1); + assertThat(entryValue).hasAuthToken(TEST_AUTH_TOKEN); + assertThat(entryValue).hasRefreshToken(TEST_REFRESH_TOKEN); + assertThat(entryValue).hasRegistrationStatus(RegistrationStatus.UNREGISTERED); + assertThat(entryValue).hasTokenExpirationTimestamp(TEST_TOKEN_EXPIRATION_TIMESTAMP); + assertThat(entryValue).hasCreationTimestamp(TEST_CREATION_TIMESTAMP_1); + + // 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 + assertThat(entryValue).hasFid(TEST_FID_1); + assertThat(entryValue).hasAuthToken(TEST_AUTH_TOKEN); + assertThat(entryValue).hasRefreshToken(TEST_REFRESH_TOKEN); + assertThat(entryValue).hasRegistrationStatus(RegistrationStatus.REGISTERED); + assertThat(entryValue).hasTokenExpirationTimestamp(TEST_TOKEN_EXPIRATION_TIMESTAMP); + assertThat(entryValue).hasCreationTimestamp(TEST_CREATION_TIMESTAMP_2); + } +} diff --git a/firebase-installations/src/main/AndroidManifest.xml b/firebase-installations/src/main/AndroidManifest.xml new file mode 100644 index 00000000000..4b67e67a5a9 --- /dev/null +++ b/firebase-installations/src/main/AndroidManifest.xml @@ -0,0 +1,15 @@ + + + + + + + + + + diff --git a/firebase-installations/src/main/java/com/google/firebase/installations/AwaitListener.java b/firebase-installations/src/main/java/com/google/firebase/installations/AwaitListener.java new file mode 100644 index 00000000000..c7ecab4396a --- /dev/null +++ b/firebase-installations/src/main/java/com/google/firebase/installations/AwaitListener.java @@ -0,0 +1,38 @@ +// Copyright 2019 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.google.firebase.installations; + +import androidx.annotation.NonNull; +import com.google.android.gms.tasks.OnCompleteListener; +import com.google.android.gms.tasks.Task; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; + +final class AwaitListener implements OnCompleteListener { + private final CountDownLatch latch = new CountDownLatch(1); + + public void onSuccess() { + latch.countDown(); + } + + public boolean await(long timeout, TimeUnit unit) throws InterruptedException { + return latch.await(timeout, unit); + } + + @Override + public void onComplete(@NonNull Task task) { + latch.countDown(); + } +} diff --git a/firebase-installations/src/main/java/com/google/firebase/installations/FirebaseInstallations.java b/firebase-installations/src/main/java/com/google/firebase/installations/FirebaseInstallations.java new file mode 100644 index 00000000000..3dc316a7b8e --- /dev/null +++ b/firebase-installations/src/main/java/com/google/firebase/installations/FirebaseInstallations.java @@ -0,0 +1,456 @@ +// 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.GuardedBy; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.VisibleForTesting; +import com.google.android.gms.common.internal.Preconditions; +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.PersistedInstallationEntry; +import com.google.firebase.installations.remote.FirebaseInstallationServiceClient; +import com.google.firebase.installations.remote.InstallationResponse; +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; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.ThreadPoolExecutor; +import java.util.concurrent.TimeUnit; + +/** + * Entry point for Firebase Installations. + * + *

Firebase Installations does + * + *

    + *
  • provide unique identifier for a Firebase installation + *
  • provide auth token of a Firebase installation + *
  • provide a API to GDPR-delete a Firebase installation + *
+ */ +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 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, + @Nullable UserAgentPublisher publisher, + @Nullable HeartBeatInfo heartbeatInfo) { + this( + new ThreadPoolExecutor(0, 1, 30L, TimeUnit.SECONDS, new LinkedBlockingQueue<>()), + firebaseApp, + new FirebaseInstallationServiceClient( + firebaseApp.getApplicationContext(), publisher, heartbeatInfo), + new PersistedInstallation(firebaseApp), + new Utils(Calendar.getInstance()), + new IidStore(), + new RandomFidGenerator()); + } + + FirebaseInstallations( + ExecutorService executor, + FirebaseApp firebaseApp, + FirebaseInstallationServiceClient serviceClient, + PersistedInstallation persistedInstallation, + Utils utils, + 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; + } + + /** + * Returns the {@link FirebaseInstallationsApi} initialized with the default {@link FirebaseApp}. + * + * @return a {@link FirebaseInstallationsApi} instance + */ + @NonNull + public static FirebaseInstallations getInstance() { + FirebaseApp defaultFirebaseApp = FirebaseApp.getInstance(); + return getInstance(defaultFirebaseApp); + } + + /** + * Returns the {@link FirebaseInstallations} initialized with a custom {@link FirebaseApp}. + * + * @param app a custom {@link FirebaseApp} + * @return a {@link FirebaseInstallations} instance + */ + @NonNull + public static FirebaseInstallations getInstance(@NonNull FirebaseApp app) { + Preconditions.checkArgument(app != null, "Null is not a valid value of FirebaseApp."); + return (FirebaseInstallations) app.get(FirebaseInstallationsApi.class); + } + + /** Returns the application id of the {@link FirebaseApp} of this {@link FirebaseInstallations} */ + @VisibleForTesting + String getApplicationId() { + return firebaseApp.getOptions().getApplicationId(); + } + + /** Returns the nick name of the {@link FirebaseApp} of this {@link FirebaseInstallations} */ + @VisibleForTesting + String getName() { + return firebaseApp.getName(); + } + + /** + * Returns a globally unique identifier of this Firebase app installation. This is a url-safe + * base64 string of a 128-bit integer. + */ + @NonNull + @Override + public Task getId() { + Task task = addGetIdListener(); + executor.execute(this::doGetId); + return task; + } + + /** + * Returns a valid authentication token for the Firebase installation. Generates a new token if + * one doesn't exist, is expired or about to expire. + * + *

Should only be called if the Firebase Installation is registered. + * + * @param authTokenOption Options to get FIS Auth Token either by force refreshing or not. Accepts + * {@link AuthTokenOption} values. Default value of AuthTokenOption = DO_NOT_FORCE_REFRESH. + */ + @NonNull + @Override + public Task getToken(@AuthTokenOption int authTokenOption) { + Task task = addGetAuthTokenListener(); + if (authTokenOption == FORCE_REFRESH) { + executor.execute(this::doGetAuthTokenForceRefresh); + } else { + executor.execute(this::doGetAuthTokenWithoutForceRefresh); + } + return task; + } + + /** + * Call to delete this Firebase app installation from Firebase backend. This call would possibly + * lead Firebase Notification, Firebase RemoteConfig, Firebase Predictions or Firebase In-App + * Messaging not function properly. + */ + @NonNull + @Override + public Task delete() { + return Tasks.call(executor, this::deleteFirebaseInstallationId); + } + + private Task addGetIdListener() { + TaskCompletionSource taskCompletionSource = new TaskCompletionSource<>(); + StateListener l = new GetIdListener(taskCompletionSource); + synchronized (lock) { + listeners.add(l); + } + return taskCompletionSource.getTask(); + } + + private Task addGetAuthTokenListener() { + TaskCompletionSource taskCompletionSource = + new TaskCompletionSource<>(); + StateListener l = new GetAuthTokenListener(utils, taskCompletionSource); + synchronized (lock) { + listeners.add(l); + } + return taskCompletionSource.getTask(); + } + + private void triggerOnStateReached(PersistedInstallationEntry persistedInstallationEntry) { + synchronized (lock) { + Iterator it = listeners.iterator(); + while (it.hasNext()) { + StateListener l = it.next(); + boolean doneListening = l.onStateReached(persistedInstallationEntry); + if (doneListening) { + it.remove(); + } + } + } + } + + private void triggerOnException(PersistedInstallationEntry prefs, Exception exception) { + synchronized (lock) { + Iterator it = listeners.iterator(); + while (it.hasNext()) { + StateListener l = it.next(); + boolean doneListening = l.onException(prefs, exception); + if (doneListening) { + it.remove(); + } + } + } + } + + private final void doGetId() { + doRegistrationInternal(false); + } + + private final void doGetAuthTokenWithoutForceRefresh() { + doRegistrationInternal(false); + } + + private final void doGetAuthTokenForceRefresh() { + doRegistrationInternal(true); + } + + /** + * 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(); + + // 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(); + } + + 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; + } + + // 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); + } + } + + /** + * 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; + } + + } finally { + releaseCrossProcessLock(fileLock); + } + } + + /** 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); + } + } + + /** 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(PersistedInstallationEntry prefs) { + // Check if this firebase app is the default (first initialized) instance + 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 = fidGenerator.createRandomFid(); + } + return fid; + } + + /** 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(); + } + } + + /** + * 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(); + } + } + + /** + * Deletes the firebase installation id of the {@link FirebaseApp} from FIS servers and local + * storage. + */ + 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(), + entry.getFirebaseInstallationId(), + firebaseApp.getOptions().getProjectId(), + entry.getRefreshToken()); + + } catch (FirebaseException exception) { + throw new FirebaseInstallationsException( + "Failed to delete a Firebase Installation.", Status.BAD_CONFIG); + } + } + + 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 new file mode 100644 index 00000000000..07683203570 --- /dev/null +++ b/firebase-installations/src/main/java/com/google/firebase/installations/FirebaseInstallationsException.java @@ -0,0 +1,56 @@ +// Copyright 2019 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.google.firebase.installations; + +import androidx.annotation.NonNull; +import com.google.firebase.FirebaseException; + +/** The class for all Exceptions thrown by {@link FirebaseInstallations}. */ +public class FirebaseInstallationsException extends FirebaseException { + public enum Status { + /** + * Indicates that the caller is misconfigured, usually with a bad or misconfigured API Key or + * Project. + */ + BAD_CONFIG, + } + + @NonNull private final Status status; + + public FirebaseInstallationsException(@NonNull Status status) { + this.status = status; + } + + public FirebaseInstallationsException(@NonNull String message, @NonNull Status status) { + super(message); + this.status = status; + } + + public FirebaseInstallationsException( + @NonNull String message, @NonNull Status status, @NonNull Throwable cause) { + super(message, cause); + this.status = status; + } + + /** + * Gets the status for the operation that failed. + * + * @return the status for the FirebaseInstallationsException + */ + @NonNull + public Status getStatus() { + return status; + } +} diff --git a/firebase-installations/src/main/java/com/google/firebase/installations/FirebaseInstallationsRegistrar.java b/firebase-installations/src/main/java/com/google/firebase/installations/FirebaseInstallationsRegistrar.java new file mode 100644 index 00000000000..10731b9d11e --- /dev/null +++ b/firebase-installations/src/main/java/com/google/firebase/installations/FirebaseInstallationsRegistrar.java @@ -0,0 +1,48 @@ +// 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.Keep; +import com.google.firebase.FirebaseApp; +import com.google.firebase.components.Component; +import com.google.firebase.components.ComponentRegistrar; +import com.google.firebase.components.Dependency; +import com.google.firebase.heartbeatinfo.HeartBeatInfo; +import com.google.firebase.platforminfo.LibraryVersionComponent; +import com.google.firebase.platforminfo.UserAgentPublisher; +import java.util.Arrays; +import java.util.List; + +/** @hide */ +@Keep +public class FirebaseInstallationsRegistrar implements ComponentRegistrar { + + @Override + public List> getComponents() { + return Arrays.asList( + Component.builder(FirebaseInstallationsApi.class) + .add(Dependency.required(FirebaseApp.class)) + .add(Dependency.required(HeartBeatInfo.class)) + .add(Dependency.required(UserAgentPublisher.class)) + .factory( + c -> + new FirebaseInstallations( + c.get(FirebaseApp.class), + c.get(UserAgentPublisher.class), + c.get(HeartBeatInfo.class))) + .build(), + LibraryVersionComponent.create("fire-installations", BuildConfig.VERSION_NAME)); + } +} 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 new file mode 100644 index 00000000000..2c067a30507 --- /dev/null +++ b/firebase-installations/src/main/java/com/google/firebase/installations/GetAuthTokenListener.java @@ -0,0 +1,57 @@ +// 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 com.google.android.gms.tasks.TaskCompletionSource; +import com.google.firebase.installations.local.PersistedInstallationEntry; + +class GetAuthTokenListener implements StateListener { + private final Utils utils; + private final TaskCompletionSource resultTaskCompletionSource; + + public GetAuthTokenListener( + Utils utils, TaskCompletionSource resultTaskCompletionSource) { + this.utils = utils; + this.resultTaskCompletionSource = resultTaskCompletionSource; + } + + @Override + 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)) { + resultTaskCompletionSource.setResult( + InstallationTokenResult.builder() + .setToken(persistedInstallationEntry.getAuthToken()) + .setTokenExpirationTimestamp(persistedInstallationEntry.getExpiresInSecs()) + .setTokenCreationTimestamp(persistedInstallationEntry.getTokenCreationEpochInSecs()) + .build()); + return true; + } + return false; + } + + @Override + public boolean onException( + PersistedInstallationEntry persistedInstallationEntry, Exception exception) { + if (persistedInstallationEntry.isErrored() + || persistedInstallationEntry.isNotGenerated() + || persistedInstallationEntry.isUnregistered()) { + resultTaskCompletionSource.trySetException(exception); + return true; + } + return false; + } +} 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 new file mode 100644 index 00000000000..44f534f7da1 --- /dev/null +++ b/firebase-installations/src/main/java/com/google/firebase/installations/GetIdListener.java @@ -0,0 +1,47 @@ +// 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 com.google.android.gms.tasks.TaskCompletionSource; +import com.google.firebase.installations.local.PersistedInstallationEntry; + +class GetIdListener implements StateListener { + final TaskCompletionSource taskCompletionSource; + + public GetIdListener(TaskCompletionSource taskCompletionSource) { + this.taskCompletionSource = taskCompletionSource; + } + + @Override + public boolean onStateReached(PersistedInstallationEntry persistedInstallationEntry) { + if (persistedInstallationEntry.isUnregistered() + || persistedInstallationEntry.isRegistered() + || persistedInstallationEntry.isErrored()) { + taskCompletionSource.trySetResult(persistedInstallationEntry.getFirebaseInstallationId()); + return true; + } + return false; + } + + @Override + public boolean onException( + PersistedInstallationEntry persistedInstallationEntry, Exception exception) { + if (persistedInstallationEntry.isErrored()) { + taskCompletionSource.trySetException(exception); + return true; + } + return false; + } +} 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 new file mode 100644 index 00000000000..8cd08bdb299 --- /dev/null +++ b/firebase-installations/src/main/java/com/google/firebase/installations/StateListener.java @@ -0,0 +1,31 @@ +// 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 com.google.firebase.installations.local.PersistedInstallationEntry; + +interface StateListener { + /** + * Returns {@code true} if the defined {@link PersistedInstallationEntry} state is reached, {@code + * false} otherwise. + */ + boolean onStateReached(PersistedInstallationEntry persistedInstallationEntry); + + /** + * Returns {@code true} if an exception is thrown while registering a Firebase Installation, + * {@code false} otherwise. + */ + boolean onException(PersistedInstallationEntry persistedInstallationEntry, Exception exception); +} 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 new file mode 100644 index 00000000000..d2f748ce734 --- /dev/null +++ b/firebase-installations/src/main/java/com/google/firebase/installations/Utils.java @@ -0,0 +1,50 @@ +// 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 android.text.TextUtils; +import com.google.firebase.installations.local.PersistedInstallationEntry; +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; + + 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 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(calendar.getTimeInMillis()); + } +} diff --git a/firebase-installations/src/main/java/com/google/firebase/installations/local/IidStore.java b/firebase-installations/src/main/java/com/google/firebase/installations/local/IidStore.java new file mode 100644 index 00000000000..4e8366df7bf --- /dev/null +++ b/firebase-installations/src/main/java/com/google/firebase/installations/local/IidStore.java @@ -0,0 +1,135 @@ +// 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.local; + +import static android.content.ContentValues.TAG; + +import android.content.Context; +import android.content.SharedPreferences; +import android.util.Base64; +import android.util.Log; +import androidx.annotation.GuardedBy; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import com.google.firebase.FirebaseApp; +import java.security.KeyFactory; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.security.PublicKey; +import java.security.spec.InvalidKeySpecException; +import java.security.spec.X509EncodedKeySpec; + +/** + * Read existing iid only for default (first initialized) instance of this firebase application.* + */ +public class IidStore { + private static final String IID_SHARED_PREFS_NAME = "com.google.android.gms.appid"; + private static final String STORE_KEY_PUB = "|S||P|"; + private static final String STORE_KEY_ID = "|S|id"; + + @GuardedBy("iidPrefs") + private final SharedPreferences iidPrefs; + + public IidStore() { + // Different FirebaseApp in the same Android application should have the same application + // context and same dir path. We only read existing Iids for the default firebase application. + iidPrefs = + FirebaseApp.getInstance() + .getApplicationContext() + .getSharedPreferences(IID_SHARED_PREFS_NAME, Context.MODE_PRIVATE); + } + + @Nullable + public String readIid() { + synchronized (iidPrefs) { + // Background: Some versions of the IID-SDK store the Instance-ID in local storage, + // others only store the App-Instance's Public-Key that can be used to calculate the + // Instance-ID. + + // If such a version was used by this App-Instance, we can directly read the existing + // Instance-ID from storage and return it + String id = readInstanceIdFromLocalStorage(); + + if (id != null) { + return id; + } + + // If this App-Instance did not store the Instance-ID in local storage, we may be able to find + // its Public-Key in order to calculate the App-Instance's Instance-ID. + return readPublicKeyFromLocalStorageAndCalculateInstanceId(); + } + } + + @Nullable + private String readInstanceIdFromLocalStorage() { + synchronized (iidPrefs) { + return iidPrefs.getString(STORE_KEY_ID, /* defaultValue= */ null); + } + } + + @Nullable + private String readPublicKeyFromLocalStorageAndCalculateInstanceId() { + synchronized (iidPrefs) { + String base64PublicKey = iidPrefs.getString(STORE_KEY_PUB, /* defaultValue= */ null); + if (base64PublicKey == null) { + return null; + } + + PublicKey publicKey = parseKey(base64PublicKey); + if (publicKey == null) { + return null; + } + + return getIdFromPublicKey(publicKey); + } + } + + @Nullable + private static String getIdFromPublicKey(@NonNull PublicKey publicKey) { + // The ID is the sha of the public key truncated to 60 bit, with first 4 bits switched to + // 0x9 and base64 encoded + // This allows the id to be used internally for legacy systems and differentiate from + // old android ids and gcm ids + + byte[] derPub = publicKey.getEncoded(); + try { + MessageDigest md = MessageDigest.getInstance("SHA1"); + + byte[] digest = md.digest(derPub); + int b0 = digest[0]; + b0 = 0x70 + (0xF & b0); + digest[0] = (byte) (b0 & 0xFF); + return Base64.encodeToString( + digest, 0, 8, Base64.URL_SAFE | Base64.NO_WRAP | Base64.NO_PADDING); + } catch (NoSuchAlgorithmException e) { + Log.w(TAG, "Unexpected error, device missing required algorithms"); + } + return null; + } + + /** Parse the public key from stored data. */ + @Nullable + private PublicKey parseKey(String base64PublicKey) { + byte[] publicKeyBytes; + try { + publicKeyBytes = Base64.decode(base64PublicKey, Base64.URL_SAFE); + KeyFactory kf = KeyFactory.getInstance("RSA"); + return kf.generatePublic(new X509EncodedKeySpec(publicKeyBytes)); + } catch (IllegalArgumentException | InvalidKeySpecException | NoSuchAlgorithmException e) { + Log.w(TAG, "Invalid key stored " + e); + } + return null; + } +} 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 new file mode 100644 index 00000000000..d3786c4b6e4 --- /dev/null +++ b/firebase-installations/src/main/java/com/google/firebase/installations/local/PersistedInstallation.java @@ -0,0 +1,176 @@ +// 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.local; + +import androidx.annotation.NonNull; +import com.google.firebase.FirebaseApp; +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 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. + */ + NOT_GENERATED, + /** + * {@link PersistedInstallationEntry} is not synced with FIS servers. Next state: REGISTERED - + * If FID registration is successful. REGISTER_ERROR - If FID registration or refresh auth token + * failed. + */ + UNREGISTERED, + /** + * {@link PersistedInstallationEntry} is synced to FIS servers. Next state: REGISTER_ERROR - If + * FID registration or refresh auth token failed. + */ + REGISTERED, + /** + * {@link PersistedInstallationEntry} is in error state when an exception is thrown while + * syncing with FIS server. Next state: UNREGISTERED - A new FID is created and persisted + * locally before registering with FIS servers. + */ + REGISTER_ERROR, + } + + 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"; + private static final String TOKEN_CREATION_TIME_IN_SECONDS_KEY = "TokenCreationEpochInSecs"; + private static final String EXPIRES_IN_SECONDS_KEY = "ExpiresInSecs"; + private static final String PERSISTED_STATUS_KEY = "Status"; + private static final String FIS_ERROR_KEY = "FisError"; + + public PersistedInstallation(@NonNull FirebaseApp firebaseApp) { + // Different FirebaseApp in the same Android application should have the same application + // context and same dir path + dataFile = + new File( + firebaseApp.getApplicationContext().getFilesDir(), + SETTINGS_FILE_NAME_PREFIX + "." + firebaseApp.getPersistenceKey() + ".json"); + this.firebaseApp = firebaseApp; + } + + @NonNull + public PersistedInstallationEntry readPersistedInstallationEntryValue() { + JSONObject json = readJSONFromFile(); + + 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; + } + + 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 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); + } + } 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; + } + + /** 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 new file mode 100644 index 00000000000..f58f49a6afb --- /dev/null +++ b/firebase-installations/src/main/java/com/google/firebase/installations/local/PersistedInstallationEntry.java @@ -0,0 +1,164 @@ +// 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.local; + +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 + * few Firebase Installation attributes and the persisted status of this entry. + */ +@AutoValue +public abstract class PersistedInstallationEntry { + + @Nullable + public abstract String getFirebaseInstallationId(); + + @NonNull + public abstract PersistedInstallation.RegistrationStatus getRegistrationStatus(); + + @Nullable + public abstract String getAuthToken(); + + @Nullable + public abstract String getRefreshToken(); + + public abstract long getExpiresInSecs(); + + public abstract long getTokenCreationEpochInSecs(); + + @Nullable + public abstract String getFisError(); + + @NonNull + public static PersistedInstallationEntry INSTANCE = PersistedInstallationEntry.builder().build(); + + public boolean isRegistered() { + return getRegistrationStatus() == PersistedInstallation.RegistrationStatus.REGISTERED; + } + + public boolean isErrored() { + return getRegistrationStatus() == PersistedInstallation.RegistrationStatus.REGISTER_ERROR; + } + + public boolean isUnregistered() { + return getRegistrationStatus() == PersistedInstallation.RegistrationStatus.UNREGISTERED; + } + + public boolean isNotGenerated() { + 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 + public abstract Builder toBuilder(); + + /** Returns a default Builder object to create an PersistedInstallationEntry object */ + @NonNull + public static PersistedInstallationEntry.Builder builder() { + return new AutoValue_PersistedInstallationEntry.Builder() + .setTokenCreationEpochInSecs(0) + .setRegistrationStatus(RegistrationStatus.ATTEMPT_MIGRATION) + .setExpiresInSecs(0); + } + + @AutoValue.Builder + public abstract static class Builder { + @NonNull + public abstract Builder setFirebaseInstallationId(@NonNull String value); + + @NonNull + public abstract Builder setRegistrationStatus( + @NonNull PersistedInstallation.RegistrationStatus value); + + @NonNull + public abstract Builder setAuthToken(@Nullable String value); + + @NonNull + public abstract Builder setRefreshToken(@Nullable String value); + + @NonNull + public abstract Builder setExpiresInSecs(long value); + + @NonNull + public abstract Builder setTokenCreationEpochInSecs(long value); + + @NonNull + public abstract Builder setFisError(@Nullable String value); + + @NonNull + public abstract PersistedInstallationEntry build(); + } +} 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 new file mode 100644 index 00000000000..bb392f9472e --- /dev/null +++ b/firebase-installations/src/main/java/com/google/firebase/installations/remote/FirebaseInstallationServiceClient.java @@ -0,0 +1,403 @@ +// 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.remote; + +import static android.content.ContentValues.TAG; +import static com.google.android.gms.common.internal.Preconditions.checkArgument; + +import android.content.Context; +import android.content.pm.PackageManager; +import android.util.JsonReader; +import android.util.Log; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import com.google.android.gms.common.util.AndroidUtilsLight; +import com.google.android.gms.common.util.Hex; +import com.google.android.gms.common.util.VisibleForTesting; +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; +import java.io.IOException; +import java.io.InputStreamReader; +import java.net.URL; +import java.nio.charset.Charset; +import java.util.regex.Pattern; +import java.util.zip.GZIPOutputStream; +import javax.net.ssl.HttpsURLConnection; +import org.json.JSONException; +import org.json.JSONObject; + +/** Http client that sends request to Firebase Installations backend API. */ +public class FirebaseInstallationServiceClient { + private static final String FIREBASE_INSTALLATIONS_API_DOMAIN = + "firebaseinstallations.googleapis.com"; + private static final String CREATE_REQUEST_RESOURCE_NAME_FORMAT = "projects/%s/installations"; + private static final String GENERATE_AUTH_TOKEN_REQUEST_RESOURCE_NAME_FORMAT = + "projects/%s/installations/%s/authTokens:generate"; + private static final String DELETE_REQUEST_RESOURCE_NAME_FORMAT = "projects/%s/installations/%s"; + private static final String FIREBASE_INSTALLATIONS_API_VERSION = "v1"; + private static final String FIREBASE_INSTALLATION_AUTH_VERSION = "FIS_v2"; + + private static final String CONTENT_TYPE_HEADER_KEY = "Content-Type"; + private static final String ACCEPT_HEADER_KEY = "Accept"; + private static final String JSON_CONTENT_TYPE = "application/json"; + private static final String CONTENT_ENCODING_HEADER_KEY = "Content-Encoding"; + private static final String GZIP_CONTENT_ENCODING = "gzip"; + + /** Heartbeat tag for firebase installations. */ + private static final String FIREBASE_INSTALLATIONS_ID_HEARTBEAT_TAG = "fire-installations-id"; + + 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 X_ANDROID_PACKAGE_HEADER_KEY = "X-Android-Package"; + private static final String X_ANDROID_CERT_HEADER_KEY = "X-Android-Cert"; + + private static final int NETWORK_TIMEOUT_MILLIS = 10000; + + private static final Pattern EXPIRATION_TIMESTAMP_PATTERN = Pattern.compile("[0-9]+s"); + + private static final int MAX_RETRIES = 1; + private static final Charset UTF_8 = Charset.forName("UTF-8"); + + @VisibleForTesting + static final String PARSING_EXPIRATION_TIME_ERROR_MESSAGE = "Invalid Expiration Timestamp."; + + private final Context context; + private final UserAgentPublisher userAgentPublisher; + private final HeartBeatInfo heartbeatInfo; + + public FirebaseInstallationServiceClient( + @NonNull Context context, + @Nullable UserAgentPublisher publisher, + @Nullable HeartBeatInfo heartBeatInfo) { + this.context = context; + this.userAgentPublisher = publisher; + this.heartbeatInfo = heartBeatInfo; + } + + /** + * Creates a FID on the FIS Servers by calling FirebaseInstallations API create method. + * + * @param apiKey API Key that has access to FIS APIs + * @param fid Firebase Installation Identifier + * @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 IOException { + String resourceName = String.format(CREATE_REQUEST_RESOURCE_NAME_FORMAT, projectID); + 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(); + + if (httpResponseCode == 200) { + return readCreateResponse(httpsURLConnection); + } + + if (httpResponseCode == 429 || (httpResponseCode >= 500 && httpResponseCode < 600)) { + retryCount++; + continue; + } + + // 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) + throws JSONException { + JSONObject firebaseInstallationData = new JSONObject(); + firebaseInstallationData.put("fid", fid); + firebaseInstallationData.put("appId", appId); + firebaseInstallationData.put("authVersion", FIREBASE_INSTALLATION_AUTH_VERSION); + firebaseInstallationData.put("sdkVersion", "t.1.1.0"); + return firebaseInstallationData; + } + + /** + * Deletes a FID on the FIS Servers by calling FirebaseInstallations API delete method. + * + * @param apiKey API Key that has access to FIS APIs + * @param fid Firebase Installation Identifier + * @param projectID Project Id + * @param refreshToken a token used to authenticate FIS requests + */ + @NonNull + public void deleteFirebaseInstallation( + @NonNull String apiKey, + @NonNull String fid, + @NonNull String projectID, + @NonNull String refreshToken) + throws FirebaseException, IOException { + String resourceName = String.format(DELETE_REQUEST_RESOURCE_NAME_FORMAT, projectID, fid); + 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(); + + if (httpResponseCode == 200 || httpResponseCode == 401 || httpResponseCode == 404) { + return; + } + + 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(); + } + + /** + * Generates a new auth token for a FID on the FIS Servers by calling FirebaseInstallations API + * generateAuthToken method. + * + * @param apiKey API Key that has access to FIS APIs + * @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( + @NonNull String apiKey, + @NonNull String fid, + @NonNull String projectID, + @NonNull String refreshToken) + throws IOException { + String resourceName = + String.format(GENERATE_AUTH_TOKEN_REQUEST_RESOURCE_NAME_FORMAT, projectID, fid); + 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); + } + + if (httpResponseCode == 401 || httpResponseCode == 404) { + return TokenResult.builder().setResponseCode(TokenResult.ResponseCode.AUTH_ERROR).build(); + } + + if (httpResponseCode == 429 || (httpResponseCode >= 500 && httpResponseCode < 600)) { + retryCount++; + continue; + } + + return TokenResult.builder().setResponseCode(TokenResult.ResponseCode.BAD_CONFIG).build(); + } + throw new IOException(); + } + + private HttpsURLConnection openHttpsURLConnection(URL url) throws IOException { + HttpsURLConnection httpsURLConnection = (HttpsURLConnection) url.openConnection(); + httpsURLConnection.setConnectTimeout(NETWORK_TIMEOUT_MILLIS); + httpsURLConnection.setReadTimeout(NETWORK_TIMEOUT_MILLIS); + httpsURLConnection.addRequestProperty(CONTENT_TYPE_HEADER_KEY, JSON_CONTENT_TYPE); + httpsURLConnection.addRequestProperty(ACCEPT_HEADER_KEY, JSON_CONTENT_TYPE); + httpsURLConnection.addRequestProperty(CONTENT_ENCODING_HEADER_KEY, GZIP_CONTENT_ENCODING); + httpsURLConnection.addRequestProperty(X_ANDROID_PACKAGE_HEADER_KEY, context.getPackageName()); + if (heartbeatInfo != null && userAgentPublisher != null) { + HeartBeat heartbeat = heartbeatInfo.getHeartBeatCode(FIREBASE_INSTALLATIONS_ID_HEARTBEAT_TAG); + if (heartbeat != HeartBeat.NONE) { + httpsURLConnection.addRequestProperty(USER_AGENT_HEADER, userAgentPublisher.getUserAgent()); + httpsURLConnection.addRequestProperty( + HEART_BEAT_HEADER, Integer.toString(heartbeat.getCode())); + } + } + httpsURLConnection.addRequestProperty( + X_ANDROID_CERT_HEADER_KEY, getFingerprintHashForPackage()); + return httpsURLConnection; + } + + // Read the response from the createFirebaseInstallation API. + private InstallationResponse readCreateResponse(HttpsURLConnection conn) throws IOException { + JsonReader reader = new JsonReader(new InputStreamReader(conn.getInputStream(), UTF_8)); + TokenResult.Builder tokenResult = TokenResult.builder(); + InstallationResponse.Builder builder = InstallationResponse.builder(); + reader.beginObject(); + while (reader.hasNext()) { + String name = reader.nextName(); + if (name.equals("name")) { + builder.setUri(reader.nextString()); + } else if (name.equals("fid")) { + builder.setFid(reader.nextString()); + } else if (name.equals("refreshToken")) { + builder.setRefreshToken(reader.nextString()); + } else if (name.equals("authToken")) { + reader.beginObject(); + while (reader.hasNext()) { + String key = reader.nextName(); + if (key.equals("token")) { + tokenResult.setToken(reader.nextString()); + } else if (key.equals("expiresIn")) { + tokenResult.setTokenExpirationTimestamp( + parseTokenExpirationTimestamp(reader.nextString())); + } else { + reader.skipValue(); + } + } + builder.setAuthToken(tokenResult.build()); + reader.endObject(); + } else { + reader.skipValue(); + } + } + reader.endObject(); + + return builder.setResponseCode(ResponseCode.OK).build(); + } + + // Read the response from the generateAuthToken FirebaseInstallation API. + private TokenResult readGenerateAuthTokenResponse(HttpsURLConnection conn) throws IOException { + JsonReader reader = new JsonReader(new InputStreamReader(conn.getInputStream(), UTF_8)); + TokenResult.Builder builder = TokenResult.builder(); + reader.beginObject(); + while (reader.hasNext()) { + String name = reader.nextName(); + if (name.equals("token")) { + builder.setToken(reader.nextString()); + } else if (name.equals("expiresIn")) { + builder.setTokenExpirationTimestamp(parseTokenExpirationTimestamp(reader.nextString())); + } else { + reader.skipValue(); + } + } + reader.endObject(); + + return builder.setResponseCode(TokenResult.ResponseCode.OK).build(); + } + + // Read the error message from the response. + private String readErrorResponse(HttpsURLConnection conn) throws IOException { + BufferedReader reader = new BufferedReader(new InputStreamReader(conn.getErrorStream(), UTF_8)); + StringBuilder response = new StringBuilder(); + for (String input = reader.readLine(); input != null; input = reader.readLine()) { + response.append(input).append('\n'); + } + return String.format( + "The server responded with an error. HTTP response: [%d %s %s]", + conn.getResponseCode(), conn.getResponseMessage(), response); + } + + /** Gets the Android package's SHA-1 fingerprint. */ + private String getFingerprintHashForPackage() { + byte[] hash; + + try { + hash = AndroidUtilsLight.getPackageCertificateHashBytes(context, context.getPackageName()); + + if (hash == null) { + Log.e(TAG, "Could not get fingerprint hash for package: " + context.getPackageName()); + return null; + } else { + return Hex.bytesToStringUppercase(hash, /* zeroTerminated= */ false); + } + } catch (PackageManager.NameNotFoundException e) { + Log.e(TAG, "No such package: " + context.getPackageName(), e); + return null; + } + } + + /** + * Returns parsed token expiration timestamp in seconds. + * + * @param expiresIn is expiration timestamp in String format: 604800s + */ + @VisibleForTesting + static long parseTokenExpirationTimestamp(String expiresIn) { + checkArgument( + EXPIRATION_TIMESTAMP_PATTERN.matcher(expiresIn).matches(), + PARSING_EXPIRATION_TIME_ERROR_MESSAGE); + return (expiresIn == null || expiresIn.length() == 0) + ? 0L + : Long.parseLong(expiresIn.substring(0, expiresIn.length() - 1)); + } +} 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 new file mode 100644 index 00000000000..213b4d19416 --- /dev/null +++ b/firebase-installations/src/main/java/com/google/firebase/installations/remote/InstallationResponse.java @@ -0,0 +1,76 @@ +// 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.remote; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import com.google.auto.value.AutoValue; + +@AutoValue +public abstract class InstallationResponse { + + public enum ResponseCode { + // Returned on success + OK, + // 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 + public abstract String getUri(); + + @Nullable + public abstract String getFid(); + + @Nullable + public abstract String getRefreshToken(); + + @Nullable + public abstract TokenResult getAuthToken(); + + @Nullable + public abstract ResponseCode getResponseCode(); + + @NonNull + public abstract Builder toBuilder(); + + /** Returns a default Builder object to create an InstallationResponse object */ + @NonNull + public static InstallationResponse.Builder builder() { + return new AutoValue_InstallationResponse.Builder(); + } + + @AutoValue.Builder + public abstract static class Builder { + @NonNull + public abstract Builder setUri(@NonNull String value); + + @NonNull + public abstract Builder setFid(@NonNull String value); + + @NonNull + public abstract Builder setRefreshToken(@NonNull String value); + + @NonNull + public abstract Builder setAuthToken(@NonNull TokenResult value); + + @NonNull + public abstract Builder setResponseCode(@NonNull ResponseCode value); + + @NonNull + public abstract InstallationResponse build(); + } +} 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 new file mode 100644 index 00000000000..1fd7a5d99fd --- /dev/null +++ b/firebase-installations/src/main/java/com/google/firebase/installations/remote/TokenResult.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.remote; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import com.google.auto.value.AutoValue; + +/** This class represents a set of values describing a FIS Auth Token Result. */ +@AutoValue +public abstract class TokenResult { + + public enum ResponseCode { + // Returned on success + 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. + 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. + AUTH_ERROR, + } + + /** A new FIS Auth-Token, created for this Firebase Installation. */ + @Nullable + public abstract String getToken(); + /** The timestamp, before the auth-token expires for this Firebase Installation. */ + @NonNull + public abstract long getTokenExpirationTimestamp(); + + @Nullable + public abstract ResponseCode getResponseCode(); + + @NonNull + public abstract Builder toBuilder(); + + /** Returns a default Builder object to create an InstallationResponse object */ + @NonNull + public static TokenResult.Builder builder() { + return new AutoValue_TokenResult.Builder().setTokenExpirationTimestamp(0); + } + + @AutoValue.Builder + public abstract static class Builder { + @NonNull + public abstract Builder setToken(@NonNull String value); + + @NonNull + public abstract Builder setTokenExpirationTimestamp(long value); + + @NonNull + public abstract Builder setResponseCode(@NonNull ResponseCode value); + + @NonNull + public abstract TokenResult build(); + } +} diff --git a/firebase-installations/src/test/java/com/google/firebase/installations/FirebaseInstallationsRegistrarTest.java b/firebase-installations/src/test/java/com/google/firebase/installations/FirebaseInstallationsRegistrarTest.java new file mode 100644 index 00000000000..f553b7d701a --- /dev/null +++ b/firebase-installations/src/test/java/com/google/firebase/installations/FirebaseInstallationsRegistrarTest.java @@ -0,0 +1,55 @@ +// 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 static org.junit.Assert.assertNotNull; + +import androidx.test.core.app.ApplicationProvider; +import com.google.firebase.FirebaseApp; +import com.google.firebase.FirebaseOptions; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; + +/** Tests for {@link FirebaseInstallationsRegistrar}. */ +@RunWith(RobolectricTestRunner.class) +public class FirebaseInstallationsRegistrarTest { + @Before + public void setUp() { + FirebaseApp.clearInstancesForTest(); + } + + @Test + public void getFirebaseInstallationsInstance() { + FirebaseApp defaultApp = + FirebaseApp.initializeApp( + ApplicationProvider.getApplicationContext(), + new FirebaseOptions.Builder().setApplicationId("1:123456789:android:abcdef").build()); + + FirebaseApp anotherApp = + FirebaseApp.initializeApp( + ApplicationProvider.getApplicationContext(), + new FirebaseOptions.Builder().setApplicationId("1:987654321:android:abcdef").build(), + "firebase_app_1"); + + FirebaseInstallations defaultFirebaseInstallation = FirebaseInstallations.getInstance(); + assertNotNull(defaultFirebaseInstallation); + + FirebaseInstallations anotherFirebaseInstallation = + FirebaseInstallations.getInstance(anotherApp); + assertNotNull(anotherFirebaseInstallation); + } +} diff --git a/firebase-installations/src/test/java/com/google/firebase/installations/FirebaseInstallationsTest.java b/firebase-installations/src/test/java/com/google/firebase/installations/FirebaseInstallationsTest.java new file mode 100644 index 00000000000..fe64dcf06b8 --- /dev/null +++ b/firebase-installations/src/test/java/com/google/firebase/installations/FirebaseInstallationsTest.java @@ -0,0 +1,18 @@ +// 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; + +/** Tests for {@link FirebaseInstallations}. */ +public class FirebaseInstallationsTest {} diff --git a/firebase-installations/src/test/java/com/google/firebase/installations/remote/FirebaseInstallationServiceClientTest.java b/firebase-installations/src/test/java/com/google/firebase/installations/remote/FirebaseInstallationServiceClientTest.java new file mode 100644 index 00000000000..eb30312c0e4 --- /dev/null +++ b/firebase-installations/src/test/java/com/google/firebase/installations/remote/FirebaseInstallationServiceClientTest.java @@ -0,0 +1,55 @@ +// 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.remote; + +import static com.google.common.truth.Truth.assertThat; +import static com.google.common.truth.Truth.assertWithMessage; +import static org.junit.Assert.fail; + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; + +/** Tests for {@link FirebaseInstallationServiceClient}. */ +@RunWith(RobolectricTestRunner.class) +public class FirebaseInstallationServiceClientTest { + + private final String TEST_EXPIRATION_TIMESTAMP = "604800s"; + private final long TEST_EXPIRATION_IN_SECS = 604800; + private final String INCORRECT_EXPIRATION_TIMESTAMP = "2345"; + + @Test + public void parseTokenExpirationTimestamp_successful() { + long actual = + FirebaseInstallationServiceClient.parseTokenExpirationTimestamp(TEST_EXPIRATION_TIMESTAMP); + + assertWithMessage("Exception status doesn't match") + .that(actual) + .isEqualTo(TEST_EXPIRATION_IN_SECS); + } + + @Test + public void parseTokenExpirationTimestamp_failed() { + try { + FirebaseInstallationServiceClient.parseTokenExpirationTimestamp( + INCORRECT_EXPIRATION_TIMESTAMP); + fail("Parsing token expiration timestamp failed."); + } catch (IllegalArgumentException expected) { + assertThat(expected) + .hasMessageThat() + .isEqualTo(FirebaseInstallationServiceClient.PARSING_EXPIRATION_TIME_ERROR_MESSAGE); + } + } +} diff --git a/subprojects.cfg b/subprojects.cfg index dd36b83abc5..33d16449cdc 100644 --- a/subprojects.cfg +++ b/subprojects.cfg @@ -21,6 +21,8 @@ firebase-inappmessaging firebase-inappmessaging:ktx firebase-inappmessaging-display firebase-inappmessaging-display:ktx +firebase-installations-interop +firebase-installations firebase-storage firebase-storage:ktx firebase-storage:test-app