diff --git a/firebase-common/src/androidTest/java/com/google/firebase/FirebaseAppTest.java b/firebase-common/src/androidTest/java/com/google/firebase/FirebaseAppTest.java index bf913d0254b..52c4b8ac623 100644 --- a/firebase-common/src/androidTest/java/com/google/firebase/FirebaseAppTest.java +++ b/firebase-common/src/androidTest/java/com/google/firebase/FirebaseAppTest.java @@ -45,6 +45,9 @@ import com.google.firebase.components.TestComponentOne; import com.google.firebase.components.TestComponentTwo; import com.google.firebase.components.TestUserAgentDependentComponent; +import com.google.firebase.emulators.EmulatedServiceSettings; +import com.google.firebase.emulators.EmulatorSettings; +import com.google.firebase.emulators.FirebaseEmulator; import com.google.firebase.platforminfo.UserAgentPublisher; import com.google.firebase.testing.FirebaseAppRule; import java.lang.reflect.InvocationTargetException; @@ -417,6 +420,43 @@ public void testDirectBoot_shouldPreserveDataCollectionAfterUnlock() { assertTrue(firebaseApp.isDataCollectionDefaultEnabled()); } + @Test + public void testEnableEmulators_shouldAllowDoubleSetBeforeAccess() { + Context mockContext = createForwardingMockContext(); + FirebaseApp firebaseApp = FirebaseApp.initializeApp(mockContext); + + // A developer would call FirebaseDatabase.EMULATOR but we can't introduce that + // dependency for this test. + FirebaseEmulator emulator = FirebaseEmulator.forName("database"); + + EmulatedServiceSettings databaseSettings = new EmulatedServiceSettings("10.0.2.2", 9000); + EmulatorSettings emulatorSettings = + new EmulatorSettings.Builder().addEmulatedService(emulator, databaseSettings).build(); + + // Set twice + firebaseApp.enableEmulators(emulatorSettings); + firebaseApp.enableEmulators(emulatorSettings); + } + + @Test + public void testEnableEmulators_shouldThrowIfSetAfterAccess() { + Context mockContext = createForwardingMockContext(); + FirebaseApp firebaseApp = FirebaseApp.initializeApp(mockContext); + + FirebaseEmulator emulator = FirebaseEmulator.forName("database"); + + EmulatedServiceSettings databaseSettings = new EmulatedServiceSettings("10.0.2.2", 9000); + EmulatorSettings emulatorSettings = + new EmulatorSettings.Builder().addEmulatedService(emulator, databaseSettings).build(); + firebaseApp.enableEmulators(emulatorSettings); + + // Access (as if from the Database SDK) + firebaseApp.getEmulatorSettings().getServiceSettings(emulator); + + // Try to set again + assertThrows(IllegalStateException.class, () -> firebaseApp.enableEmulators(emulatorSettings)); + } + /** Returns mock context that forwards calls to targetContext and localBroadcastManager. */ private Context createForwardingMockContext() { final UserManager spyUserManager = spy(targetContext.getSystemService(UserManager.class)); diff --git a/firebase-common/src/main/java/com/google/firebase/FirebaseApp.java b/firebase-common/src/main/java/com/google/firebase/FirebaseApp.java index 00c4bdb5b28..dee3e1d9e90 100644 --- a/firebase-common/src/main/java/com/google/firebase/FirebaseApp.java +++ b/firebase-common/src/main/java/com/google/firebase/FirebaseApp.java @@ -44,6 +44,7 @@ import com.google.firebase.components.ComponentRegistrar; import com.google.firebase.components.ComponentRuntime; import com.google.firebase.components.Lazy; +import com.google.firebase.emulators.EmulatorSettings; import com.google.firebase.events.Publisher; import com.google.firebase.heartbeatinfo.DefaultHeartBeatInfo; import com.google.firebase.internal.DataCollectionConfigStorage; @@ -110,6 +111,9 @@ public class FirebaseApp { private final FirebaseOptions options; private final ComponentRuntime componentRuntime; + private final AtomicBoolean emulatorSettingsFrozen = new AtomicBoolean(false); + private EmulatorSettings emulatorSettings = EmulatorSettings.DEFAULT; + // Default disabled. We released Firebase publicly without this feature, so making it default // enabled is a backwards incompatible change. private final AtomicBoolean automaticResourceManagementEnabled = new AtomicBoolean(false); @@ -142,6 +146,20 @@ public FirebaseOptions getOptions() { return options; } + /** + * Returns the specified {@link EmulatorSettings} or a default. + * + *

TODO(samstern): Un-hide this once Firestore, Database, and Functions are implemented + * + * @hide + */ + @NonNull + public EmulatorSettings getEmulatorSettings() { + checkNotDeleted(); + emulatorSettingsFrozen.set(true); + return emulatorSettings; + } + @Override public boolean equals(Object o) { if (!(o instanceof FirebaseApp)) { @@ -305,6 +323,26 @@ public static FirebaseApp initializeApp( return firebaseApp; } + /** + * Specify which services should access local emulators for this FirebaseApp instance. + * + *

For example, if the {@link EmulatorSettings} contain {@link + * com.google.firebase.emulators.EmulatedServiceSettings} for {@link FirebaseDatabase#EMULATOR}, + * then calls to Cloud Firestore will communicate with the emulator rather than production. + * + *

TODO(samstern): Un-hide this once Firestore, Database, and Functions are implemented + * + * @param emulatorSettings the emulator settings for all services. + * @hide + */ + public void enableEmulators(@NonNull EmulatorSettings emulatorSettings) { + checkNotDeleted(); + Preconditions.checkState( + !this.emulatorSettingsFrozen.get(), + "Cannot enable emulators after Firebase SDKs have already been used."); + this.emulatorSettings = emulatorSettings; + } + /** * Deletes the {@link FirebaseApp} and all its data. All calls to this {@link FirebaseApp} * instance will throw once it has been called. diff --git a/firebase-common/src/main/java/com/google/firebase/emulators/EmulatedServiceSettings.java b/firebase-common/src/main/java/com/google/firebase/emulators/EmulatedServiceSettings.java new file mode 100644 index 00000000000..5acd04e9aa8 --- /dev/null +++ b/firebase-common/src/main/java/com/google/firebase/emulators/EmulatedServiceSettings.java @@ -0,0 +1,44 @@ +// Copyright 2020 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.emulators; + +import androidx.annotation.NonNull; + +/** + * Settings to connect a single Firebase service to a local emulator. + * + *

TODO(samstern): Un-hide this once Firestore, Database, and Functions are implemented + * + * @see EmulatorSettings + * @hide + */ +public final class EmulatedServiceSettings { + + private final String host; + private final int port; + + public EmulatedServiceSettings(@NonNull String host, int port) { + this.host = host; + this.port = port; + } + + public String getHost() { + return host; + } + + public int getPort() { + return port; + } +} diff --git a/firebase-common/src/main/java/com/google/firebase/emulators/EmulatorSettings.java b/firebase-common/src/main/java/com/google/firebase/emulators/EmulatorSettings.java new file mode 100644 index 00000000000..0ad3c80a32f --- /dev/null +++ b/firebase-common/src/main/java/com/google/firebase/emulators/EmulatorSettings.java @@ -0,0 +1,87 @@ +// Copyright 2020 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.emulators; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import com.google.firebase.components.Preconditions; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +/** + * Settings that control which Firebase services should access a local emulator, rather than + * production. + * + *

TODO(samstern): Un-hide this once Firestore, Database, and Functions are implemented + * + * @see com.google.firebase.FirebaseApp#enableEmulators(EmulatorSettings) + * @hide + */ +public final class EmulatorSettings { + + /** Empty emulator settings to be used as an internal default */ + public static final EmulatorSettings DEFAULT = new EmulatorSettings.Builder().build(); + + public static final class Builder { + + private final Map settingsMap = new HashMap<>(); + + /** Constructs an empty builder. */ + public Builder() {} + + /** + * Specify the emulator settings for a single service. + * + * @param emulator the emulated service. + * @param settings the emulator settings. + * @return the builder, for chaining. + */ + @NonNull + public Builder addEmulatedService( + @NonNull FirebaseEmulator emulator, @NonNull EmulatedServiceSettings settings) { + Preconditions.checkState( + !settingsMap.containsKey(emulator), + "Cannot call addEmulatedService twice for " + emulator.toString()); + this.settingsMap.put(emulator, settings); + return this; + } + + @NonNull + public EmulatorSettings build() { + return new EmulatorSettings(new HashMap<>(settingsMap)); + } + } + + private final Map settingsMap; + + private EmulatorSettings(@NonNull Map settingsMap) { + this.settingsMap = Collections.unmodifiableMap(settingsMap); + } + + /** + * Fetch the emulation settings for a single Firebase service. + * + * @hide + */ + @Nullable + public EmulatedServiceSettings getServiceSettings(@NonNull FirebaseEmulator emulator) { + if (settingsMap.containsKey(emulator)) { + return settingsMap.get(emulator); + } + + return null; + } +} diff --git a/firebase-common/src/main/java/com/google/firebase/emulators/FirebaseEmulator.java b/firebase-common/src/main/java/com/google/firebase/emulators/FirebaseEmulator.java new file mode 100644 index 00000000000..8c5800fd055 --- /dev/null +++ b/firebase-common/src/main/java/com/google/firebase/emulators/FirebaseEmulator.java @@ -0,0 +1,50 @@ +// Copyright 2020 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.emulators; + +import androidx.annotation.NonNull; + +/** + * Identifier Firebase services that can be emulated using the Firebase Emulator Suite. + * + *

TODO(samstern): Un-hide this once Firestore, Database, and Functions are implemented + * + * @see com.google.firebase.FirebaseApp#enableEmulators(EmulatorSettings) + * @see EmulatorSettings + * @see EmulatedServiceSettings + * @hide + */ +public final class FirebaseEmulator { + + private final String name; + + /** + * Only to be called by SDKs which support emulators in order to make constants. + * + * @hide + */ + @NonNull + public static FirebaseEmulator forName(String name) { + return new FirebaseEmulator(name); + } + + private FirebaseEmulator(String name) { + this.name = name; + } + + public String getName() { + return name; + } +} diff --git a/firebase-database/src/androidTest/java/com/google/firebase/database/FirebaseDatabaseTest.java b/firebase-database/src/androidTest/java/com/google/firebase/database/FirebaseDatabaseTest.java index 121a8a3e93e..61135dfdee5 100644 --- a/firebase-database/src/androidTest/java/com/google/firebase/database/FirebaseDatabaseTest.java +++ b/firebase-database/src/androidTest/java/com/google/firebase/database/FirebaseDatabaseTest.java @@ -30,6 +30,8 @@ import com.google.firebase.database.core.persistence.MockPersistenceStorageEngine; import com.google.firebase.database.core.persistence.PersistenceManager; import com.google.firebase.database.future.WriteFuture; +import com.google.firebase.emulators.EmulatedServiceSettings; +import com.google.firebase.emulators.EmulatorSettings; import java.util.ArrayList; import java.util.Arrays; import java.util.List; @@ -64,6 +66,26 @@ public void getInstanceForApp() { assertEquals(IntegrationTestValues.getAltNamespace(), db.getReference().toString()); } + @Test + public void getInstanceForAppWithEmulator() { + FirebaseApp app = + appForDatabaseUrl(IntegrationTestValues.getAltNamespace(), "getInstanceForAppWithEmulator"); + + EmulatedServiceSettings serviceSettings = new EmulatedServiceSettings("10.0.2.2", 9000); + EmulatorSettings emulatorSettings = + new EmulatorSettings.Builder() + .addEmulatedService(FirebaseDatabase.EMULATOR, serviceSettings) + .build(); + app.enableEmulators(emulatorSettings); + + FirebaseDatabase db = FirebaseDatabase.getInstance(app); + DatabaseReference rootRef = db.getReference(); + assertEquals(rootRef.toString(), "http://10.0.2.2:9000"); + + DatabaseReference urlReference = db.getReferenceFromUrl("https://otherns.firebaseio.com"); + assertEquals(urlReference.toString(), "http://10.0.2.2:9000"); + } + @Test public void getInstanceForAppWithUrl() { FirebaseApp app = diff --git a/firebase-database/src/main/java/com/google/firebase/database/FirebaseDatabase.java b/firebase-database/src/main/java/com/google/firebase/database/FirebaseDatabase.java index d9a79a3d4bb..d8bca73485f 100644 --- a/firebase-database/src/main/java/com/google/firebase/database/FirebaseDatabase.java +++ b/firebase-database/src/main/java/com/google/firebase/database/FirebaseDatabase.java @@ -20,6 +20,7 @@ import androidx.annotation.NonNull; import com.google.firebase.FirebaseApp; import com.google.firebase.FirebaseOptions; +import com.google.firebase.database.annotations.Nullable; import com.google.firebase.database.core.DatabaseConfig; import com.google.firebase.database.core.Path; import com.google.firebase.database.core.Repo; @@ -28,6 +29,8 @@ import com.google.firebase.database.core.utilities.ParsedUrl; import com.google.firebase.database.core.utilities.Utilities; import com.google.firebase.database.core.utilities.Validation; +import com.google.firebase.emulators.EmulatedServiceSettings; +import com.google.firebase.emulators.FirebaseEmulator; /** * The entry point for accessing a Firebase Database. You can get an instance by calling {@link @@ -36,6 +39,8 @@ */ public class FirebaseDatabase { + public static final FirebaseEmulator EMULATOR = FirebaseEmulator.forName("database"); + private static final String SDK_VERSION = BuildConfig.VERSION_NAME; private final FirebaseApp app; @@ -99,7 +104,7 @@ public static synchronized FirebaseDatabase getInstance( + "FirebaseApp or from your getInstance() call."); } - ParsedUrl parsedUrl = Utilities.parseUrl(url); + ParsedUrl parsedUrl = Utilities.parseUrl(url, getEmulatorServiceSettings(app)); if (!parsedUrl.path.isEmpty()) { throw new DatabaseException( "Specified Database URL '" @@ -112,6 +117,7 @@ public static synchronized FirebaseDatabase getInstance( checkNotNull(app, "Provided FirebaseApp must not be null."); FirebaseDatabaseComponent component = app.get(FirebaseDatabaseComponent.class); checkNotNull(component, "Firebase Database component is not present."); + return component.get(parsedUrl.repoInfo); } @@ -188,7 +194,7 @@ public DatabaseReference getReferenceFromUrl(@NonNull String url) { "Can't pass null for argument 'url' in " + "FirebaseDatabase.getReferenceFromUrl()"); } - ParsedUrl parsedUrl = Utilities.parseUrl(url); + ParsedUrl parsedUrl = Utilities.parseUrl(url, getEmulatorServiceSettings(this.app)); if (!parsedUrl.repoInfo.host.equals(this.repo.getRepoInfo().host)) { throw new DatabaseException( "Invalid URL (" @@ -288,6 +294,11 @@ public synchronized void setPersistenceCacheSizeBytes(long cacheSizeInBytes) { this.config.setPersistenceCacheSizeBytes(cacheSizeInBytes); } + @Nullable + private static EmulatedServiceSettings getEmulatorServiceSettings(@NonNull FirebaseApp app) { + return app.getEmulatorSettings().getServiceSettings(EMULATOR); + } + /** @return The semver version for this build of the Firebase Database client */ @NonNull public static String getSdkVersion() { diff --git a/firebase-database/src/main/java/com/google/firebase/database/core/utilities/Utilities.java b/firebase-database/src/main/java/com/google/firebase/database/core/utilities/Utilities.java index 6267d328e22..1ddbd685824 100644 --- a/firebase-database/src/main/java/com/google/firebase/database/core/utilities/Utilities.java +++ b/firebase-database/src/main/java/com/google/firebase/database/core/utilities/Utilities.java @@ -16,6 +16,7 @@ import android.net.Uri; import android.util.Base64; +import androidx.annotation.NonNull; import androidx.annotation.Nullable; import com.google.android.gms.tasks.Task; import com.google.android.gms.tasks.TaskCompletionSource; @@ -24,6 +25,7 @@ import com.google.firebase.database.DatabaseReference; import com.google.firebase.database.core.Path; import com.google.firebase.database.core.RepoInfo; +import com.google.firebase.emulators.EmulatedServiceSettings; import java.io.UnsupportedEncodingException; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; @@ -32,7 +34,13 @@ public class Utilities { private static final char[] HEX_CHARACTERS = "0123456789abcdef".toCharArray(); - public static ParsedUrl parseUrl(String url) throws DatabaseException { + public static ParsedUrl parseUrl(@NonNull String url) { + return Utilities.parseUrl(url, null); + } + + public static ParsedUrl parseUrl( + @NonNull String url, @Nullable EmulatedServiceSettings emulatorSettings) + throws DatabaseException { try { Uri uri = Uri.parse(url); @@ -46,26 +54,29 @@ public static ParsedUrl parseUrl(String url) throws DatabaseException { throw new IllegalArgumentException("Database URL does not specify a valid host"); } - RepoInfo repoInfo = new RepoInfo(); - repoInfo.host = host.toLowerCase(); - - int port = uri.getPort(); - if (port != -1) { - repoInfo.secure = scheme.equals("https") || scheme.equals("wss"); - repoInfo.host += ":" + port; - } else { - repoInfo.secure = true; + String namespace = uri.getQueryParameter("ns"); + if (namespace == null) { + String[] parts = host.split("\\.", -1); + namespace = parts[0].toLowerCase(); } - String namespaceParam = uri.getQueryParameter("ns"); - if (namespaceParam != null) { - repoInfo.namespace = namespaceParam; + RepoInfo repoInfo = new RepoInfo(); + if (emulatorSettings != null) { + repoInfo.host = emulatorSettings.getHost() + ":" + emulatorSettings.getPort(); + repoInfo.secure = false; } else { - String[] parts = host.split("\\.", -1); - repoInfo.namespace = parts[0].toLowerCase(); + repoInfo.host = host.toLowerCase(); + int port = uri.getPort(); + if (port != -1) { + repoInfo.secure = scheme.equals("https") || scheme.equals("wss"); + repoInfo.host += ":" + port; + } else { + repoInfo.secure = true; + } } repoInfo.internalHost = repoInfo.host; + repoInfo.namespace = namespace; String originalPathString = extractPathString(url); // URLEncoding a space turns it into a '+', which is different diff --git a/firebase-database/src/test/java/com/google/firebase/database/core/utilities/ParseUrlTest.java b/firebase-database/src/test/java/com/google/firebase/database/core/utilities/ParseUrlTest.java index 9e902d0ebd5..4ac207f5566 100644 --- a/firebase-database/src/test/java/com/google/firebase/database/core/utilities/ParseUrlTest.java +++ b/firebase-database/src/test/java/com/google/firebase/database/core/utilities/ParseUrlTest.java @@ -19,6 +19,7 @@ import static org.junit.Assert.assertTrue; import com.google.firebase.database.DatabaseException; +import com.google.firebase.emulators.EmulatedServiceSettings; import org.junit.Test; import org.robolectric.RobolectricTestRunner; import org.robolectric.annotation.Config; @@ -35,7 +36,6 @@ public void testUrlParsing() throws DatabaseException { assertEquals("gsoltis.fblocal.com:9000", parsed.repoInfo.internalHost); parsed = Utilities.parseUrl("http://gsoltis.firebaseio.com/foo/bar"); - assertEquals("/foo/bar", parsed.path.toString()); assertEquals("gsoltis.firebaseio.com", parsed.repoInfo.host); assertEquals("gsoltis.firebaseio.com", parsed.repoInfo.internalHost); @@ -90,4 +90,14 @@ public void testUrlParsingSslDetection() throws DatabaseException { parsed = Utilities.parseUrl("http://gsoltis.firebaseio.com"); assertTrue(parsed.repoInfo.secure); } + + @Test + public void testUrlParsingWithEmulator() { + EmulatedServiceSettings serviceSettings = new EmulatedServiceSettings("10.0.2.2", 9000); + + ParsedUrl parsedUrl = Utilities.parseUrl("https://myns.firebaseio.com", serviceSettings); + assertFalse(parsedUrl.repoInfo.secure); + assertEquals(parsedUrl.repoInfo.host, "10.0.2.2:9000"); + assertEquals(parsedUrl.repoInfo.namespace, "myns"); + } }