From 428eedb9460563cffe9a6790d0feecb5fa0efcc0 Mon Sep 17 00:00:00 2001 From: Hiranya Jayathilaka Date: Wed, 5 Apr 2017 19:12:49 -0700 Subject: [PATCH 01/58] Initial code migration: * Reformatted all source files * Separated unit and integration tests * Added maven checkstyle plugin * Got the basic build and unit tests working * Scrubbed the codebase for private keys, certificates etc. --- .gitignore | 3 + pom.xml | 128 + .../java/com/google/firebase/FirebaseApp.java | 432 + .../FirebaseAppLifecycleListener.java | 15 + .../google/firebase/FirebaseException.java | 23 + .../com/google/firebase/FirebaseOptions.java | 214 + .../firebase/ImplFirebaseTrampolines.java | 54 + .../TestOnlyImplFirebaseTrampolines.java | 30 + .../google/firebase/auth/FirebaseAuth.java | 186 + .../firebase/auth/FirebaseAuthException.java | 33 + .../firebase/auth/FirebaseCredential.java | 21 + .../firebase/auth/FirebaseCredentials.java | 383 + .../google/firebase/auth/FirebaseToken.java | 193 + .../TestOnlyImplFirebaseAuthTrampolines.java | 56 + .../internal/FirebaseCustomAuthToken.java | 111 + .../auth/internal/FirebaseTokenFactory.java | 84 + .../auth/internal/FirebaseTokenVerifier.java | 216 + .../firebase/database/ChildEventListener.java | 59 + .../firebase/database/DataSnapshot.java | 294 + .../firebase/database/DatabaseError.java | 235 + .../firebase/database/DatabaseException.java | 29 + .../firebase/database/DatabaseReference.java | 603 ++ .../com/google/firebase/database/Exclude.java | 15 + .../firebase/database/FirebaseDatabase.java | 328 + .../database/GenericTypeIndicator.java | 47 + .../database/IgnoreExtraProperties.java | 16 + .../firebase/database/InternalHelpers.java | 46 + .../com/google/firebase/database/Logger.java | 19 + .../google/firebase/database/MutableData.java | 318 + .../firebase/database/OnDisconnect.java | 244 + .../firebase/database/PropertyName.java | 16 + .../com/google/firebase/database/Query.java | 663 ++ .../google/firebase/database/ServerValue.java | 24 + .../database/ThrowOnExtraProperties.java | 16 + .../google/firebase/database/Transaction.java | 103 + .../firebase/database/ValueEventListener.java | 28 + .../database/annotations/NotNull.java | 19 + .../database/annotations/Nullable.java | 18 + .../database/collection/ArraySortedMap.java | 272 + .../collection/ImmutableSortedMap.java | 154 + .../ImmutableSortedMapIterator.java | 92 + .../collection/ImmutableSortedSet.java | 111 + .../collection/LLRBBlackValueNode.java | 28 + .../database/collection/LLRBEmptyNode.java | 100 + .../database/collection/LLRBNode.java | 59 + .../database/collection/LLRBRedValueNode.java | 33 + .../database/collection/LLRBValueNode.java | 244 + .../database/collection/RBTreeSortedMap.java | 326 + .../collection/StandardComparator.java | 21 + .../database/connection/CompoundHash.java | 27 + .../database/connection/Connection.java | 253 + .../ConnectionAuthTokenProvider.java | 29 + .../connection/ConnectionContext.java | 52 + .../database/connection/ConnectionUtils.java | 57 + .../database/connection/Constants.java | 8 + .../database/connection/HostInfo.java | 47 + .../connection/ListenHashProvider.java | 10 + .../connection/PersistentConnection.java | 71 + .../connection/PersistentConnectionImpl.java | 1293 +++ .../database/connection/RangeMerge.java | 28 + .../connection/RequestResultCallback.java | 6 + .../connection/WebsocketConnection.java | 385 + .../database/connection/util/RetryHelper.java | 158 + .../connection/util/StringListReader.java | 174 + .../database/core/AuthTokenProvider.java | 62 + .../database/core/ChildEventRegistration.java | 112 + .../firebase/database/core/CompoundWrite.java | 287 + .../firebase/database/core/Constants.java | 16 + .../firebase/database/core/Context.java | 274 + .../database/core/DatabaseConfig.java | 163 + .../database/core/EventRegistration.java | 63 + .../core/EventRegistrationZombieListener.java | 6 + .../firebase/database/core/EventTarget.java | 21 + .../firebase/database/core/GaePlatform.java | 113 + .../database/core/JvmAuthTokenProvider.java | 115 + .../firebase/database/core/JvmPlatform.java | 78 + .../google/firebase/database/core/Path.java | 264 + .../firebase/database/core/Platform.java | 34 + .../google/firebase/database/core/Repo.java | 1367 +++ .../firebase/database/core/RepoInfo.java | 96 + .../firebase/database/core/RepoManager.java | 139 + .../firebase/database/core/RunLoop.java | 32 + .../firebase/database/core/ServerValues.java | 104 + .../database/core/SnapshotHolder.java | 32 + .../database/core/SparseSnapshotTree.java | 124 + .../firebase/database/core/SyncPoint.java | 251 + .../firebase/database/core/SyncTree.java | 1006 ++ .../google/firebase/database/core/Tag.java | 42 + .../core/ThreadBackgroundExecutor.java | 6 + .../database/core/ThreadInitializer.java | 30 + .../database/core/ThreadPoolEventTarget.java | 71 + .../database/core/UserWriteRecord.java | 120 + .../database/core/ValidationPath.java | 145 + .../database/core/ValueEventRegistration.java | 96 + .../firebase/database/core/WriteTree.java | 460 + .../firebase/database/core/WriteTreeRef.java | 118 + .../database/core/ZombieEventManager.java | 143 + .../database/core/operation/AckUserWrite.java | 53 + .../core/operation/ListenComplete.java | 26 + .../database/core/operation/Merge.java | 46 + .../database/core/operation/Operation.java | 39 + .../core/operation/OperationSource.java | 57 + .../database/core/operation/Overwrite.java | 35 + .../core/persistence/CachePolicy.java | 35 + .../DefaultPersistenceManager.java | 258 + .../core/persistence/LRUCachePolicy.java | 36 + .../persistence/NoopPersistenceManager.java | 120 + .../core/persistence/PersistenceManager.java | 105 + .../persistence/PersistenceStorageEngine.java | 115 + .../core/persistence/PruneForest.java | 197 + .../core/persistence/TrackedQuery.java | 80 + .../core/persistence/TrackedQueryManager.java | 404 + .../core/utilities/ImmutableTree.java | 343 + .../database/core/utilities/Predicate.java | 14 + .../database/core/utilities/Tree.java | 181 + .../database/core/utilities/TreeNode.java | 30 + .../database/core/view/CacheNode.java | 62 + .../database/core/view/CancelEvent.java | 33 + .../firebase/database/core/view/Change.java | 94 + .../database/core/view/DataEvent.java | 67 + .../firebase/database/core/view/Event.java | 23 + .../database/core/view/EventGenerator.java | 98 + .../database/core/view/EventRaiser.java | 46 + .../database/core/view/QueryParams.java | 360 + .../database/core/view/QuerySpec.java | 77 + .../firebase/database/core/view/View.java | 196 + .../database/core/view/ViewCache.java | 39 + .../database/core/view/ViewProcessor.java | 711 ++ .../view/filter/ChildChangeAccumulator.java | 60 + .../core/view/filter/IndexedFilter.java | 121 + .../core/view/filter/LimitedFilter.java | 192 + .../database/core/view/filter/NodeFilter.java | 70 + .../core/view/filter/RangedFilter.java | 119 + .../database/logging/DefaultLogger.java | 76 + .../firebase/database/logging/LogWrapper.java | 84 + .../firebase/database/logging/Logger.java | 27 + .../database/snapshot/BooleanNode.java | 50 + .../firebase/database/snapshot/ChildKey.java | 132 + .../database/snapshot/ChildrenNode.java | 424 + .../database/snapshot/CompoundHash.java | 226 + .../database/snapshot/DeferredValueNode.java | 55 + .../database/snapshot/DoubleNode.java | 60 + .../firebase/database/snapshot/EmptyNode.java | 150 + .../firebase/database/snapshot/Index.java | 46 + .../database/snapshot/IndexedNode.java | 165 + .../firebase/database/snapshot/KeyIndex.java | 57 + .../firebase/database/snapshot/LeafNode.java | 205 + .../firebase/database/snapshot/LongNode.java | 59 + .../firebase/database/snapshot/NamedNode.java | 64 + .../firebase/database/snapshot/Node.java | 94 + .../database/snapshot/NodeUtilities.java | 109 + .../firebase/database/snapshot/PathIndex.java | 73 + .../database/snapshot/PriorityIndex.java | 57 + .../database/snapshot/PriorityUtilities.java | 35 + .../database/snapshot/RangeMerge.java | 118 + .../database/snapshot/StringNode.java | 63 + .../database/snapshot/ValueIndex.java | 60 + .../tubesock/MessageBuilderFactory.java | 192 + .../database/tubesock/ThreadInitializer.java | 6 + .../firebase/database/tubesock/WebSocket.java | 402 + .../tubesock/WebSocketEventHandler.java | 15 + .../database/tubesock/WebSocketException.java | 14 + .../database/tubesock/WebSocketHandshake.java | 112 + .../database/tubesock/WebSocketMessage.java | 34 + .../database/tubesock/WebSocketReceiver.java | 159 + .../database/tubesock/WebSocketWriter.java | 152 + .../database/util/AndroidSupport.java | 19 + .../firebase/database/util/GAuthToken.java | 67 + .../firebase/database/util/JsonMapper.java | 121 + .../firebase/database/utilities/Clock.java | 7 + .../database/utilities/DefaultClock.java | 9 + .../database/utilities/DefaultRunLoop.java | 107 + .../database/utilities/NodeSizeEstimator.java | 79 + .../database/utilities/OffsetClock.java | 21 + .../firebase/database/utilities/Pair.java | 54 + .../database/utilities/ParsedUrl.java | 13 + .../database/utilities/PushIdGenerator.java | 56 + .../database/utilities/Utilities.java | 244 + .../database/utilities/Validation.java | 150 + .../utilities/encoding/CustomClassMapper.java | 791 ++ .../utilities/tuple/NameAndPriority.java | 33 + .../database/utilities/tuple/NodeAndPath.java | 34 + .../database/utilities/tuple/PathAndId.java | 25 + .../firebase/internal/AuthStateListener.java | 13 + .../com/google/firebase/internal/Base64.java | 750 ++ .../google/firebase/internal/Base64Utils.java | 104 + .../firebase/internal/FirebaseAppStore.java | 69 + .../firebase/internal/FirebaseExecutors.java | 22 + .../internal/GaeScheduledExecutorService.java | 185 + .../firebase/internal/GaeThreadFactory.java | 150 + .../firebase/internal/GetTokenResult.java | 34 + .../google/firebase/internal/GuardedBy.java | 11 + .../com/google/firebase/internal/Joiner.java | 48 + .../com/google/firebase/internal/Log.java | 50 + .../com/google/firebase/internal/NonNull.java | 9 + .../google/firebase/internal/Nullable.java | 9 + .../com/google/firebase/internal/Objects.java | 134 + .../firebase/internal/Preconditions.java | 400 + .../internal/RevivingScheduledExecutor.java | 198 + .../internal/SharedPrefsFirebaseAppStore.java | 217 + .../google/firebase/tasks/Continuation.java | 61 + .../tasks/ContinueWithCompletionListener.java | 54 + .../ContinueWithTaskCompletionListener.java | 72 + .../tasks/OnCompleteCompletionListener.java | 49 + .../firebase/tasks/OnCompleteListener.java | 19 + .../tasks/OnFailureCompletionListener.java | 51 + .../firebase/tasks/OnFailureListener.java | 18 + .../tasks/OnSuccessCompletionListener.java | 51 + .../firebase/tasks/OnSuccessListener.java | 17 + .../tasks/RuntimeExecutionException.java | 13 + .../java/com/google/firebase/tasks/Task.java | 190 + .../tasks/TaskCompletionListener.java | 15 + .../tasks/TaskCompletionListenerQueue.java | 69 + .../firebase/tasks/TaskCompletionSource.java | 58 + .../google/firebase/tasks/TaskExecutors.java | 39 + .../com/google/firebase/tasks/TaskImpl.java | 230 + .../java/com/google/firebase/tasks/Tasks.java | 254 + .../com/google/firebase/FirebaseAppTest.java | 390 + .../google/firebase/FirebaseOptionsTest.java | 129 + .../firebase/auth/FirebaseAuthTest.java | 257 + .../auth/FirebaseCredentialsTest.java | 321 + .../internal/FirebaseTokenFactoryTest.java | 121 + .../internal/FirebaseTokenVerifierTest.java | 263 + .../google/firebase/database/AuthTestIT.java | 153 + .../database/ConnectionLoadTestIT.java | 257 + .../firebase/database/DataSnapshotTestIT.java | 77 + .../google/firebase/database/DataTestIT.java | 3155 +++++++ .../google/firebase/database/DeepEquals.java | 449 + .../google/firebase/database/EventHelper.java | 244 + .../google/firebase/database/EventRecord.java | 36 + .../google/firebase/database/EventTestIT.java | 964 ++ .../database/FirebaseDatabaseTestIT.java | 568 ++ .../google/firebase/database/InfoTestIT.java | 180 + .../google/firebase/database/ListBuilder.java | 21 + .../google/firebase/database/MapBuilder.java | 21 + .../google/firebase/database/MapperTest.java | 2068 +++++ .../firebase/database/MutableDataTest.java | 211 + .../firebase/database/ObjectMapTest.java | 307 + .../firebase/database/OrderByTestIT.java | 1004 ++ .../google/firebase/database/OrderTestIT.java | 850 ++ .../database/PerformanceBenchmarks.java | 263 + .../google/firebase/database/QueryTestIT.java | 3539 +++++++ .../firebase/database/RealtimeTestIT.java | 1059 +++ .../google/firebase/database/RulesTestIT.java | 444 + .../database/TestChildEventListener.java | 31 + .../firebase/database/TestConstants.java | 18 + .../google/firebase/database/TestFailure.java | 15 + .../google/firebase/database/TestHelpers.java | 476 + .../firebase/database/TestTokenProvider.java | 68 + .../firebase/database/TransactionTestIT.java | 2056 +++++ .../firebase/database/UtilitiesTest.java | 48 + .../database/ValueExpectationHelper.java | 63 + .../collection/ArraySortedMapTest.java | 384 + .../collection/RBTreeSortedMapTest.java | 291 + .../database/connection/ConnectionTestIT.java | 58 + .../database/connection/ListenAggregator.java | 67 + .../database/core/CompoundWriteTest.java | 596 ++ .../database/core/CoreTestHelpers.java | 14 + .../database/core/JvmPlatformTest.java | 40 + .../firebase/database/core/PathTest.java | 53 + .../core/RandomOperationGenerator.java | 523 ++ .../core/RandomViewProcessorTest.java | 149 + .../database/core/RangeMergeTest.java | 291 + .../firebase/database/core/RepoInfoTest.java | 23 + .../firebase/database/core/SyncPointTest.java | 1087 +++ .../database/core/SynchronousConnection.java | 231 + .../database/core/ZombieVerifier.java | 136 + .../DefaultPersistenceManagerTest.java | 106 + .../core/persistence/KeepSyncedTestIT.java | 238 + .../core/persistence/MockListenProvider.java | 31 + .../MockPersistenceStorageEngine.java | 287 + .../core/persistence/PersistenceTestIT.java | 977 ++ .../core/persistence/PruneForestTest.java | 135 + .../persistence/RandomPersistenceTest.java | 270 + .../core/persistence/TestCachePolicy.java | 42 + .../persistence/TrackedQueryManagerTest.java | 342 + .../core/utilities/ChildKeyGenerator.java | 79 + .../database/core/utilities/TestClock.java | 18 + .../database/core/utilities/TreeTest.java | 37 + .../database/core/view/QueryParamsTest.java | 32 + .../database/core/view/ViewAccess.java | 12 + .../firebase/database/future/ReadFuture.java | 205 + .../firebase/database/future/WriteFuture.java | 111 + .../database/integration/ShutdownExample.java | 59 + .../database/snapshot/CompoundHashTest.java | 184 + .../CompoundHashingIntegrationTestIT.java | 190 + .../firebase/database/snapshot/NodeTest.java | 167 + .../firebase/database/tubesock/Autobahn.java | 60 + .../database/tubesock/FirebaseClient.java | 62 + .../database/tubesock/TestClient.java | 74 + .../database/tubesock/UpdateClient.java | 60 + .../database/util/GAuthTokenTest.java | 44 + .../database/util/JsonMapperTest.java | 50 + .../integration/DatabaseServerAuthTestIT.java | 305 + .../internal/FirebaseAppStoreTest.java | 87 + .../RevivingScheduledExecutorTest.java | 227 + .../OnCompleteCompletionListenerTest.java | 32 + .../OnFailureCompletionListenerTest.java | 45 + .../OnSuccessCompletionListenerTest.java | 45 + .../tasks/TaskCompletionSourceTest.java | 87 + .../firebase/tasks/TaskExecutorsTest.java | 39 + .../google/firebase/tasks/TaskImplTest.java | 584 ++ .../com/google/firebase/tasks/TasksTest.java | 290 + .../tasks/testing/TestOnCompleteListener.java | 50 + .../tasks/testing/TestOnFailureListener.java | 49 + .../tasks/testing/TestOnSuccessListener.java | 48 + .../firebase/testing/FirebaseAppRule.java | 33 + .../firebase/testing/MockitoTestRule.java | 29 + .../firebase/testing/ServiceAccount.java | 84 + .../google/firebase/testing/TestUtils.java | 71 + .../resources/service_accounts/editor.json | 12 + .../service_accounts/editor_public_key.pem | 24 + src/test/resources/service_accounts/none.json | 12 + .../service_accounts/none_public_key.pem | 20 + .../resources/service_accounts/owner.json | 12 + .../service_accounts/owner_public_key.pem | 19 + .../resources/service_accounts/viewer.json | 12 + .../service_accounts/viewer_public_key.pem | 19 + src/test/resources/syncPointSpec.json | 8203 +++++++++++++++++ 319 files changed, 67339 insertions(+) create mode 100644 .gitignore create mode 100644 pom.xml create mode 100644 src/main/java/com/google/firebase/FirebaseApp.java create mode 100644 src/main/java/com/google/firebase/FirebaseAppLifecycleListener.java create mode 100644 src/main/java/com/google/firebase/FirebaseException.java create mode 100644 src/main/java/com/google/firebase/FirebaseOptions.java create mode 100644 src/main/java/com/google/firebase/ImplFirebaseTrampolines.java create mode 100644 src/main/java/com/google/firebase/TestOnlyImplFirebaseTrampolines.java create mode 100644 src/main/java/com/google/firebase/auth/FirebaseAuth.java create mode 100644 src/main/java/com/google/firebase/auth/FirebaseAuthException.java create mode 100644 src/main/java/com/google/firebase/auth/FirebaseCredential.java create mode 100644 src/main/java/com/google/firebase/auth/FirebaseCredentials.java create mode 100644 src/main/java/com/google/firebase/auth/FirebaseToken.java create mode 100644 src/main/java/com/google/firebase/auth/TestOnlyImplFirebaseAuthTrampolines.java create mode 100644 src/main/java/com/google/firebase/auth/internal/FirebaseCustomAuthToken.java create mode 100644 src/main/java/com/google/firebase/auth/internal/FirebaseTokenFactory.java create mode 100644 src/main/java/com/google/firebase/auth/internal/FirebaseTokenVerifier.java create mode 100644 src/main/java/com/google/firebase/database/ChildEventListener.java create mode 100644 src/main/java/com/google/firebase/database/DataSnapshot.java create mode 100644 src/main/java/com/google/firebase/database/DatabaseError.java create mode 100644 src/main/java/com/google/firebase/database/DatabaseException.java create mode 100644 src/main/java/com/google/firebase/database/DatabaseReference.java create mode 100644 src/main/java/com/google/firebase/database/Exclude.java create mode 100644 src/main/java/com/google/firebase/database/FirebaseDatabase.java create mode 100644 src/main/java/com/google/firebase/database/GenericTypeIndicator.java create mode 100644 src/main/java/com/google/firebase/database/IgnoreExtraProperties.java create mode 100644 src/main/java/com/google/firebase/database/InternalHelpers.java create mode 100644 src/main/java/com/google/firebase/database/Logger.java create mode 100644 src/main/java/com/google/firebase/database/MutableData.java create mode 100644 src/main/java/com/google/firebase/database/OnDisconnect.java create mode 100644 src/main/java/com/google/firebase/database/PropertyName.java create mode 100644 src/main/java/com/google/firebase/database/Query.java create mode 100644 src/main/java/com/google/firebase/database/ServerValue.java create mode 100644 src/main/java/com/google/firebase/database/ThrowOnExtraProperties.java create mode 100644 src/main/java/com/google/firebase/database/Transaction.java create mode 100644 src/main/java/com/google/firebase/database/ValueEventListener.java create mode 100644 src/main/java/com/google/firebase/database/annotations/NotNull.java create mode 100644 src/main/java/com/google/firebase/database/annotations/Nullable.java create mode 100644 src/main/java/com/google/firebase/database/collection/ArraySortedMap.java create mode 100644 src/main/java/com/google/firebase/database/collection/ImmutableSortedMap.java create mode 100644 src/main/java/com/google/firebase/database/collection/ImmutableSortedMapIterator.java create mode 100644 src/main/java/com/google/firebase/database/collection/ImmutableSortedSet.java create mode 100644 src/main/java/com/google/firebase/database/collection/LLRBBlackValueNode.java create mode 100644 src/main/java/com/google/firebase/database/collection/LLRBEmptyNode.java create mode 100644 src/main/java/com/google/firebase/database/collection/LLRBNode.java create mode 100644 src/main/java/com/google/firebase/database/collection/LLRBRedValueNode.java create mode 100644 src/main/java/com/google/firebase/database/collection/LLRBValueNode.java create mode 100644 src/main/java/com/google/firebase/database/collection/RBTreeSortedMap.java create mode 100644 src/main/java/com/google/firebase/database/collection/StandardComparator.java create mode 100644 src/main/java/com/google/firebase/database/connection/CompoundHash.java create mode 100644 src/main/java/com/google/firebase/database/connection/Connection.java create mode 100644 src/main/java/com/google/firebase/database/connection/ConnectionAuthTokenProvider.java create mode 100644 src/main/java/com/google/firebase/database/connection/ConnectionContext.java create mode 100644 src/main/java/com/google/firebase/database/connection/ConnectionUtils.java create mode 100644 src/main/java/com/google/firebase/database/connection/Constants.java create mode 100644 src/main/java/com/google/firebase/database/connection/HostInfo.java create mode 100644 src/main/java/com/google/firebase/database/connection/ListenHashProvider.java create mode 100644 src/main/java/com/google/firebase/database/connection/PersistentConnection.java create mode 100644 src/main/java/com/google/firebase/database/connection/PersistentConnectionImpl.java create mode 100644 src/main/java/com/google/firebase/database/connection/RangeMerge.java create mode 100644 src/main/java/com/google/firebase/database/connection/RequestResultCallback.java create mode 100644 src/main/java/com/google/firebase/database/connection/WebsocketConnection.java create mode 100644 src/main/java/com/google/firebase/database/connection/util/RetryHelper.java create mode 100644 src/main/java/com/google/firebase/database/connection/util/StringListReader.java create mode 100644 src/main/java/com/google/firebase/database/core/AuthTokenProvider.java create mode 100644 src/main/java/com/google/firebase/database/core/ChildEventRegistration.java create mode 100644 src/main/java/com/google/firebase/database/core/CompoundWrite.java create mode 100644 src/main/java/com/google/firebase/database/core/Constants.java create mode 100644 src/main/java/com/google/firebase/database/core/Context.java create mode 100644 src/main/java/com/google/firebase/database/core/DatabaseConfig.java create mode 100644 src/main/java/com/google/firebase/database/core/EventRegistration.java create mode 100644 src/main/java/com/google/firebase/database/core/EventRegistrationZombieListener.java create mode 100644 src/main/java/com/google/firebase/database/core/EventTarget.java create mode 100644 src/main/java/com/google/firebase/database/core/GaePlatform.java create mode 100644 src/main/java/com/google/firebase/database/core/JvmAuthTokenProvider.java create mode 100644 src/main/java/com/google/firebase/database/core/JvmPlatform.java create mode 100644 src/main/java/com/google/firebase/database/core/Path.java create mode 100644 src/main/java/com/google/firebase/database/core/Platform.java create mode 100644 src/main/java/com/google/firebase/database/core/Repo.java create mode 100644 src/main/java/com/google/firebase/database/core/RepoInfo.java create mode 100644 src/main/java/com/google/firebase/database/core/RepoManager.java create mode 100644 src/main/java/com/google/firebase/database/core/RunLoop.java create mode 100644 src/main/java/com/google/firebase/database/core/ServerValues.java create mode 100644 src/main/java/com/google/firebase/database/core/SnapshotHolder.java create mode 100644 src/main/java/com/google/firebase/database/core/SparseSnapshotTree.java create mode 100644 src/main/java/com/google/firebase/database/core/SyncPoint.java create mode 100644 src/main/java/com/google/firebase/database/core/SyncTree.java create mode 100644 src/main/java/com/google/firebase/database/core/Tag.java create mode 100644 src/main/java/com/google/firebase/database/core/ThreadBackgroundExecutor.java create mode 100644 src/main/java/com/google/firebase/database/core/ThreadInitializer.java create mode 100644 src/main/java/com/google/firebase/database/core/ThreadPoolEventTarget.java create mode 100644 src/main/java/com/google/firebase/database/core/UserWriteRecord.java create mode 100644 src/main/java/com/google/firebase/database/core/ValidationPath.java create mode 100644 src/main/java/com/google/firebase/database/core/ValueEventRegistration.java create mode 100644 src/main/java/com/google/firebase/database/core/WriteTree.java create mode 100644 src/main/java/com/google/firebase/database/core/WriteTreeRef.java create mode 100644 src/main/java/com/google/firebase/database/core/ZombieEventManager.java create mode 100644 src/main/java/com/google/firebase/database/core/operation/AckUserWrite.java create mode 100644 src/main/java/com/google/firebase/database/core/operation/ListenComplete.java create mode 100644 src/main/java/com/google/firebase/database/core/operation/Merge.java create mode 100644 src/main/java/com/google/firebase/database/core/operation/Operation.java create mode 100644 src/main/java/com/google/firebase/database/core/operation/OperationSource.java create mode 100644 src/main/java/com/google/firebase/database/core/operation/Overwrite.java create mode 100644 src/main/java/com/google/firebase/database/core/persistence/CachePolicy.java create mode 100644 src/main/java/com/google/firebase/database/core/persistence/DefaultPersistenceManager.java create mode 100644 src/main/java/com/google/firebase/database/core/persistence/LRUCachePolicy.java create mode 100644 src/main/java/com/google/firebase/database/core/persistence/NoopPersistenceManager.java create mode 100644 src/main/java/com/google/firebase/database/core/persistence/PersistenceManager.java create mode 100644 src/main/java/com/google/firebase/database/core/persistence/PersistenceStorageEngine.java create mode 100644 src/main/java/com/google/firebase/database/core/persistence/PruneForest.java create mode 100644 src/main/java/com/google/firebase/database/core/persistence/TrackedQuery.java create mode 100644 src/main/java/com/google/firebase/database/core/persistence/TrackedQueryManager.java create mode 100644 src/main/java/com/google/firebase/database/core/utilities/ImmutableTree.java create mode 100644 src/main/java/com/google/firebase/database/core/utilities/Predicate.java create mode 100644 src/main/java/com/google/firebase/database/core/utilities/Tree.java create mode 100644 src/main/java/com/google/firebase/database/core/utilities/TreeNode.java create mode 100644 src/main/java/com/google/firebase/database/core/view/CacheNode.java create mode 100644 src/main/java/com/google/firebase/database/core/view/CancelEvent.java create mode 100644 src/main/java/com/google/firebase/database/core/view/Change.java create mode 100644 src/main/java/com/google/firebase/database/core/view/DataEvent.java create mode 100644 src/main/java/com/google/firebase/database/core/view/Event.java create mode 100644 src/main/java/com/google/firebase/database/core/view/EventGenerator.java create mode 100644 src/main/java/com/google/firebase/database/core/view/EventRaiser.java create mode 100644 src/main/java/com/google/firebase/database/core/view/QueryParams.java create mode 100644 src/main/java/com/google/firebase/database/core/view/QuerySpec.java create mode 100644 src/main/java/com/google/firebase/database/core/view/View.java create mode 100644 src/main/java/com/google/firebase/database/core/view/ViewCache.java create mode 100644 src/main/java/com/google/firebase/database/core/view/ViewProcessor.java create mode 100644 src/main/java/com/google/firebase/database/core/view/filter/ChildChangeAccumulator.java create mode 100644 src/main/java/com/google/firebase/database/core/view/filter/IndexedFilter.java create mode 100644 src/main/java/com/google/firebase/database/core/view/filter/LimitedFilter.java create mode 100644 src/main/java/com/google/firebase/database/core/view/filter/NodeFilter.java create mode 100644 src/main/java/com/google/firebase/database/core/view/filter/RangedFilter.java create mode 100644 src/main/java/com/google/firebase/database/logging/DefaultLogger.java create mode 100644 src/main/java/com/google/firebase/database/logging/LogWrapper.java create mode 100644 src/main/java/com/google/firebase/database/logging/Logger.java create mode 100644 src/main/java/com/google/firebase/database/snapshot/BooleanNode.java create mode 100644 src/main/java/com/google/firebase/database/snapshot/ChildKey.java create mode 100644 src/main/java/com/google/firebase/database/snapshot/ChildrenNode.java create mode 100644 src/main/java/com/google/firebase/database/snapshot/CompoundHash.java create mode 100644 src/main/java/com/google/firebase/database/snapshot/DeferredValueNode.java create mode 100644 src/main/java/com/google/firebase/database/snapshot/DoubleNode.java create mode 100644 src/main/java/com/google/firebase/database/snapshot/EmptyNode.java create mode 100644 src/main/java/com/google/firebase/database/snapshot/Index.java create mode 100644 src/main/java/com/google/firebase/database/snapshot/IndexedNode.java create mode 100644 src/main/java/com/google/firebase/database/snapshot/KeyIndex.java create mode 100644 src/main/java/com/google/firebase/database/snapshot/LeafNode.java create mode 100644 src/main/java/com/google/firebase/database/snapshot/LongNode.java create mode 100644 src/main/java/com/google/firebase/database/snapshot/NamedNode.java create mode 100644 src/main/java/com/google/firebase/database/snapshot/Node.java create mode 100644 src/main/java/com/google/firebase/database/snapshot/NodeUtilities.java create mode 100644 src/main/java/com/google/firebase/database/snapshot/PathIndex.java create mode 100644 src/main/java/com/google/firebase/database/snapshot/PriorityIndex.java create mode 100644 src/main/java/com/google/firebase/database/snapshot/PriorityUtilities.java create mode 100644 src/main/java/com/google/firebase/database/snapshot/RangeMerge.java create mode 100644 src/main/java/com/google/firebase/database/snapshot/StringNode.java create mode 100644 src/main/java/com/google/firebase/database/snapshot/ValueIndex.java create mode 100644 src/main/java/com/google/firebase/database/tubesock/MessageBuilderFactory.java create mode 100644 src/main/java/com/google/firebase/database/tubesock/ThreadInitializer.java create mode 100644 src/main/java/com/google/firebase/database/tubesock/WebSocket.java create mode 100644 src/main/java/com/google/firebase/database/tubesock/WebSocketEventHandler.java create mode 100644 src/main/java/com/google/firebase/database/tubesock/WebSocketException.java create mode 100644 src/main/java/com/google/firebase/database/tubesock/WebSocketHandshake.java create mode 100644 src/main/java/com/google/firebase/database/tubesock/WebSocketMessage.java create mode 100644 src/main/java/com/google/firebase/database/tubesock/WebSocketReceiver.java create mode 100644 src/main/java/com/google/firebase/database/tubesock/WebSocketWriter.java create mode 100644 src/main/java/com/google/firebase/database/util/AndroidSupport.java create mode 100644 src/main/java/com/google/firebase/database/util/GAuthToken.java create mode 100644 src/main/java/com/google/firebase/database/util/JsonMapper.java create mode 100644 src/main/java/com/google/firebase/database/utilities/Clock.java create mode 100644 src/main/java/com/google/firebase/database/utilities/DefaultClock.java create mode 100644 src/main/java/com/google/firebase/database/utilities/DefaultRunLoop.java create mode 100644 src/main/java/com/google/firebase/database/utilities/NodeSizeEstimator.java create mode 100644 src/main/java/com/google/firebase/database/utilities/OffsetClock.java create mode 100644 src/main/java/com/google/firebase/database/utilities/Pair.java create mode 100644 src/main/java/com/google/firebase/database/utilities/ParsedUrl.java create mode 100644 src/main/java/com/google/firebase/database/utilities/PushIdGenerator.java create mode 100644 src/main/java/com/google/firebase/database/utilities/Utilities.java create mode 100644 src/main/java/com/google/firebase/database/utilities/Validation.java create mode 100644 src/main/java/com/google/firebase/database/utilities/encoding/CustomClassMapper.java create mode 100644 src/main/java/com/google/firebase/database/utilities/tuple/NameAndPriority.java create mode 100644 src/main/java/com/google/firebase/database/utilities/tuple/NodeAndPath.java create mode 100644 src/main/java/com/google/firebase/database/utilities/tuple/PathAndId.java create mode 100644 src/main/java/com/google/firebase/internal/AuthStateListener.java create mode 100644 src/main/java/com/google/firebase/internal/Base64.java create mode 100644 src/main/java/com/google/firebase/internal/Base64Utils.java create mode 100644 src/main/java/com/google/firebase/internal/FirebaseAppStore.java create mode 100644 src/main/java/com/google/firebase/internal/FirebaseExecutors.java create mode 100644 src/main/java/com/google/firebase/internal/GaeScheduledExecutorService.java create mode 100644 src/main/java/com/google/firebase/internal/GaeThreadFactory.java create mode 100644 src/main/java/com/google/firebase/internal/GetTokenResult.java create mode 100644 src/main/java/com/google/firebase/internal/GuardedBy.java create mode 100644 src/main/java/com/google/firebase/internal/Joiner.java create mode 100644 src/main/java/com/google/firebase/internal/Log.java create mode 100644 src/main/java/com/google/firebase/internal/NonNull.java create mode 100644 src/main/java/com/google/firebase/internal/Nullable.java create mode 100644 src/main/java/com/google/firebase/internal/Objects.java create mode 100644 src/main/java/com/google/firebase/internal/Preconditions.java create mode 100644 src/main/java/com/google/firebase/internal/RevivingScheduledExecutor.java create mode 100644 src/main/java/com/google/firebase/internal/SharedPrefsFirebaseAppStore.java create mode 100644 src/main/java/com/google/firebase/tasks/Continuation.java create mode 100644 src/main/java/com/google/firebase/tasks/ContinueWithCompletionListener.java create mode 100644 src/main/java/com/google/firebase/tasks/ContinueWithTaskCompletionListener.java create mode 100644 src/main/java/com/google/firebase/tasks/OnCompleteCompletionListener.java create mode 100644 src/main/java/com/google/firebase/tasks/OnCompleteListener.java create mode 100644 src/main/java/com/google/firebase/tasks/OnFailureCompletionListener.java create mode 100644 src/main/java/com/google/firebase/tasks/OnFailureListener.java create mode 100644 src/main/java/com/google/firebase/tasks/OnSuccessCompletionListener.java create mode 100644 src/main/java/com/google/firebase/tasks/OnSuccessListener.java create mode 100644 src/main/java/com/google/firebase/tasks/RuntimeExecutionException.java create mode 100644 src/main/java/com/google/firebase/tasks/Task.java create mode 100644 src/main/java/com/google/firebase/tasks/TaskCompletionListener.java create mode 100644 src/main/java/com/google/firebase/tasks/TaskCompletionListenerQueue.java create mode 100644 src/main/java/com/google/firebase/tasks/TaskCompletionSource.java create mode 100644 src/main/java/com/google/firebase/tasks/TaskExecutors.java create mode 100644 src/main/java/com/google/firebase/tasks/TaskImpl.java create mode 100644 src/main/java/com/google/firebase/tasks/Tasks.java create mode 100644 src/test/java/com/google/firebase/FirebaseAppTest.java create mode 100644 src/test/java/com/google/firebase/FirebaseOptionsTest.java create mode 100644 src/test/java/com/google/firebase/auth/FirebaseAuthTest.java create mode 100644 src/test/java/com/google/firebase/auth/FirebaseCredentialsTest.java create mode 100644 src/test/java/com/google/firebase/auth/internal/FirebaseTokenFactoryTest.java create mode 100644 src/test/java/com/google/firebase/auth/internal/FirebaseTokenVerifierTest.java create mode 100644 src/test/java/com/google/firebase/database/AuthTestIT.java create mode 100644 src/test/java/com/google/firebase/database/ConnectionLoadTestIT.java create mode 100644 src/test/java/com/google/firebase/database/DataSnapshotTestIT.java create mode 100644 src/test/java/com/google/firebase/database/DataTestIT.java create mode 100644 src/test/java/com/google/firebase/database/DeepEquals.java create mode 100644 src/test/java/com/google/firebase/database/EventHelper.java create mode 100644 src/test/java/com/google/firebase/database/EventRecord.java create mode 100644 src/test/java/com/google/firebase/database/EventTestIT.java create mode 100644 src/test/java/com/google/firebase/database/FirebaseDatabaseTestIT.java create mode 100644 src/test/java/com/google/firebase/database/InfoTestIT.java create mode 100644 src/test/java/com/google/firebase/database/ListBuilder.java create mode 100644 src/test/java/com/google/firebase/database/MapBuilder.java create mode 100644 src/test/java/com/google/firebase/database/MapperTest.java create mode 100644 src/test/java/com/google/firebase/database/MutableDataTest.java create mode 100644 src/test/java/com/google/firebase/database/ObjectMapTest.java create mode 100644 src/test/java/com/google/firebase/database/OrderByTestIT.java create mode 100644 src/test/java/com/google/firebase/database/OrderTestIT.java create mode 100644 src/test/java/com/google/firebase/database/PerformanceBenchmarks.java create mode 100644 src/test/java/com/google/firebase/database/QueryTestIT.java create mode 100644 src/test/java/com/google/firebase/database/RealtimeTestIT.java create mode 100644 src/test/java/com/google/firebase/database/RulesTestIT.java create mode 100644 src/test/java/com/google/firebase/database/TestChildEventListener.java create mode 100644 src/test/java/com/google/firebase/database/TestConstants.java create mode 100644 src/test/java/com/google/firebase/database/TestFailure.java create mode 100644 src/test/java/com/google/firebase/database/TestHelpers.java create mode 100644 src/test/java/com/google/firebase/database/TestTokenProvider.java create mode 100644 src/test/java/com/google/firebase/database/TransactionTestIT.java create mode 100644 src/test/java/com/google/firebase/database/UtilitiesTest.java create mode 100644 src/test/java/com/google/firebase/database/ValueExpectationHelper.java create mode 100644 src/test/java/com/google/firebase/database/collection/ArraySortedMapTest.java create mode 100644 src/test/java/com/google/firebase/database/collection/RBTreeSortedMapTest.java create mode 100644 src/test/java/com/google/firebase/database/connection/ConnectionTestIT.java create mode 100644 src/test/java/com/google/firebase/database/connection/ListenAggregator.java create mode 100644 src/test/java/com/google/firebase/database/core/CompoundWriteTest.java create mode 100644 src/test/java/com/google/firebase/database/core/CoreTestHelpers.java create mode 100644 src/test/java/com/google/firebase/database/core/JvmPlatformTest.java create mode 100644 src/test/java/com/google/firebase/database/core/PathTest.java create mode 100644 src/test/java/com/google/firebase/database/core/RandomOperationGenerator.java create mode 100644 src/test/java/com/google/firebase/database/core/RandomViewProcessorTest.java create mode 100644 src/test/java/com/google/firebase/database/core/RangeMergeTest.java create mode 100644 src/test/java/com/google/firebase/database/core/RepoInfoTest.java create mode 100644 src/test/java/com/google/firebase/database/core/SyncPointTest.java create mode 100644 src/test/java/com/google/firebase/database/core/SynchronousConnection.java create mode 100644 src/test/java/com/google/firebase/database/core/ZombieVerifier.java create mode 100644 src/test/java/com/google/firebase/database/core/persistence/DefaultPersistenceManagerTest.java create mode 100644 src/test/java/com/google/firebase/database/core/persistence/KeepSyncedTestIT.java create mode 100644 src/test/java/com/google/firebase/database/core/persistence/MockListenProvider.java create mode 100644 src/test/java/com/google/firebase/database/core/persistence/MockPersistenceStorageEngine.java create mode 100644 src/test/java/com/google/firebase/database/core/persistence/PersistenceTestIT.java create mode 100644 src/test/java/com/google/firebase/database/core/persistence/PruneForestTest.java create mode 100644 src/test/java/com/google/firebase/database/core/persistence/RandomPersistenceTest.java create mode 100644 src/test/java/com/google/firebase/database/core/persistence/TestCachePolicy.java create mode 100644 src/test/java/com/google/firebase/database/core/persistence/TrackedQueryManagerTest.java create mode 100644 src/test/java/com/google/firebase/database/core/utilities/ChildKeyGenerator.java create mode 100644 src/test/java/com/google/firebase/database/core/utilities/TestClock.java create mode 100644 src/test/java/com/google/firebase/database/core/utilities/TreeTest.java create mode 100644 src/test/java/com/google/firebase/database/core/view/QueryParamsTest.java create mode 100644 src/test/java/com/google/firebase/database/core/view/ViewAccess.java create mode 100644 src/test/java/com/google/firebase/database/future/ReadFuture.java create mode 100644 src/test/java/com/google/firebase/database/future/WriteFuture.java create mode 100644 src/test/java/com/google/firebase/database/integration/ShutdownExample.java create mode 100644 src/test/java/com/google/firebase/database/snapshot/CompoundHashTest.java create mode 100644 src/test/java/com/google/firebase/database/snapshot/CompoundHashingIntegrationTestIT.java create mode 100644 src/test/java/com/google/firebase/database/snapshot/NodeTest.java create mode 100644 src/test/java/com/google/firebase/database/tubesock/Autobahn.java create mode 100644 src/test/java/com/google/firebase/database/tubesock/FirebaseClient.java create mode 100644 src/test/java/com/google/firebase/database/tubesock/TestClient.java create mode 100644 src/test/java/com/google/firebase/database/tubesock/UpdateClient.java create mode 100644 src/test/java/com/google/firebase/database/util/GAuthTokenTest.java create mode 100644 src/test/java/com/google/firebase/database/util/JsonMapperTest.java create mode 100644 src/test/java/com/google/firebase/integration/DatabaseServerAuthTestIT.java create mode 100644 src/test/java/com/google/firebase/internal/FirebaseAppStoreTest.java create mode 100644 src/test/java/com/google/firebase/internal/RevivingScheduledExecutorTest.java create mode 100644 src/test/java/com/google/firebase/tasks/OnCompleteCompletionListenerTest.java create mode 100644 src/test/java/com/google/firebase/tasks/OnFailureCompletionListenerTest.java create mode 100644 src/test/java/com/google/firebase/tasks/OnSuccessCompletionListenerTest.java create mode 100644 src/test/java/com/google/firebase/tasks/TaskCompletionSourceTest.java create mode 100644 src/test/java/com/google/firebase/tasks/TaskExecutorsTest.java create mode 100644 src/test/java/com/google/firebase/tasks/TaskImplTest.java create mode 100644 src/test/java/com/google/firebase/tasks/TasksTest.java create mode 100644 src/test/java/com/google/firebase/tasks/testing/TestOnCompleteListener.java create mode 100644 src/test/java/com/google/firebase/tasks/testing/TestOnFailureListener.java create mode 100644 src/test/java/com/google/firebase/tasks/testing/TestOnSuccessListener.java create mode 100644 src/test/java/com/google/firebase/testing/FirebaseAppRule.java create mode 100644 src/test/java/com/google/firebase/testing/MockitoTestRule.java create mode 100644 src/test/java/com/google/firebase/testing/ServiceAccount.java create mode 100644 src/test/java/com/google/firebase/testing/TestUtils.java create mode 100644 src/test/resources/service_accounts/editor.json create mode 100644 src/test/resources/service_accounts/editor_public_key.pem create mode 100644 src/test/resources/service_accounts/none.json create mode 100644 src/test/resources/service_accounts/none_public_key.pem create mode 100644 src/test/resources/service_accounts/owner.json create mode 100644 src/test/resources/service_accounts/owner_public_key.pem create mode 100644 src/test/resources/service_accounts/viewer.json create mode 100644 src/test/resources/service_accounts/viewer_public_key.pem create mode 100644 src/test/resources/syncPointSpec.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000..ec1055155 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +target/ +.idea/ +*.iml diff --git a/pom.xml b/pom.xml new file mode 100644 index 000000000..ebdbcd7eb --- /dev/null +++ b/pom.xml @@ -0,0 +1,128 @@ + + 4.0.0 + + com.google.firebase + firebase-admin + 4.1.7-SNAPSHOT + jar + + firebase-admin + http://maven.apache.org + + + UTF-8 + + + + + + maven-checkstyle-plugin + 2.17 + + + validate + validate + + google_checks.xml + UTF-8 + true + true + + + check + + + + + + maven-compiler-plugin + 3.6.1 + + 1.7 + 1.7 + + + + maven-surefire-plugin + 2.19.1 + + + maven-failsafe-plugin + 2.19.1 + + + **/*IT.java + + + + + + integration-test + verify + + + + + + + + + + com.google.api-client + google-api-client + 1.22.0 + + + com.google.api-client + google-api-client-gson + 1.22.0 + + + org.json + json + 20160810 + + + com.google.guava + guava + 21.0 + + + org.mockito + mockito-core + 2.7.21 + test + + + net.java.quickcheck + quickcheck + 0.6 + test + + + org.codehaus.jackson + jackson-mapper-asl + 1.9.13 + test + + + org.hamcrest + hamcrest-library + 1.3 + test + + + com.firebase + firebase-token-generator + 2.0.0 + test + + + junit + junit + 4.12 + test + + + diff --git a/src/main/java/com/google/firebase/FirebaseApp.java b/src/main/java/com/google/firebase/FirebaseApp.java new file mode 100644 index 000000000..4966ecf58 --- /dev/null +++ b/src/main/java/com/google/firebase/FirebaseApp.java @@ -0,0 +1,432 @@ +package com.google.firebase; + +import static com.google.firebase.internal.Base64Utils.encodeUrlSafeNoPadding; +import static java.nio.charset.StandardCharsets.UTF_8; + +import com.google.common.annotations.VisibleForTesting; +import com.google.common.collect.ImmutableList; +import com.google.firebase.internal.AuthStateListener; +import com.google.firebase.internal.FirebaseAppStore; +import com.google.firebase.internal.FirebaseExecutors; +import com.google.firebase.internal.GetTokenResult; +import com.google.firebase.internal.GuardedBy; +import com.google.firebase.internal.Joiner; +import com.google.firebase.internal.NonNull; +import com.google.firebase.internal.Nullable; +import com.google.firebase.internal.Objects; +import com.google.firebase.internal.Preconditions; +import com.google.firebase.tasks.Continuation; +import com.google.firebase.tasks.Task; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.Callable; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; + +/** + * The entry point of Firebase SDKs. It holds common configuration and state for Firebase APIs. Most + * applications don't need to directly interact with FirebaseApp. + * + *

Firebase APIs use the default FirebaseApp by default, unless a different one is explicitly + * passed to the API via FirebaseFoo.getInstance(firebaseApp). + * + *

{@link FirebaseApp#initializeApp(FirebaseOptions)} initializes the default app instance. This + * method should be invoked at startup. + */ +public class FirebaseApp { + + private static final String DEFAULT_APP_NAME = "[DEFAULT]"; + + private static final long TOKEN_REFRESH_INTERVAL_MILLIS = TimeUnit.MINUTES.toMillis(55); + private static final TokenRefresher.Factory DEFAULT_TOKEN_REFRESHER_FACTORY = + new TokenRefresher.Factory(); + + private static final Object sLock = new Object(); + + /** + * A map of (name, FirebaseApp) instances. + */ + @GuardedBy("sLock") + static final Map instances = new HashMap<>(); + + private final String name; + private final FirebaseOptions options; + private final TokenRefresher tokenRefresher; + + private final AtomicBoolean deleted = new AtomicBoolean(); + + private final List lifecycleListeners = + new CopyOnWriteArrayList<>(); + + private final List authStateListeners = new ArrayList<>(); + + private final AtomicReference currentToken = new AtomicReference<>(); + + /** + * Returns the unique name of this app. + */ + @NonNull + public String getName() { + checkNotDeleted(); + return name; + } + + /** + * Returns the specified {@link FirebaseOptions}. + */ + @NonNull + public FirebaseOptions getOptions() { + checkNotDeleted(); + return options; + } + + @Override + public boolean equals(Object o) { + if (!(o instanceof FirebaseApp)) { + return false; + } + return name.equals(((FirebaseApp) o).getName()); + } + + @Override + public int hashCode() { + return name.hashCode(); + } + + @Override + public String toString() { + return Objects.toStringHelper(this).add("name", name).add("options", options).toString(); + } + + /** + * Returns a mutable list of all FirebaseApps. + */ + public static List getApps() { + // TODO(arondeak): reenable persistence. See b/28158809. + return new ArrayList<>(instances.values()); + } + + /** + * Returns the default (first initialized) instance of the {@link FirebaseApp}. + * + * @throws IllegalStateException if the default app was not initialized. + */ + @Nullable + public static FirebaseApp getInstance() { + return getInstance(DEFAULT_APP_NAME); + } + + /** + * Returns the instance identified by the unique name, or throws if it does not exist. + * + * @param name represents the name of the {@link FirebaseApp} instance. + * @return the {@link FirebaseApp} corresponding to the name. + * @throws IllegalStateException if the {@link FirebaseApp} was not initialized, either via {@link + * #initializeApp(FirebaseOptions, String)} or {@link #getApps()}. + */ + public static FirebaseApp getInstance(@NonNull String name) { + synchronized (sLock) { + FirebaseApp firebaseApp = instances.get(normalize(name)); + if (firebaseApp != null) { + return firebaseApp; + } + + List availableAppNames = getAllAppNames(); + String availableAppNamesMessage; + if (availableAppNames.isEmpty()) { + availableAppNamesMessage = ""; + } else { + availableAppNamesMessage = + "Available app names: " + Joiner.on(", ").join(availableAppNames); + } + String errorMessage = + String.format( + "FirebaseApp with name %s doesn't exist. %s", name, availableAppNamesMessage); + throw new IllegalStateException(errorMessage); + } + } + + /** + * Initializes the default {@link FirebaseApp} instance. Same as {@link + * #initializeApp(FirebaseOptions, String)}, but it uses {@link #DEFAULT_APP_NAME} as name. + * + *

The creation of the default instance is automatically triggered at app startup time, if + * Firebase configuration values are available from resources - populated from + * google-services.json. + */ + public static FirebaseApp initializeApp(FirebaseOptions options) { + return initializeApp(options, DEFAULT_APP_NAME); + } + + /** + * A factory method to intialize a {@link FirebaseApp}. + * + * @param options represents the global {@link FirebaseOptions} + * @param name unique name for the app. It is an error to initialize an app with an already + * existing name. Starting and ending whitespace characters in the name are ignored (trimmed). + * @return an instance of {@link FirebaseApp} + * @throws IllegalStateException if an app with the same name has already been initialized. + */ + public static FirebaseApp initializeApp(FirebaseOptions options, String name) { + return initializeApp(options, name, DEFAULT_TOKEN_REFRESHER_FACTORY); + } + + static FirebaseApp initializeApp(FirebaseOptions options, String name, + TokenRefresher.Factory tokenRefresherFactory) { + FirebaseAppStore appStore = FirebaseAppStore.initialize(); + String normalizedName = normalize(name); + final FirebaseApp firebaseApp; + synchronized (sLock) { + Preconditions.checkState( + !instances.containsKey(normalizedName), + "FirebaseApp name " + normalizedName + " already exists!"); + + firebaseApp = new FirebaseApp(normalizedName, options, tokenRefresherFactory); + instances.put(normalizedName, firebaseApp); + } + + appStore.persistApp(firebaseApp); + + return firebaseApp; + } + + /** + * Deletes the {@link FirebaseApp} and all its data. All calls to this {@link FirebaseApp} + * instance will throw once it has been called. + * + *

A no-op if delete was called before. + * + * @hide + */ + void delete() { + boolean valueChanged = deleted.compareAndSet(false /* expected */, true); + if (!valueChanged) { + return; + } + tokenRefresher.cleanup(); + + synchronized (sLock) { + instances.remove(this.name); + } + + FirebaseAppStore appStore = FirebaseAppStore.getInstance(); + if (appStore != null) { + appStore.removeApp(name); + } + + notifyOnAppDeleted(); + } + + /** + * Default constructor. + */ + private FirebaseApp(String name, FirebaseOptions options, TokenRefresher.Factory factory) { + this.name = Preconditions.checkNotEmpty(name); + this.options = Preconditions.checkNotNull(options); + tokenRefresher = Preconditions.checkNotNull(factory).create(this); + } + + private void checkNotDeleted() { + Preconditions.checkState(!deleted.get(), "FirebaseApp was deleted"); + } + + /** + * Internal-only method to fetch a valid Service Account OAuth2 Token. + * + * @param forceRefresh force refreshes the token. Should only be set to true if the + * token is invalidated out of band. + * @return a {@link Task} + */ + Task getToken(boolean forceRefresh) { + checkNotDeleted(); + return options.getCredential().getAccessToken(forceRefresh).continueWith( + new Continuation() { + @Override + public GetTokenResult then(@NonNull Task task) throws Exception { + GetTokenResult newToken = new GetTokenResult(task.getResult()); + GetTokenResult oldToken = currentToken.get(); + List listenersCopy = null; + if (!newToken.equals(oldToken)) { + synchronized (authStateListeners) { + // Grab the lock before compareAndSet to avoid a potential race condition + // with addAuthStateListener + if (currentToken.compareAndSet(oldToken, newToken)) { + listenersCopy = ImmutableList.copyOf(authStateListeners); + tokenRefresher.scheduleRefresh(TOKEN_REFRESH_INTERVAL_MILLIS); + } + } + } + + if (listenersCopy != null) { + for (AuthStateListener listener : listenersCopy) { + listener.onAuthStateChanged(newToken); + } + } + return newToken; + } + }); + } + + boolean isDefaultApp() { + return DEFAULT_APP_NAME.equals(getName()); + } + + /** + * Use this key to store data per FirebaseApp. + */ + String getPersistenceKey() { + return FirebaseApp.getPersistenceKey(getName(), getOptions()); + } + + /** + * If an API has locally stored data it must register lifecycle listeners at initialization time. + */ + // TODO(arondeak): make sure that all APIs that are interested in these events are + // initialized using reflection when an app is deleted (for v5). + void addLifecycleEventListener(@NonNull FirebaseAppLifecycleListener listener) { + checkNotDeleted(); + Preconditions.checkNotNull(listener); + lifecycleListeners.add(listener); + } + + void removeLifecycleEventListener(@NonNull FirebaseAppLifecycleListener listener) { + checkNotDeleted(); + Preconditions.checkNotNull(listener); + lifecycleListeners.remove(listener); + } + + void addAuthStateListener(@NonNull final AuthStateListener listener) { + checkNotDeleted(); + Preconditions.checkNotNull(listener); + + GetTokenResult currentToken; + synchronized (authStateListeners) { + authStateListeners.add(listener); + currentToken = this.currentToken.get(); + } + + if (currentToken != null) { + // Task has copied the mAuthStateListeners before the listener was added. + // Notify this listener explicitly. + listener.onAuthStateChanged(currentToken); + } + } + + void removeAuthStateListener(@NonNull AuthStateListener listener) { + checkNotDeleted(); + Preconditions.checkNotNull(listener); + synchronized (authStateListeners) { + authStateListeners.remove(listener); + } + } + + /** + * Notifies all listeners with the name and options of the deleted {@link FirebaseApp} instance. + */ + private void notifyOnAppDeleted() { + for (FirebaseAppLifecycleListener listener : lifecycleListeners) { + listener.onDeleted(name, options); + } + } + + @VisibleForTesting + static void clearInstancesForTest() { + // TODO(arondeak): also delete, once functionality is implemented. + synchronized (sLock) { + instances.clear(); + } + } + + /** + * Returns persistence key. Exists to support getting {@link FirebaseApp} persistence key after + * the app has been deleted. + */ + static String getPersistenceKey(String name, FirebaseOptions options) { + return encodeUrlSafeNoPadding(name.getBytes(UTF_8)); + } + + private static List getAllAppNames() { + Set allAppNames = new HashSet<>(); + synchronized (sLock) { + for (FirebaseApp app : instances.values()) { + allAppNames.add(app.getName()); + } + FirebaseAppStore appStore = FirebaseAppStore.getInstance(); + if (appStore != null) { + allAppNames.addAll(appStore.getAllPersistedAppNames()); + } + } + List sortedNameList = new ArrayList<>(allAppNames); + Collections.sort(sortedNameList); + return sortedNameList; + } + + /** + * Normalizes the app name. + */ + private static String normalize(@NonNull String name) { + return name.trim(); + } + + static class TokenRefresher { + + private final FirebaseApp firebaseApp; + private ScheduledFuture> future; + + TokenRefresher(FirebaseApp app) { + this.firebaseApp = Preconditions.checkNotNull(app); + } + + /** + * Schedule a forced token refresh to be executed after a specified duration. + * + * @param delayMillis Duration in milliseconds, after which the token should be forcibly + * refreshed. + */ + final synchronized void scheduleRefresh(long delayMillis) { + cancelPrevious(); + scheduleNext(new Callable>() { + @Override + public Task call() throws Exception { + return firebaseApp.getToken(true); + } + }, delayMillis); + } + + protected void cancelPrevious() { + if (future != null) { + future.cancel(true); + } + } + + protected void scheduleNext(Callable> task, long delayMillis) { + try { + future = FirebaseExecutors.DEFAULT_SCHEDULED_EXECUTOR.schedule( + task, delayMillis, TimeUnit.MILLISECONDS); + } catch (UnsupportedOperationException ignored) { + // Cannot support task scheduling in the current runtime. + } + } + + protected synchronized void cleanup() { + if (future != null) { + future.cancel(true); + } + } + + static class Factory { + + TokenRefresher create(FirebaseApp app) { + return new TokenRefresher(app); + } + } + } +} diff --git a/src/main/java/com/google/firebase/FirebaseAppLifecycleListener.java b/src/main/java/com/google/firebase/FirebaseAppLifecycleListener.java new file mode 100644 index 000000000..d80165b3b --- /dev/null +++ b/src/main/java/com/google/firebase/FirebaseAppLifecycleListener.java @@ -0,0 +1,15 @@ +package com.google.firebase; + +/** + * A listener which gets notified when {@link com.google.firebase.FirebaseApp} gets deleted. + */ +// TODO(arondeak): consider making it public in a future release. +interface FirebaseAppLifecycleListener { + + /** + * Gets called when {@link FirebaseApp#delete()} is called. {@link FirebaseApp} public methods + * start throwing after delete is called, so name and options are passed in to be able to + * identify the instance. + */ + void onDeleted(String firebaseAppName, FirebaseOptions options); +} diff --git a/src/main/java/com/google/firebase/FirebaseException.java b/src/main/java/com/google/firebase/FirebaseException.java new file mode 100644 index 000000000..fd6cc27f3 --- /dev/null +++ b/src/main/java/com/google/firebase/FirebaseException.java @@ -0,0 +1,23 @@ +package com.google.firebase; + +import com.google.firebase.internal.NonNull; +import com.google.firebase.internal.Preconditions; + +/** + * Base class for all Firebase exceptions. + */ +public class FirebaseException extends Exception { + + // TODO(b/27677218): Exceptions should have non-empty messages. + @Deprecated + protected FirebaseException() { + } + + public FirebaseException(@NonNull String detailMessage) { + super(Preconditions.checkNotEmpty(detailMessage, "Detail message must not be empty")); + } + + public FirebaseException(@NonNull String detailMessage, Throwable cause) { + super(Preconditions.checkNotEmpty(detailMessage, "Detail message must not be empty"), cause); + } +} diff --git a/src/main/java/com/google/firebase/FirebaseOptions.java b/src/main/java/com/google/firebase/FirebaseOptions.java new file mode 100644 index 000000000..91d852921 --- /dev/null +++ b/src/main/java/com/google/firebase/FirebaseOptions.java @@ -0,0 +1,214 @@ +package com.google.firebase; + +import com.google.firebase.auth.FirebaseCredential; +import com.google.firebase.auth.FirebaseCredentials; +import com.google.firebase.internal.NonNull; +import com.google.firebase.internal.Nullable; +import com.google.firebase.internal.Objects; +import com.google.firebase.internal.Preconditions; + +import java.io.InputStream; +import java.util.HashMap; +import java.util.Map; + +/** + * Configurable Firebase options. + */ +public final class FirebaseOptions { + + // TODO(arondeak): deprecate and remove it once we can fetch these from Remote Config. + + private final String databaseUrl; + private final FirebaseCredential firebaseCredential; + private final Map databaseAuthVariableOverride; + + /** + * Builder for constructing {@link FirebaseOptions}. + */ + public static final class Builder { + + private String databaseUrl; + private FirebaseCredential firebaseCredential; + private FirebaseCredential serviceAccountCredential; + private Map databaseAuthVariableOverride = new HashMap<>(); + + /** + * Constructs an empty builder. + */ + public Builder() { + } + + /** + * Initializes the builder's values from the options object. + * + *

The new builder is not backed by this objects values, that is changes made to the new + * builder don't change the values of the origin object. + */ + public Builder(FirebaseOptions options) { + databaseUrl = options.databaseUrl; + firebaseCredential = options.firebaseCredential; + databaseAuthVariableOverride = options.databaseAuthVariableOverride; + } + + /** + * Sets the Realtime Database URL to use for data storage. + * + *

See + * Initialize the SDK + * for code samples and detailed documentation. + * + * @param databaseUrl The Realtime Database URL to use for data storage. + * @return This Builder instance is returned so subsequent calls can be chained. + */ + public Builder setDatabaseUrl(@Nullable String databaseUrl) { + this.databaseUrl = databaseUrl; + return this; + } + + /** + * Sets the service account to use to authenticate the SDK. + * + *

This method is deprecated in favor of the {@link #setCredential} + * method. Only one of the setCredential() and + * setServiceAccount() methods can be used. + * + * @param stream A stream containing the service account contents as JSON. + * @return This Builder instance is returned so subsequent calls can be chained. + * @deprecated Use {@link #setCredential} instead and obtain credentials via {@link + * FirebaseCredentials}. + */ + @Deprecated + public Builder setServiceAccount(@NonNull InputStream stream) { + serviceAccountCredential = FirebaseCredentials.fromCertificate(stream); + return this; + } + + /** + * Sets the FirebaseCredential to use to authenticate the SDK. + * + *

This method replaces the deprecated {@link #setServiceAccount} method. + * + *

See + * Initialize the SDK + * for code samples and detailed documentation. + * + * @param credential A FirebaseCredential used to authenticate the SDK. See {@link + * FirebaseCredentials} for default implementations. + * @return This Builder instance is returned so subsequent calls can be chained. + */ + public Builder setCredential(@NonNull FirebaseCredential credential) { + Preconditions.checkArgument(credential != null); + firebaseCredential = credential; + return this; + } + + /** + * Sets the auth variable to be used by the Realtime Database + * rules. + * + *

When set, security rules for Realtime Database actions are evaluated using the provided + * auth object. During evaluation the object is available on the auth variable. + * Use this option to enforce schema validation and additional security for this app instance. + * + *

If this option is not provided, security rules are bypassed entirely for this app + * instance. If this option is set to null, security rules are evaluated against + * an unauthenticated user. That is, the auth variable is null. + * + *

See + * + * Authenticate with limited privileges for code samples and detailed + * documentation. + * + * @param databaseAuthVariableOverride The value to use for the auth variable in + * the security rules for Realtime Database actions. + * @return This Builder instance is returned so subsequent calls can be chained. + */ + public Builder setDatabaseAuthVariableOverride( + @Nullable Map databaseAuthVariableOverride) { + this.databaseAuthVariableOverride = databaseAuthVariableOverride; + return this; + } + + /** + * Builds the {@link FirebaseOptions} instance from the previously set + * options. + * + * @return A {@link FirebaseOptions} instance created from the previously set options. + */ + public FirebaseOptions build() { + if (serviceAccountCredential == null && firebaseCredential == null) { + throw new IllegalStateException( + "FirebaseOptions must be initialized with setCredential()."); + } else if (serviceAccountCredential != null && firebaseCredential != null) { + throw new IllegalStateException( + "FirebaseOptions cannot be initialized with both " + + "setCredential() and setServiceAccount()."); + } + + FirebaseCredential firebaseCredential = + this.firebaseCredential != null ? this.firebaseCredential : serviceAccountCredential; + + return new FirebaseOptions(databaseUrl, firebaseCredential, databaseAuthVariableOverride); + } + } + + private FirebaseOptions( + @Nullable String databaseUrl, + @NonNull FirebaseCredential firebaseCredential, + @Nullable Map databaseAuthVariableOverride) { + Preconditions.checkArgument(firebaseCredential != null, "Service Account must be provided."); + + this.databaseUrl = databaseUrl; + this.firebaseCredential = firebaseCredential; + this.databaseAuthVariableOverride = databaseAuthVariableOverride; + } + + /** + * Returns the Realtime Database URL to use for data storage. + * + * @return The Realtime Database URL supplied via {@link Builder#setDatabaseUrl}. + */ + public String getDatabaseUrl() { + return databaseUrl; + } + + + FirebaseCredential getCredential() { + return firebaseCredential; + } + + /** + * Returns the auth variable to be used in Security Rules. + * + * @return The auth variable supplied via + * {@link Builder#setDatabaseAuthVariableOverride}. + */ + public Map getDatabaseAuthVariableOverride() { + return databaseAuthVariableOverride; + } + + @Override + public boolean equals(Object obj) { + if (!(obj instanceof FirebaseOptions)) { + return false; + } + FirebaseOptions other = (FirebaseOptions) obj; + return Objects.equal(databaseUrl, other.databaseUrl) + && Objects.equal(firebaseCredential, other.firebaseCredential) + && Objects.equal(databaseAuthVariableOverride, other.databaseAuthVariableOverride); + } + + @Override + public int hashCode() { + return Objects.hashCode(databaseUrl, firebaseCredential, databaseAuthVariableOverride); + } + + @Override + public String toString() { + return Objects.toStringHelper(this) + .add("databaseUrl", databaseUrl) + .add("credential", firebaseCredential) + .add("databaseAuthVariableOverride", databaseAuthVariableOverride) + .toString(); + } +} diff --git a/src/main/java/com/google/firebase/ImplFirebaseTrampolines.java b/src/main/java/com/google/firebase/ImplFirebaseTrampolines.java new file mode 100644 index 000000000..d586838e3 --- /dev/null +++ b/src/main/java/com/google/firebase/ImplFirebaseTrampolines.java @@ -0,0 +1,54 @@ +package com.google.firebase; + +import com.google.firebase.auth.FirebaseCredential; +import com.google.firebase.internal.AuthStateListener; +import com.google.firebase.internal.GetTokenResult; +import com.google.firebase.internal.NonNull; +import com.google.firebase.tasks.Task; + +/** + * Provides trampolines into package-private APIs used by components of Firebase. + * Intentionally scarily-named to dissuade people from actually trying to use the class and to make + * it less likely to appear in code completion. + */ +public final class ImplFirebaseTrampolines { + + private ImplFirebaseTrampolines() { + } + + public static FirebaseCredential getCredential(@NonNull FirebaseApp app) { + return app.getOptions().getCredential(); + } + + public static boolean isDefaultApp(@NonNull FirebaseApp app) { + return app.isDefaultApp(); + } + + public static String getPersistenceKey(@NonNull FirebaseApp app) { + return app.getPersistenceKey(); + } + + public static void addLifecycleEventListener( + @NonNull FirebaseApp app, @NonNull FirebaseAppLifecycleListener listener) { + app.addLifecycleEventListener(listener); + } + + public static void removeLifecycleEventListener( + @NonNull FirebaseApp app, @NonNull FirebaseAppLifecycleListener listener) { + app.removeLifecycleEventListener(listener); + } + + public static void addAuthStateChangeListener( + @NonNull FirebaseApp app, @NonNull AuthStateListener listener) { + app.addAuthStateListener(listener); + } + + public static void removeAuthStateChangeListener( + @NonNull FirebaseApp app, @NonNull AuthStateListener listener) { + app.removeAuthStateListener(listener); + } + + public static Task getToken(@NonNull FirebaseApp app, boolean forceRefresh) { + return app.getToken(forceRefresh); + } +} diff --git a/src/main/java/com/google/firebase/TestOnlyImplFirebaseTrampolines.java b/src/main/java/com/google/firebase/TestOnlyImplFirebaseTrampolines.java new file mode 100644 index 000000000..97be03976 --- /dev/null +++ b/src/main/java/com/google/firebase/TestOnlyImplFirebaseTrampolines.java @@ -0,0 +1,30 @@ +package com.google.firebase; + +import com.google.firebase.internal.GetTokenResult; +import com.google.firebase.tasks.Task; + +/** + * Provides trampolines into package-private APIs used by components of Firebase + * + * Intentionally scarily-named to dissuade people from actually trying to use the class and to make + * it less likely to appear in code completion. + * + * This class will not be compiled into the shipping library and can only be used in tests. + * + * @hide + */ +public final class TestOnlyImplFirebaseTrampolines { + + private TestOnlyImplFirebaseTrampolines() { + } + + /* FirebaseApp */ + public static void clearInstancesForTest() { + FirebaseApp.clearInstancesForTest(); + } + + public static Task getToken(FirebaseApp app, boolean forceRefresh) { + return app.getToken(forceRefresh); + } +} + diff --git a/src/main/java/com/google/firebase/auth/FirebaseAuth.java b/src/main/java/com/google/firebase/auth/FirebaseAuth.java new file mode 100644 index 000000000..446cdcb03 --- /dev/null +++ b/src/main/java/com/google/firebase/auth/FirebaseAuth.java @@ -0,0 +1,186 @@ +package com.google.firebase.auth; + +import com.google.api.client.googleapis.auth.oauth2.GoogleCredential; +import com.google.api.client.googleapis.auth.oauth2.GooglePublicKeysManager; +import com.google.api.client.json.JsonFactory; +import com.google.api.client.json.gson.GsonFactory; +import com.google.api.client.util.Clock; +import com.google.common.annotations.VisibleForTesting; +import com.google.firebase.FirebaseApp; +import com.google.firebase.FirebaseException; +import com.google.firebase.ImplFirebaseTrampolines; +import com.google.firebase.auth.internal.FirebaseTokenFactory; +import com.google.firebase.auth.internal.FirebaseTokenVerifier; +import com.google.firebase.internal.NonNull; +import com.google.firebase.tasks.Continuation; +import com.google.firebase.tasks.Task; +import com.google.firebase.tasks.Tasks; +import java.util.HashMap; +import java.util.Map; + +/** + *

This class is the entry point for all server-side Firebase Authentication actions.

+ * + *

You can get an instance of FirebaseAuth via {@link FirebaseAuth#getInstance(FirebaseApp)} + * and then use it to perform a variety of authentication-related operations, including generating + * custom tokens for use by client-side code, verifying Firebase ID Tokens received from clients, + * or creating new FirebaseApp instances that are scoped to a particular authentication UID.

+ */ +public class FirebaseAuth { + + /** + * A static map of FirebaseApp name to FirebaseAuth instance. To ensure thread- + * safety, it should only be accessed in getInstance(), which is a synchronized method. + */ + private static Map authInstances = new HashMap<>(); + + /** + * A global, thread-safe Json Factory built using Gson. + */ + private static JsonFactory jsonFactory = new GsonFactory(); + + /** + * Gets the FirebaseAuth instance for the default {@link FirebaseApp}. + * + * @return The FirebaseAuth instance for the default {@link FirebaseApp}. + */ + public static FirebaseAuth getInstance() { + return FirebaseAuth.getInstance(FirebaseApp.getInstance()); + } + + /** + * Gets an instance of FirebaseAuth for a specific {@link FirebaseApp}. + * + * @param app The {@link FirebaseApp} to get a FirebaseAuth instance for. + * @return A FirebaseAuth instance. + */ + public static synchronized FirebaseAuth getInstance(FirebaseApp app) { + if (!authInstances.containsKey(app.getName())) { + authInstances.put(app.getName(), new FirebaseAuth(app)); + } + + return authInstances.get(app.getName()); + } + + private final FirebaseApp firebaseApp; + private final GooglePublicKeysManager googlePublicKeysManager; + private final Clock clock; + + private FirebaseAuth(FirebaseApp firebaseApp) { + this(firebaseApp, FirebaseTokenVerifier.DEFAULT_KEY_MANAGER, Clock.SYSTEM); + } + + /** + * Constructor for injecting a GooglePublicKeysManager, which is used to verify tokens are + * correctly signed. This should only be used for testing to override the default key manager. + */ + @VisibleForTesting + FirebaseAuth( + FirebaseApp firebaseApp, GooglePublicKeysManager googlePublicKeysManager, Clock clock) { + this.firebaseApp = firebaseApp; + this.googlePublicKeysManager = googlePublicKeysManager; + this.clock = clock; + } + + /** + * Creates a Firebase Custom Token associated with the given UID. This token can then be provided + * back to a client application for use with the signInWithCustomToken authentication API. + * + * @param uid The UID to store in the token. This identifies the user to other Firebase services + * (Firebase Database, Firebase Auth, etc.) + * @return A {@link Task} which will complete successfully with the created Firebase Custom Token, + * or unsuccessfully with the failure Exception. + */ + public Task createCustomToken(String uid) { + return createCustomToken(uid, null); + } + + /** + * Creates a Firebase Custom Token associated with the given UID and additionally containing the + * specified developerClaims. This token can then be provided back to a client application for use + * with the signInWithCustomToken authentication API. + * + * @param uid The UID to store in the token. This identifies the user to other Firebase services + * (Realtime Database, Storage, etc.). Should be less than 128 characters. + * @param developerClaims Additional claims to be stored in the token (and made available to + * security rules in Database, Storage, etc.). These must be able to be serialized to JSON (e.g. + * contain only Maps, Arrays, Strings, Booleans, Numbers, etc.) + * @return A {@link Task} which will complete successfully with the created Firebase Custom Token, + * or unsuccessfully with the failure Exception. + */ + public Task createCustomToken( + final String uid, final Map developerClaims) { + FirebaseCredential credential = ImplFirebaseTrampolines.getCredential(firebaseApp); + if (!(credential instanceof FirebaseCredentials.CertCredential)) { + return Tasks.forException( + new FirebaseException( + "Must initialize FirebaseApp with a certificate credential to call " + + "createCustomToken()")); + } + + return ((FirebaseCredentials.CertCredential) credential) + .getCertificate(false) + .continueWith( + new Continuation() { + @Override + public String then(@NonNull Task task) throws Exception { + GoogleCredential baseCredential = task.getResult(); + FirebaseTokenFactory tokenFactory = FirebaseTokenFactory.getInstance(); + return tokenFactory.createSignedCustomAuthTokenForUser( + uid, + developerClaims, + baseCredential.getServiceAccountId(), + baseCredential.getServiceAccountPrivateKey()); + } + }); + } + + /** + * Parses and verifies a Firebase ID Token. + * + *

A Firebase application can identify itself to a trusted backend server by sending its + * Firebase ID Token (accessible via the getToken API in the Firebase Authentication client) with + * its request. + * + *

The backend server can then use the verifyIdToken() method to verify the token is valid, + * meaning: the token is properly signed, has not expired, and it was issued for the project + * associated with this FirebaseAuth instance (which by default is extracted from your service + * account) + * + *

If the token is valid, the returned {@link Task} will complete successfully and provide a + * parsed version of the token from which the UID and other claims in the token can be inspected. + * If the token is invalid, the Task will fail with an exception indicating the failure. + * + * @param token A Firebase ID Token to verify and parse. + * @return A {@link Task} which will complete successfully with the parsed token, or + * unsuccessfully with the failure Exception. + */ + public Task verifyIdToken(final String token) { + FirebaseCredential credential = ImplFirebaseTrampolines.getCredential(firebaseApp); + if (!(credential instanceof FirebaseCredentials.CertCredential)) { + return Tasks.forException( + new FirebaseException( + "Must initialize FirebaseApp with a certificate credential to call verifyIdToken()")); + } + return ((FirebaseCredentials.CertCredential) credential) + .getProjectId(false) + .continueWith( + new Continuation() { + @Override + public FirebaseToken then(@NonNull Task task) throws Exception { + FirebaseTokenVerifier firebaseTokenVerifier = + new FirebaseTokenVerifier.Builder() + .setProjectId(task.getResult()) + .setPublicKeysManager(googlePublicKeysManager) + .setClock(clock) + .build(); + FirebaseToken firebaseToken = FirebaseToken.parse(jsonFactory, token); + + // This will throw a FirebaseAuthException with details on how the token is invalid. + firebaseTokenVerifier.verifyTokenAndSignature(firebaseToken.getToken()); + + return firebaseToken; + } + }); + } +} diff --git a/src/main/java/com/google/firebase/auth/FirebaseAuthException.java b/src/main/java/com/google/firebase/auth/FirebaseAuthException.java new file mode 100644 index 000000000..91c978c1f --- /dev/null +++ b/src/main/java/com/google/firebase/auth/FirebaseAuthException.java @@ -0,0 +1,33 @@ +package com.google.firebase.auth; + +// TODO(rahulrav/isachen): Move it out from firebase-common. Temporary host it their for +// database's integration.http://b/27624510. + +// TODO(rahulrav/isachen): Decide if changing this not enforcing an error code. Need to align +// with the decision in http://b/27677218. Also, need to turn this into abstract later. + +import com.google.firebase.FirebaseException; +import com.google.firebase.internal.NonNull; +import com.google.firebase.internal.Preconditions; + +/** + * Generic exception related to Firebase Authentication. Check the error code and message for more + * details. + */ +public class FirebaseAuthException extends FirebaseException { + + private final String mErrorCode; + + public FirebaseAuthException(@NonNull String errorCode, @NonNull String detailMessage) { + super(detailMessage); + mErrorCode = Preconditions.checkNotEmpty(errorCode); + } + + /** + * Returns an error code that may provide more information about the error. + */ + @NonNull + public String getErrorCode() { + return mErrorCode; + } +} diff --git a/src/main/java/com/google/firebase/auth/FirebaseCredential.java b/src/main/java/com/google/firebase/auth/FirebaseCredential.java new file mode 100644 index 000000000..8673ba41e --- /dev/null +++ b/src/main/java/com/google/firebase/auth/FirebaseCredential.java @@ -0,0 +1,21 @@ +package com.google.firebase.auth; + +import com.google.firebase.tasks.Task; + +/** + * Provides Google OAuth2 access tokens used to authenticate with Firebase + * services. In most cases, you will not need to implement this yourself and can + * instead use the default implementations provided by + * {@link FirebaseCredentials}. + */ +public interface FirebaseCredential { + + /** + * Returns a Google OAuth2 access token used to authenticate with Firebase + * services. + * + * @param forceRefresh Whether to fetch a new token or use a cached one if available. + * @return A {@link Task} providing an access token. + */ + Task getAccessToken(boolean forceRefresh); +} diff --git a/src/main/java/com/google/firebase/auth/FirebaseCredentials.java b/src/main/java/com/google/firebase/auth/FirebaseCredentials.java new file mode 100644 index 000000000..a7742c871 --- /dev/null +++ b/src/main/java/com/google/firebase/auth/FirebaseCredentials.java @@ -0,0 +1,383 @@ +package com.google.firebase.auth; + +import static com.google.firebase.internal.Preconditions.checkNotNull; + +import com.google.api.client.googleapis.auth.oauth2.GoogleCredential; +import com.google.api.client.googleapis.util.Utils; +import com.google.api.client.http.HttpTransport; +import com.google.api.client.json.JsonFactory; +import com.google.common.annotations.VisibleForTesting; +import com.google.firebase.internal.NonNull; +import com.google.firebase.tasks.Continuation; +import com.google.firebase.tasks.Task; +import com.google.firebase.tasks.Tasks; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.Reader; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.List; +import java.util.concurrent.Callable; +import org.json.JSONException; +import org.json.JSONObject; + +/** + * Standard {@link FirebaseCredential} implementations for use with {@link + * com.google.firebase.FirebaseOptions}. + */ +public class FirebaseCredentials { + + private static final List FIREBASE_SCOPES = + Arrays.asList( + "https://www.googleapis.com/auth/firebase.database", + "https://www.googleapis.com/auth/userinfo.email"); + + /** + * Helper class that implements {@link FirebaseCredential} on top of {@link GoogleCredential} and + * provides caching of access tokens and credentials. + */ + abstract static class BaseCredential implements FirebaseCredential { + + final HttpTransport transport; + final JsonFactory jsonFactory; + final Clock clock; + + private GoogleCredential googleCredential; + private final Object accessTokenTaskLock = new Object(); + private Task accessTokenTask; + + BaseCredential(HttpTransport transport, JsonFactory jsonFactory) { + this(transport, jsonFactory, new Clock()); + } + + BaseCredential(HttpTransport transport, JsonFactory jsonFactory, Clock clock) { + this.transport = checkNotNull(transport, "HttpTransport must not be null"); + this.jsonFactory = checkNotNull(jsonFactory, "JsonFactory must not be null"); + this.clock = checkNotNull(clock, "Clock must not be null"); + } + + /** + * Retrieves a GoogleCredential. Should not use caching. + */ + abstract GoogleCredential fetchCredential() throws Exception; + + /** + * Retrieves an access token from a GoogleCredential. Should not use caching. + */ + abstract FirebaseAccessToken fetchToken(GoogleCredential credential) throws Exception; + + /** + * Returns the associated GoogleCredential for this class. This implementation is cached by + * default. + * + * @param forceRefresh Whether to fetch from cache + */ + final Task getCertificate(boolean forceRefresh) { + if (!forceRefresh) { + synchronized (this) { + if (googleCredential != null) { + return Tasks.forResult(googleCredential); + } + } + } + + return Tasks.call( + new Callable() { + @Override + public GoogleCredential call() throws Exception { + // Retrieve a new credential. This is a network operation that can be repeated and is + // done outside of the lock. + GoogleCredential credential = fetchCredential(); + synchronized (BaseCredential.this) { + googleCredential = credential; + } + return credential; + } + }); + } + + private boolean refreshRequired(@NonNull Task previousTask, + boolean forceRefresh) { + return previousTask == null || (previousTask.isComplete() && (forceRefresh || !previousTask + .isSuccessful() || previousTask.getResult().isExpired())); + } + + /** + * Returns an access token for this credential. This implementation is cached by default. + * + * @param forceRefresh Whether or not to force an access token refresh + */ + @Override + public final Task getAccessToken(boolean forceRefresh) { + synchronized (accessTokenTaskLock) { + if (refreshRequired(accessTokenTask, forceRefresh)) { + accessTokenTask = getCertificate(forceRefresh).continueWith( + new Continuation() { + @Override + public FirebaseAccessToken then(@NonNull Task task) + throws Exception { + return fetchToken(task.getResult()); + } + }); + } + + return accessTokenTask.continueWith(new Continuation() { + @Override + public String then(@NonNull Task task) throws Exception { + return task.getResult().getToken(); + } + }); + } + } + } + + static class CertCredential extends BaseCredential { + + private String jsonData; + private String projectId; + private Exception streamException; + + CertCredential(InputStream inputStream, HttpTransport transport, JsonFactory jsonFactory) { + super(transport, jsonFactory); + try { + jsonData = streamToString(inputStream); + JSONObject jsonObject = new JSONObject(jsonData); + projectId = jsonObject.getString("project_id"); + } catch (IOException e) { + streamException = new IOException("Failed to read service account", e); + } catch (JSONException e) { + streamException = + new JSONException("Failed to parse service account: 'project_id' must be set"); + } + } + + @Override + GoogleCredential fetchCredential() throws Exception { + if (streamException != null) { + throw streamException; + } + + GoogleCredential firebaseCredential = + GoogleCredential.fromStream( + new ByteArrayInputStream(jsonData.getBytes("UTF-8")), transport, jsonFactory); + + if (firebaseCredential.getServiceAccountId() == null) { + throw new IOException( + "Error reading credentials from stream, 'type' value 'service_account' not " + + "recognized. Expecting 'authorized_user'."); + } + + return firebaseCredential.createScoped(FIREBASE_SCOPES); + } + + @Override + FirebaseAccessToken fetchToken(GoogleCredential credential) throws Exception { + if (streamException != null) { + throw streamException; + } + + credential.refreshToken(); + return new FirebaseAccessToken(credential, clock); + } + + Task getProjectId(boolean forceRefresh) { + if (streamException != null) { + return Tasks.forException(streamException); + } + + return Tasks.forResult(projectId); + } + } + + static class ApplicationDefaultCredential extends BaseCredential { + + ApplicationDefaultCredential(HttpTransport transport, JsonFactory jsonFactory) { + super(transport, jsonFactory); + } + + @Override + GoogleCredential fetchCredential() throws Exception { + return GoogleCredential.getApplicationDefault(transport, jsonFactory) + .createScoped(FIREBASE_SCOPES); + } + + @Override + FirebaseAccessToken fetchToken(GoogleCredential credential) throws Exception { + credential.refreshToken(); + return new FirebaseAccessToken(credential, clock); + } + + } + + static class RefreshTokenCredential extends BaseCredential { + + private String jsonData; + private Exception streamException; + + RefreshTokenCredential( + InputStream inputStream, HttpTransport transport, JsonFactory jsonFactory) { + super(transport, jsonFactory); + try { + jsonData = streamToString(inputStream); + } catch (IOException e) { + streamException = new IOException("Failed to read refresh token", e); + } + } + + @Override + GoogleCredential fetchCredential() throws Exception { + if (streamException != null) { + throw streamException; + } + + GoogleCredential credential = + GoogleCredential.fromStream( + new ByteArrayInputStream(jsonData.getBytes("UTF-8")), transport, jsonFactory); + + if (credential.getServiceAccountId() != null) { + throw new IOException( + "Error reading credentials from stream, 'type' value 'authorized_user' not " + + "recognized. Expecting 'service_account'."); + } + + return credential; + } + + @Override + FirebaseAccessToken fetchToken(GoogleCredential credential) throws Exception { + if (streamException != null) { + throw streamException; + } + + credential.refreshToken(); + return new FirebaseAccessToken(credential, clock); + } + } + + private static class DefaultCredentialsHolder { + + static final FirebaseCredential INSTANCE = + applicationDefault(Utils.getDefaultTransport(), Utils.getDefaultJsonFactory()); + } + + private static String streamToString(InputStream inputStream) throws IOException { + StringBuilder stringBuilder = new StringBuilder(); + Reader reader = new InputStreamReader(inputStream, StandardCharsets.UTF_8); + char[] buffer = new char[256]; + int length; + + while ((length = reader.read(buffer)) != -1) { + stringBuilder.append(buffer, 0, length); + } + inputStream.close(); + return stringBuilder.toString(); + } + + /** + * Returns a {@link FirebaseCredential} based on Google Application Default + * Credentials which can be used to authenticate the SDK. + * + *

See + * Google Application Default Credentials + * for details on Google Application Deafult Credentials. + * + *

See Initialize the SDK + * for code samples and detailed documentation. + * + * @return A {@link FirebaseCredential} based on Google Application Default Credentials which can + * be used to authenticate the SDK. + */ + @NonNull + public static FirebaseCredential applicationDefault() { + return DefaultCredentialsHolder.INSTANCE; + } + + @VisibleForTesting + static FirebaseCredential applicationDefault(HttpTransport transport, JsonFactory jsonFactory) { + return new ApplicationDefaultCredential(transport, jsonFactory); + } + + /** + * Returns a {@link FirebaseCredential} generated from the provided service + * account certificate which can be used to authenticate the SDK. + * + *

See Initialize the SDK + * for code samples and detailed documentation. + * + * @param serviceAccount An InputStream containing the JSON representation of a + * service account certificate. + * @return A {@link FirebaseCredential} generated from the provided service account certificate + * which can be used to authenticate the SDK. + */ + @NonNull + public static FirebaseCredential fromCertificate(InputStream serviceAccount) { + checkNotNull(serviceAccount); + return fromCertificate( + serviceAccount, Utils.getDefaultTransport(), Utils.getDefaultJsonFactory()); + } + + @VisibleForTesting + static FirebaseCredential fromCertificate( + InputStream serviceAccount, HttpTransport transport, JsonFactory jsonFactory) { + return new CertCredential(serviceAccount, transport, jsonFactory); + } + + /** + * Returns a {@link FirebaseCredential} generated from the provided refresh + * token which can be used to authenticate the SDK. + * + *

See Initialize the SDK + * for code samples and detailed documentation. + * + * @param refreshToken An InputStream containing the JSON representation of a refresh + * token. + * @return A {@link FirebaseCredential} generated from the provided service account credential + * which can be used to authenticate the SDK. + */ + @NonNull + public static FirebaseCredential fromRefreshToken(InputStream refreshToken) { + checkNotNull(refreshToken); + return fromRefreshToken( + refreshToken, Utils.getDefaultTransport(), Utils.getDefaultJsonFactory()); + } + + @VisibleForTesting + static FirebaseCredential fromRefreshToken( + final InputStream refreshToken, HttpTransport transport, JsonFactory jsonFactory) { + return new RefreshTokenCredential(refreshToken, transport, jsonFactory); + } + + static class Clock { + + protected long now() { + return System.currentTimeMillis(); + } + } + + static class FirebaseAccessToken { + + private final String mToken; + private final long mExpirationTime; + private final Clock mClock; + + FirebaseAccessToken(GoogleCredential credential, Clock clock) { + checkNotNull(credential, "Google credential is required"); + checkNotNull(clock, "Clock is required"); + mToken = checkNotNull(credential.getAccessToken(), + "Access token should not be null after refresh."); + mExpirationTime = credential.getExpirationTimeMilliseconds(); + mClock = clock; + } + + String getToken() { + return mToken; + } + + boolean isExpired() { + return mExpirationTime < mClock.now(); + } + } +} diff --git a/src/main/java/com/google/firebase/auth/FirebaseToken.java b/src/main/java/com/google/firebase/auth/FirebaseToken.java new file mode 100644 index 000000000..00257b489 --- /dev/null +++ b/src/main/java/com/google/firebase/auth/FirebaseToken.java @@ -0,0 +1,193 @@ +package com.google.firebase.auth; + +import com.google.api.client.auth.openidconnect.IdToken; +import com.google.api.client.json.JsonFactory; +import com.google.api.client.json.webtoken.JsonWebSignature; +import com.google.api.client.util.Key; +import java.io.IOException; +import java.util.Map; + +/** + * Implementation of a Parsed Firebase Token returned by {@link FirebaseAuth#verifyIdToken(String)}. + * It can used to get the uid and other attributes of the user provided in the Token. + */ +public final class FirebaseToken { + + private final FirebaseTokenImpl token; + + FirebaseToken(FirebaseTokenImpl token) { + this.token = token; + } + + /** + * Returns the Uid for the this token. + */ + public String getUid() { + return token.getPayload().getSubject(); + } + + /** + * Returns the Issuer for the this token. + */ + public String getIssuer() { + return token.getPayload().getIssuer(); + } + + /** + * Returns the user's display name. + */ + public String getName() { + return token.getPayload().getName(); + } + + /** + * Returns the Uri string of the user's profile photo. + */ + public String getPicture() { + return token.getPayload().getPicture(); + } + + /** + * Returns the e-mail address for this user, or {@code null} if it's unavailable. + */ + public String getEmail() { + return token.getPayload().getEmail(); + } + + /** + * Indicates if the email address returned by {@link #getEmail()} has been verified as good. + */ + public boolean isEmailVerified() { + return token.getPayload().isEmailVerified(); + } + + /** + * Returns a map of all of the claims on this token. + */ + public Map getClaims() { + return token.getPayload(); + } + + FirebaseTokenImpl getToken() { + return token; + } + + static FirebaseToken parse(JsonFactory jsonFactory, String tokenString) + throws IOException { + try { + JsonWebSignature jws = + JsonWebSignature.parser(jsonFactory) + .setPayloadClass(FirebaseTokenImpl.Payload.class) + .parse(tokenString); + return new FirebaseToken( + new FirebaseTokenImpl( + jws.getHeader(), + (FirebaseTokenImpl.Payload) jws.getPayload(), + jws.getSignatureBytes(), + jws.getSignedContentBytes())); + } catch (IOException e) { + throw new IOException( + "Decoding Firebase ID token failed. Make sure you passed the entire string JWT which " + + "represents an ID token. See https://firebase.google.com/docs/auth/admin/" + + "verify-id-tokens for details on how to retrieve an ID token.", + e); + } + + } + + static class FirebaseTokenImpl extends IdToken { + + FirebaseTokenImpl( + Header header, Payload payload, byte[] signatureBytes, byte[] signedContentBytes) { + super(header, payload, signatureBytes, signedContentBytes); + } + + @Override + public Payload getPayload() { + return (Payload) super.getPayload(); + } + + + /** + * Represents a FirebaseWebToken Payload. + */ + public static class Payload extends IdToken.Payload { + + /** + * Timestamp of the last time this user authenticated with Firebase on the device receiving + * this token. + */ + @Key("auth_time") + private long authTime; + + /** + * User's primary email address. + */ + @Key + private String email; + + /** + * Indicates whether or not the e-mail field is verified to be a known-good address. + */ + @Key("email_verified") + private boolean emailVerified; + + /** + * User's Display Name + */ + @Key + private String name; + + /** + * URI of the User's profile picture. + */ + @Key + private String picture; + + /** + * Returns the UID of the user represented by this token. This is an alias for + * {@link #getSubject()} + */ + public String getUid() { + return getSubject(); + } + + /** + * Returns the time in seconds from the Unix Epoch that this user last authenticated with + * Firebase on this device. + */ + public long getAuthTime() { + return authTime; + } + + /** + * Returns the e-mail address for this user, or {@code null} if it's unavailable. + */ + public String getEmail() { + return email; + } + + /** + * Indicates if the email address returned by {@link #getEmail()} has been verified as good. + */ + public boolean isEmailVerified() { + return emailVerified; + } + + /** + * Returns the user's display name. + */ + public String getName() { + return name; + } + + /** + * Returns the Uri string of the user's profile photo. + */ + public String getPicture() { + return picture; + } + } + } + +} diff --git a/src/main/java/com/google/firebase/auth/TestOnlyImplFirebaseAuthTrampolines.java b/src/main/java/com/google/firebase/auth/TestOnlyImplFirebaseAuthTrampolines.java new file mode 100644 index 000000000..2baaa68df --- /dev/null +++ b/src/main/java/com/google/firebase/auth/TestOnlyImplFirebaseAuthTrampolines.java @@ -0,0 +1,56 @@ +package com.google.firebase.auth; + +import com.google.api.client.googleapis.auth.oauth2.GoogleCredential; +import com.google.api.client.googleapis.auth.oauth2.GooglePublicKeysManager; +import com.google.api.client.json.JsonFactory; +import com.google.api.client.util.Clock; +import com.google.firebase.FirebaseApp; +import com.google.firebase.FirebaseException; +import com.google.firebase.tasks.Task; +import com.google.firebase.tasks.Tasks; +import java.io.IOException; + +/** + * Provides trampolines into package-private Auth APIs used by components of Firebase + * + *

This class will not be compiled into the shipping library and can only be used in tests. + * + * @hide + */ +public final class TestOnlyImplFirebaseAuthTrampolines { + + private TestOnlyImplFirebaseAuthTrampolines() { + } + + /* FirebaseApp */ + public static FirebaseToken.FirebaseTokenImpl getToken(FirebaseToken tokenHolder) { + return tokenHolder.getToken(); + } + + /* FirebaseToken */ + public static FirebaseToken parseToken(JsonFactory jsonFactory, String tokenString) + throws IOException { + return FirebaseToken.parse(jsonFactory, tokenString); + } + + /* FirebaseCredentials */ + public static Task getCertificate(FirebaseCredential credential) { + if (credential instanceof FirebaseCredentials.CertCredential) { + return ((FirebaseCredentials.CertCredential) credential).getCertificate(false); + } else { + return Tasks.forException(new FirebaseException("Cannot convert to CertCredential")); + } + } + + /* FirebaseCredentials */ + public static Task getProjectId(FirebaseCredential credential) { + return ((FirebaseCredentials.CertCredential) credential).getProjectId(false); + } + + /* FirebaseAuth */ + public static FirebaseAuth getFirebaseAuthInstance( + FirebaseApp firebaseApp, GooglePublicKeysManager googlePublicKeysManager, Clock clock) { + return new FirebaseAuth(firebaseApp, googlePublicKeysManager, clock); + } +} + diff --git a/src/main/java/com/google/firebase/auth/internal/FirebaseCustomAuthToken.java b/src/main/java/com/google/firebase/auth/internal/FirebaseCustomAuthToken.java new file mode 100644 index 000000000..3f9a62f92 --- /dev/null +++ b/src/main/java/com/google/firebase/auth/internal/FirebaseCustomAuthToken.java @@ -0,0 +1,111 @@ +package com.google.firebase.auth.internal; + +import com.google.api.client.auth.openidconnect.IdToken; +import com.google.api.client.json.GenericJson; +import com.google.api.client.json.JsonFactory; +import com.google.api.client.json.webtoken.JsonWebSignature; +import com.google.api.client.util.Key; +import com.google.firebase.auth.FirebaseToken; +import java.io.IOException; + +/** + * Implementation of a JWT used for Firebase Custom Auth. + * + *

These JWTs are minted by the developer's application and signed by the developer's Private Key + * and used to trigger an authentication event. These will be exchanged with SecureTokenService for + * a {@link FirebaseToken}, which is what will actually be sent to Google to perform actions + * against the Firebase APIs on behalf of the user created, or signed in, using a + * FirebaseCustomAuthToken. + */ +public final class FirebaseCustomAuthToken extends IdToken { + + static final String FIREBASE_AUDIENCE = + "https://identitytoolkit.googleapis.com/google.identity.identitytoolkit.v1.IdentityToolkit"; + static final long TOKEN_DURATION_SECONDS = 3600L; // 1 hour + + public FirebaseCustomAuthToken( + Header header, Payload payload, byte[] signatureBytes, byte[] signedContentBytes) { + super(header, payload, signatureBytes, signedContentBytes); + } + + @Override + public Payload getPayload() { + return (Payload) super.getPayload(); + } + + public static FirebaseCustomAuthToken parse(JsonFactory jsonFactory, String tokenString) + throws IOException { + JsonWebSignature jws = JsonWebSignature.parser(jsonFactory) + .setPayloadClass(Payload.class) + .parse(tokenString); + return new FirebaseCustomAuthToken( + jws.getHeader(), + (Payload) jws.getPayload(), + jws.getSignatureBytes(), + jws.getSignedContentBytes()); + } + + /** + * Represents a FirebaseCustomAuthToken Payload. + */ + public static class Payload extends IdToken.Payload { + + /** + * The uid of the user to store in the Firebase data store. + */ + @Key("uid") + private String uid; + + /** + * Any additional claims the developer wishes stored and signed by Firebase. + * + *

TODO(jeffcraig@google.com): Come up with a solution to allow this to be parsed as the + * correct type. + */ + @Key("claims") + private GenericJson developerClaims; + + public final String getUid() { + return uid; + } + + public Payload setUid(String uid) { + this.uid = uid; + return this; + } + + public final GenericJson getDeveloperClaims() { + return developerClaims; + } + + public Payload setDeveloperClaims(GenericJson developerClaims) { + this.developerClaims = developerClaims; + return this; + } + + @Override + public Payload setIssuer(String issuer) { + return (Payload) super.setIssuer(issuer); + } + + @Override + public Payload setSubject(String subject) { + return (Payload) super.setSubject(subject); + } + + @Override + public Payload setAudience(Object audience) { + return (Payload) super.setAudience(audience); + } + + @Override + public Payload setIssuedAtTimeSeconds(Long issuedAt) { + return (Payload) super.setIssuedAtTimeSeconds(issuedAt); + } + + @Override + public Payload setExpirationTimeSeconds(Long expirationTime) { + return (Payload) super.setExpirationTimeSeconds(expirationTime); + } + } +} diff --git a/src/main/java/com/google/firebase/auth/internal/FirebaseTokenFactory.java b/src/main/java/com/google/firebase/auth/internal/FirebaseTokenFactory.java new file mode 100644 index 000000000..2e797e2b8 --- /dev/null +++ b/src/main/java/com/google/firebase/auth/internal/FirebaseTokenFactory.java @@ -0,0 +1,84 @@ +package com.google.firebase.auth.internal; + +import com.google.api.client.json.GenericJson; +import com.google.api.client.json.JsonFactory; +import com.google.api.client.json.gson.GsonFactory; +import com.google.api.client.json.webtoken.JsonWebSignature; +import com.google.api.client.util.Clock; +import com.google.common.base.Preconditions; +import java.io.IOException; +import java.security.GeneralSecurityException; +import java.security.PrivateKey; +import java.util.Collection; +import java.util.Map; + +/** + * Provides helper methods to simplify the creation of FirebaseCustomAuthTokens. + * + *

This class is designed to hide underlying implementation details from a Firebase developer. + */ +public class FirebaseTokenFactory { + + private static FirebaseTokenFactory instance; + + private JsonFactory factory; + private Clock clock; + + public FirebaseTokenFactory(JsonFactory factory, Clock clock) { + this.factory = factory; + this.clock = clock; + } + + public static FirebaseTokenFactory getInstance() { + if (null == instance) { + instance = new FirebaseTokenFactory(new GsonFactory(), Clock.SYSTEM); + } + + return instance; + } + + public String createSignedCustomAuthTokenForUser( + String uid, + String issuer, + PrivateKey privateKey) throws GeneralSecurityException, IOException { + return createSignedCustomAuthTokenForUser(uid, null, issuer, privateKey); + } + + public String createSignedCustomAuthTokenForUser( + String uid, + Map developerClaims, + String issuer, + PrivateKey privateKey) throws GeneralSecurityException, IOException { + Preconditions.checkState(uid != null, "Uid must be provided."); + Preconditions.checkState(issuer != null && !"".equals(issuer), + "Must provide an issuer."); + Preconditions.checkState(uid.length() <= 128, "Uid must be shorter than 128 characters."); + + JsonWebSignature.Header header = new JsonWebSignature.Header() + .setAlgorithm("RS256"); + + long issuedAt = clock.currentTimeMillis() / 1000; + FirebaseCustomAuthToken.Payload payload = new FirebaseCustomAuthToken.Payload() + .setUid(uid) + .setIssuer(issuer) + .setSubject(issuer) + .setAudience(FirebaseCustomAuthToken.FIREBASE_AUDIENCE) + .setIssuedAtTimeSeconds(issuedAt) + .setExpirationTimeSeconds(issuedAt + FirebaseCustomAuthToken.TOKEN_DURATION_SECONDS); + + if (developerClaims != null) { + Collection reservedNames = payload.getClassInfo().getNames(); + for (String key : developerClaims.keySet()) { + if (reservedNames.contains(key)) { + throw new IllegalArgumentException( + String.format("developer_claims can not contain a reserved key: %s", key)); + } + } + GenericJson jsonObject = new GenericJson(); + jsonObject.putAll(developerClaims); + payload.setDeveloperClaims(jsonObject); + } + + return JsonWebSignature.signUsingRsaSha256(privateKey, factory, header, payload); + } +} diff --git a/src/main/java/com/google/firebase/auth/internal/FirebaseTokenVerifier.java b/src/main/java/com/google/firebase/auth/internal/FirebaseTokenVerifier.java new file mode 100644 index 000000000..5fbcf1227 --- /dev/null +++ b/src/main/java/com/google/firebase/auth/internal/FirebaseTokenVerifier.java @@ -0,0 +1,216 @@ +package com.google.firebase.auth.internal; + +import com.google.api.client.auth.openidconnect.IdToken; +import com.google.api.client.auth.openidconnect.IdToken.Payload; +import com.google.api.client.auth.openidconnect.IdTokenVerifier; +import com.google.api.client.googleapis.auth.oauth2.GooglePublicKeysManager; +import com.google.api.client.http.javanet.NetHttpTransport; +import com.google.api.client.json.gson.GsonFactory; +import com.google.api.client.json.webtoken.JsonWebSignature.Header; +import com.google.api.client.util.ArrayMap; +import com.google.api.client.util.Clock; +import com.google.common.annotations.VisibleForTesting; +import com.google.common.base.Preconditions; +import com.google.firebase.auth.FirebaseAuthException; +import java.io.IOException; +import java.math.BigDecimal; +import java.security.GeneralSecurityException; +import java.security.PublicKey; +import java.util.Collection; +import java.util.Collections; + +/** + * Verifies that a JWT returned by Firebase is valid for use in the this project. + * + * This class should be kept as a Singleton within the server in order to maximize caching of the + * public signing keys. + */ +public final class FirebaseTokenVerifier extends IdTokenVerifier { + + private static final String ISSUER_PREFIX = "https://securetoken.google.com/"; + + @VisibleForTesting + static final String CLIENT_CERT_URL = "https://www.googleapis.com/robot/v1/metadata/x509/" + + "securetoken@system.gserviceaccount.com"; + private static final String FIREBASE_AUDIENCE = + "https://identitytoolkit.googleapis.com/google.identity.identitytoolkit.v1.IdentityToolkit"; + private static final String ERROR_CODE = "ERROR_INVALID_CREDENTIAL"; + private static final String PROJECT_ID_MATCH_MESSAGE = + " Make sure the ID token comes from the same Firebase project as the service account used to " + + "authenticate this SDK."; + private static final String VERIFY_ID_TOKEN_DOCS_MESSAGE = + " See https://firebase.google.com/docs/auth/admin/verify-id-tokens for details on how to " + + "retrieve an ID token."; + private static final String ALGORITHM = "RS256"; + private String projectId; + + private GooglePublicKeysManager publicKeysManager; + + /** + * The default public keys manager for verifying projects use the correct public key + */ + public static final GooglePublicKeysManager DEFAULT_KEY_MANAGER = + new GooglePublicKeysManager.Builder(new NetHttpTransport.Builder().build(), new GsonFactory()) + .setClock(Clock.SYSTEM) + .setPublicCertsEncodedUrl(CLIENT_CERT_URL) + .build(); + + protected FirebaseTokenVerifier(Builder builder) { + super(builder); + Preconditions.checkArgument(builder.projectId != null, "projectId must be set"); + + this.projectId = builder.projectId; + this.publicKeysManager = builder.publicKeysManager; + } + + /** + * We are changing the semantics of the super-class method in order to provide more details on why + * this is failing to the developer. + */ + public boolean verifyTokenAndSignature(IdToken token) throws FirebaseAuthException { + Payload payload = token.getPayload(); + Header header = token.getHeader(); + String errorMessage = null; + + boolean isCustomToken = + payload.getAudience() != null && payload.getAudience().equals(FIREBASE_AUDIENCE); + boolean isLegacyCustomToken = + header.getAlgorithm() != null + && header.getAlgorithm().equals("HS256") + && payload.get("v") != null + && payload.get("v").equals(new BigDecimal(0)) + && payload.get("d") != null + && payload.get("d") instanceof ArrayMap + && ((ArrayMap) payload.get("d")).get("uid") != null; + + if (header.getKeyId() == null) { + if (isCustomToken) { + errorMessage = "verifyIdToken() expects an ID token, but was given a custom token."; + } else if (isLegacyCustomToken) { + errorMessage = "verifyIdToken() expects an ID token, but was given a legacy custom token."; + } else { + errorMessage = "Firebase ID token has no \"kid\" claim."; + } + } else if (header.getAlgorithm() == null || !header.getAlgorithm().equals(ALGORITHM)) { + errorMessage = + String.format( + "Firebase ID token has incorrect algorithm. Expected \"%s\" but got \"%s\".", + ALGORITHM, header.getAlgorithm()); + } else if (!token.verifyAudience(getAudience())) { + errorMessage = + String.format( + "Firebase ID token has incorrect \"aud\" (audience) claim. Expected \"%s\" but got " + + "\"%s\".", + concat(getAudience()), concat(token.getPayload().getAudienceAsList())); + errorMessage += PROJECT_ID_MATCH_MESSAGE; + } else if (!token.verifyIssuer(getIssuers())) { + errorMessage = + String.format( + "Firebase ID token has incorrect \"iss\" (issuer) claim. " + + "Expected \"%s\" but got \"%s\".", + concat(getIssuers()), token.getPayload().getIssuer()); + errorMessage += PROJECT_ID_MATCH_MESSAGE; + } else if (payload.getSubject() == null) { + errorMessage = "Firebase ID token has no \"sub\" (subject) claim."; + } else if (payload.getSubject().isEmpty()) { + errorMessage = "Firebase ID token has an empty string \"sub\" (subject) claim."; + } else if (payload.getSubject().length() > 128) { + errorMessage = "Firebase ID token has \"sub\" (subject) claim longer than 128 characters."; + } else if (!token.verifyTime(getClock().currentTimeMillis(), getAcceptableTimeSkewSeconds())) { + errorMessage = + "Firebase ID token has expired or is not yet valid. Get a fresh token from your client " + + "app and try again."; + } + + if (errorMessage != null) { + errorMessage += VERIFY_ID_TOKEN_DOCS_MESSAGE; + throw new FirebaseAuthException(ERROR_CODE, errorMessage); + } + + try { + if (!verifySignature(token)) { + throw new FirebaseAuthException( + ERROR_CODE, + "Firebase ID token isn't signed by a valid public key." + VERIFY_ID_TOKEN_DOCS_MESSAGE); + } + } catch (IOException | GeneralSecurityException e) { + throw new FirebaseAuthException( + ERROR_CODE, "Firebase ID token has invalid signature." + VERIFY_ID_TOKEN_DOCS_MESSAGE); + } + + return true; + } + + private String concat(Collection collection) { + StringBuilder stringBuilder = new StringBuilder(); + for (String inputLine : collection) { + stringBuilder.append(inputLine.trim()).append(", "); + } + return stringBuilder.substring(0, stringBuilder.length() - 2); + } + + /** + * Verifies the cryptographic signature on the FirebaseToken. Can block on a web request to + * fetch the keys if they have expired. + * + * TODO(jeffcraig): Wrap these blocking steps in a Task. + */ + private boolean verifySignature(IdToken token) + throws GeneralSecurityException, IOException { + for (PublicKey key : publicKeysManager.getPublicKeys()) { + if (token.verifySignature(key)) { + return true; + } + } + return false; + } + + public String getProjectId() { + return projectId; + } + + /** + * Builder for {@link FirebaseTokenVerifier}. + */ + public static class Builder extends IdTokenVerifier.Builder { + + String projectId; + + GooglePublicKeysManager publicKeysManager = DEFAULT_KEY_MANAGER; + + public String getProjectId() { + return projectId; + } + + public Builder setProjectId(String projectId) { + this.projectId = projectId; + + this.setIssuer(ISSUER_PREFIX + projectId); + this.setAudience(Collections.singleton(projectId)); + + return this; + } + + @Override + public Builder setClock(Clock clock) { + return (Builder) super.setClock(clock); + } + + public GooglePublicKeysManager getPublicKeyManager() { + return publicKeysManager; + } + + /** + * Override the GooglePublicKeysManager from the default. + */ + public Builder setPublicKeysManager(GooglePublicKeysManager publicKeysManager) { + this.publicKeysManager = publicKeysManager; + return this; + } + + @Override + public FirebaseTokenVerifier build() { + return new FirebaseTokenVerifier(this); + } + } +} diff --git a/src/main/java/com/google/firebase/database/ChildEventListener.java b/src/main/java/com/google/firebase/database/ChildEventListener.java new file mode 100644 index 000000000..553578f6e --- /dev/null +++ b/src/main/java/com/google/firebase/database/ChildEventListener.java @@ -0,0 +1,59 @@ +package com.google.firebase.database; + +/** + * Classes implementing this interface can be used to receive events about changes in the child + * locations of a given {@link DatabaseReference DatabaseReference} ref. Attach the listener to a + * location using {@link DatabaseReference#addChildEventListener(ChildEventListener)} and the + * appropriate method will be triggered when changes occur. + */ +public interface ChildEventListener { + + /** + * This method is triggered when a new child is added to the location to which this listener was + * added. + * + * @param snapshot An immutable snapshot of the data at the new child location + * @param previousChildName The key name of sibling location ordered before the new child. This + * will be null for the first child node of a location. + */ + void onChildAdded(DataSnapshot snapshot, String previousChildName); + + /** + * This method is triggered when the data at a child location has changed. + * + * @param snapshot An immutable snapshot of the data at the new data at the child location + * @param previousChildName The key name of sibling location ordered before the child. This will + * be null for the first child node of a location. + */ + void onChildChanged(DataSnapshot snapshot, String previousChildName); + + /** + * This method is triggered when a child is removed from the location to which this listener was + * added. + * + * @param snapshot An immutable snapshot of the data at the child that was removed. + */ + void onChildRemoved(DataSnapshot snapshot); + + /** + * This method is triggered when a child location's priority changes. See {@link + * DatabaseReference#setPriority(Object)} and Ordered Data for more information on priorities and ordering data. + * + * @param snapshot An immutable snapshot of the data at the location that moved. + * @param previousChildName The key name of the sibling location ordered before the child + * location. This will be null if this location is ordered first. + */ + void onChildMoved(DataSnapshot snapshot, String previousChildName); + + /** + * This method will be triggered in the event that this listener either failed at the server, or + * is removed as a result of the security and Firebase rules. For more information on securing + * your data, see: Security Quickstart + * + * @param error A description of the error that occurred + */ + void onCancelled(DatabaseError error); +} diff --git a/src/main/java/com/google/firebase/database/DataSnapshot.java b/src/main/java/com/google/firebase/database/DataSnapshot.java new file mode 100644 index 000000000..d51a5b7f6 --- /dev/null +++ b/src/main/java/com/google/firebase/database/DataSnapshot.java @@ -0,0 +1,294 @@ +package com.google.firebase.database; + +import com.google.firebase.database.core.Path; +import com.google.firebase.database.snapshot.IndexedNode; +import com.google.firebase.database.snapshot.NamedNode; +import com.google.firebase.database.snapshot.Node; +import com.google.firebase.database.utilities.Validation; +import com.google.firebase.database.utilities.encoding.CustomClassMapper; +import java.util.Iterator; + +/** + * A DataSnapshot instance contains data from a Firebase Database location. Any time you read + * Database data, you receive the data as a DataSnapshot.
+ *
+ * DataSnapshots are passed to the methods in listeners that you attach with {@link + * DatabaseReference#addValueEventListener(ValueEventListener)}, {@link + * DatabaseReference#addChildEventListener(ChildEventListener)}, or {@link + * DatabaseReference#addListenerForSingleValueEvent(ValueEventListener)}.
+ *
+ * They are efficiently-generated immutable copies of the data at a Firebase Database location. They + * can't be modified and will never change. To modify data at a location, use a {@link + * DatabaseReference DatabaseReference} reference (e.g. with {@link + * DatabaseReference#setValue(Object)}). + */ +public class DataSnapshot { + + private final IndexedNode node; + private final DatabaseReference query; + + /** + * @param ref A DatabaseReference + * @param node The indexed node + */ + DataSnapshot(DatabaseReference ref, IndexedNode node) { + this.node = node; + this.query = ref; + } + + /** + * Get a DataSnapshot for the location at the specified relative path. The relative path can + * either be a simple child key (e.g. 'fred') or a deeper slash-separated path (e.g. + * 'fred/name/first'). If the child location has no data, an empty DataSnapshot is returned. + * + * @param path A relative path to the location of child data + * @return The DataSnapshot for the child location + */ + public DataSnapshot child(String path) { + DatabaseReference childRef = query.child(path); + Node childNode = this.node.getNode().getChild(new Path(path)); + return new DataSnapshot(childRef, IndexedNode.from(childNode)); + } + + /** + * Can be used to determine if this DataSnapshot has data at a particular location + * + * @param path A relative path to the location of child data + * @return Whether or not the specified child location has data + */ + public boolean hasChild(String path) { + if (query.getParent() == null) { + Validation.validateRootPathString(path); + } else { + Validation.validatePathString(path); + } + return !node.getNode().getChild(new Path(path)).isEmpty(); + } + + /** + * Indicates whether this snapshot has any children + * + * @return True if the snapshot has any children, otherwise false + */ + public boolean hasChildren() { + return node.getNode().getChildCount() > 0; + } + + /** + * Returns true if the snapshot contains a non-null value. + * + * @return True if the snapshot contains a non-null value, otherwise false + */ + public boolean exists() { + return !node.getNode().isEmpty(); + } + + /** + * getValue() returns the data contained in this snapshot as native types. The possible types + * returned are: + * + *

    + *
  • Boolean + *
  • String + *
  • Long + *
  • Double + *
  • Map<String, Object> + *
  • List<Object> + *
+ * + * This list is recursive; the possible types for {@link java.lang.Object} in the above list is + * given by the same list. These types correspond to the types available in JSON. + * + * @return The data contained in this snapshot as native types + */ + public Object getValue() { + return node.getNode().getValue(); + } + + /** + * getValue() returns the data contained in this snapshot as native types. The possible types + * returned are: + * + *
    + *
  • Boolean + *
  • String + *
  • Long + *
  • Double + *
  • Map<String, Object> + *
  • List<Object> + *
+ * + * This list is recursive; the possible types for {@link java.lang.Object} in the above list is + * given by the same list. These types correspond to the types available in JSON. + * + *

If useExportFormat is set to true, priority information will be included in the output. + * Priority information shows up as a .priority key in a map. For data that would not otherwise be + * a map, the map will also include a .value key with the data. + * + * @param useExportFormat Whether or not to include priority information + * @return The data, along with its priority, in native types + */ + public Object getValue(boolean useExportFormat) { + return node.getNode().getValue(useExportFormat); + } + + /** + * This method is used to marshall the data contained in this snapshot into a class of your + * choosing. The class must fit 2 simple constraints: + * + *

    + *
  1. The class must have a default constructor that takes no arguments + *
  2. The class must define public getters for the properties to be assigned. Properties + * without a public getter will be set to their default value when an instance is + * deserialized + *
+ * + * An example class might look like: + * + *

+   *     class Message {
+   *         private String author;
+   *         private String text;
+   *
+   *         private Message() {}
+   *
+   *         public Message(String author, String text) {
+   *             this.author = author;
+   *             this.text = text;
+   *         }
+   *
+   *         public String getAuthor() {
+   *             return author;
+   *         }
+   *
+   *         public String getText() {
+   *             return text;
+   *         }
+   *     }
+   *
+   *
+   *     // Later
+   *     Message m = snapshot.getValue(Message.class);
+   * 
+ * + * @param valueType The class into which this snapshot should be marshalled + * @param The type to return. Implicitly defined from the class passed in + * @return An instance of the class passed in, populated with the data from this snapshot + */ + public T getValue(Class valueType) { + Object value = node.getNode().getValue(); + return CustomClassMapper.convertToCustomClass(value, valueType); + } + + /** + * Due to the way that Java implements generics, it takes an extra step to get back a + * properly-typed Collection. So, in the case where you want a {@link java.util.List} of Message + * instances, you will need to do something like the following: + * + *

+   *     GenericTypeIndicator<List<Message>> t = new GenericTypeIndicator<List<Message>>() {};
+   *     List<Message> messages = snapshot.getValue(t);
+   * 
+ * + * It is important to use a subclass of {@link GenericTypeIndicator}. See {@link + * GenericTypeIndicator} for more details + * + * @param t A subclass of {@link GenericTypeIndicator} indicating the type of generic collection + * to be returned. + * @param The type to return. Implicitly defined from the {@link GenericTypeIndicator} passed + * in + * @return A properly typed collection, populated with the data from this snapshot + */ + public T getValue(GenericTypeIndicator t) { + Object value = node.getNode().getValue(); + return CustomClassMapper.convertToCustomClass(value, t); + } + + /** @return The number of immediate children in the this snapshot */ + public long getChildrenCount() { + return node.getNode().getChildCount(); + } + + /** + * Used to obtain a reference to the source location for this snapshot. + * + * @return A DatabaseReference corresponding to the location that this snapshot came from + */ + public DatabaseReference getRef() { + return query; + } + + /** @return the key name for the source location of this snapshot */ + public String getKey() { + return query.getKey(); + } + + /** + * Gives access to all of the immediate children of this snapshot. Can be used in native for + * loops: + *
for (DataSnapshot child : parent.getChildren()) { + *
    ... + *
} + *
+ * + * @return The immediate children of this snapshot + */ + public Iterable getChildren() { + final Iterator iter = node.iterator(); + return new Iterable() { + + @Override + public Iterator iterator() { + return new Iterator() { + @Override + public boolean hasNext() { + return iter.hasNext(); + } + + @Override + public DataSnapshot next() { + NamedNode namedNode = iter.next(); + return new DataSnapshot( + query.child(namedNode.getName().asString()), IndexedNode.from(namedNode.getNode())); + } + + @Override + public void remove() { + throw new UnsupportedOperationException("remove called on immutable collection"); + } + }; + } + }; + } + + /** + * Returns the priority of the data contained in this snapshot as a native type. Possible return + * types: + * + *
    + *
  • Double + *
  • String + *
+ * + * Note that null is also allowed + * + * @return the priority of the data contained in this snapshot as a native type + */ + public Object getPriority() { + Object priority = node.getNode().getPriority().getValue(); + if (priority instanceof Long) { + return Double.valueOf((Long) priority); + } else { + return priority; + } + } + + @Override + public String toString() { + return "DataSnapshot { key = " + + this.query.getKey() + + ", value = " + + this.node.getNode().getValue(true) + + " }"; + } +} diff --git a/src/main/java/com/google/firebase/database/DatabaseError.java b/src/main/java/com/google/firebase/database/DatabaseError.java new file mode 100644 index 000000000..9d62bfd0c --- /dev/null +++ b/src/main/java/com/google/firebase/database/DatabaseError.java @@ -0,0 +1,235 @@ +package com.google.firebase.database; + +import java.io.PrintWriter; +import java.io.StringWriter; +import java.util.HashMap; +import java.util.Map; + +/** + * Instances of DatabaseError are passed to callbacks when an operation failed. They contain a + * description of the specific error that occurred. + */ +public class DatabaseError { + + /** + * Internal use + */ + public static final int DATA_STALE = -1; + /** + * The server indicated that this operation failed + */ + public static final int OPERATION_FAILED = -2; + /** + * This client does not have permission to perform this operation + */ + public static final int PERMISSION_DENIED = -3; + /** + * The operation had to be aborted due to a network disconnect + */ + public static final int DISCONNECTED = -4; + + // Preempted was removed, this is for here for completeness and history + // public static final int PREEMPTED = -5; + + /** + * The supplied auth token has expired + */ + public static final int EXPIRED_TOKEN = -6; + /** + * The specified authentication token is invalid. This can occur when the token is malformed, + * expired, or the secret that was used to generate it has been revoked. + */ + public static final int INVALID_TOKEN = -7; + /** + * The transaction had too many retries + */ + public static final int MAX_RETRIES = -8; + /** + * The transaction was overridden by a subsequent set + */ + public static final int OVERRIDDEN_BY_SET = -9; + /** + * The service is unavailable + */ + public static final int UNAVAILABLE = -10; + /** + * An exception occurred in user code + */ + public static final int USER_CODE_EXCEPTION = -11; + + // client codes + /** + * The operation could not be performed due to a network error. + */ + public static final int NETWORK_ERROR = -24; + + /** + * The write was canceled locally + */ + public static final int WRITE_CANCELED = -25; + + /** + * An unknown error occurred. Please refer to the error message and error details for more + * information. + */ + public static final int UNKNOWN_ERROR = -999; + + private static final Map errorReasons = new HashMap<>(); + + static { + // Firebase Database error codes + errorReasons.put(DATA_STALE, "The transaction needs to be run again with current data"); + errorReasons.put(OPERATION_FAILED, "The server indicated that this operation failed"); + errorReasons.put( + PERMISSION_DENIED, "This client does not have permission to perform this operation"); + errorReasons.put(DISCONNECTED, "The operation had to be aborted due to a network disconnect"); + errorReasons.put(EXPIRED_TOKEN, "The supplied auth token has expired"); + errorReasons.put(INVALID_TOKEN, "The supplied auth token was invalid"); + errorReasons.put(MAX_RETRIES, "The transaction had too many retries"); + errorReasons.put(OVERRIDDEN_BY_SET, "The transaction was overridden by a subsequent set"); + errorReasons.put(UNAVAILABLE, "The service is unavailable"); + errorReasons.put( + USER_CODE_EXCEPTION, + "User code called from the Firebase Database runloop threw an exception:\n"); + + // client codes + errorReasons.put(NETWORK_ERROR, "The operation could not be performed due to a network error"); + errorReasons.put(WRITE_CANCELED, "The write was canceled by the user."); + errorReasons.put(UNKNOWN_ERROR, "An unknown error occurred"); + } + + private static final Map errorCodes = new HashMap<>(); + + static { + + // Firebase Database error codes + errorCodes.put("datastale", DATA_STALE); + errorCodes.put("failure", OPERATION_FAILED); + errorCodes.put("permission_denied", PERMISSION_DENIED); + errorCodes.put("disconnected", DISCONNECTED); + errorCodes.put("expired_token", EXPIRED_TOKEN); + errorCodes.put("invalid_token", INVALID_TOKEN); + errorCodes.put("maxretries", MAX_RETRIES); + errorCodes.put("overriddenbyset", OVERRIDDEN_BY_SET); + errorCodes.put("unavailable", UNAVAILABLE); + + // client codes + errorCodes.put("network_error", NETWORK_ERROR); + errorCodes.put("write_canceled", WRITE_CANCELED); + } + + /** + * For internal use + * + * @param status The status string + * @return An error corresponding the to the status + * @hide + */ + public static DatabaseError fromStatus(String status) { + return fromStatus(status, null); + } + + /** + * For internal use + * + * @param status The status string + * @param reason The reason for the error + * @return An error corresponding the to the status + * @hide + */ + public static DatabaseError fromStatus(String status, String reason) { + return fromStatus(status, reason, null); + } + + /** + * For internal use + * + * @param code The error code + * @return An error corresponding the to the code + * @hide + */ + public static DatabaseError fromCode(int code) { + if (!errorReasons.containsKey(code)) { + throw new IllegalArgumentException("Invalid Firebase Database error code: " + code); + } + String message = errorReasons.get(code); + return new DatabaseError(code, message, null); + } + + /** + * For internal use + * + * @param status The status string + * @param reason The reason for the error + * @param details Additional details or null + * @return An error corresponding the to the status + * @hide + */ + public static DatabaseError fromStatus(String status, String reason, String details) { + Integer code = errorCodes.get(status.toLowerCase()); + if (code == null) { + code = UNKNOWN_ERROR; + } + + String message = reason == null ? errorReasons.get(code) : reason; + return new DatabaseError(code, message, details); + } + + public static DatabaseError fromException(Throwable e) { + StringWriter stringWriter = new StringWriter(); + PrintWriter printWriter = new PrintWriter(stringWriter); + e.printStackTrace(printWriter); + String reason = errorReasons.get(USER_CODE_EXCEPTION) + stringWriter.toString(); + return new DatabaseError(USER_CODE_EXCEPTION, reason); + } + + private final int code; + private final String message; + private final String details; + + private DatabaseError(int code, String message) { + this(code, message, null); + } + + private DatabaseError(int code, String message, String details) { + this.code = code; + this.message = message; + this.details = (details == null) ? "" : details; + } + + /** + * @return One of the defined status codes, depending on the error + */ + public int getCode() { + return code; + } + + /** + * @return A human-readable description of the error + */ + public String getMessage() { + return message; + } + + /** + * @return Human-readable details on the error and additional information, e.g. links to docs; + */ + public String getDetails() { + return details; + } + + @Override + public String toString() { + return "DatabaseError: " + message; + } + + /** + * Can be used if a third party needs an Exception from Firebase Database for integration + * purposes. + * + * @return An exception wrapping this error, with an appropriate message and no stack trace. + */ + public DatabaseException toException() { + return new DatabaseException("Firebase Database error: " + message); + } +} diff --git a/src/main/java/com/google/firebase/database/DatabaseException.java b/src/main/java/com/google/firebase/database/DatabaseException.java new file mode 100644 index 000000000..a437f68ea --- /dev/null +++ b/src/main/java/com/google/firebase/database/DatabaseException.java @@ -0,0 +1,29 @@ +package com.google.firebase.database; + +/** + * This error is thrown when the Firebase Database library is unable to operate on the input it has + * been given. + */ +public class DatabaseException extends RuntimeException { + + /** + * For internal use + * + * @param message A human readable description of the error + * @hide + */ + public DatabaseException(String message) { + super(message); + } + + /** + * For internal use + * + * @param message A human readable description of the error + * @param cause The underlying cause for this error + * @hide + */ + public DatabaseException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/src/main/java/com/google/firebase/database/DatabaseReference.java b/src/main/java/com/google/firebase/database/DatabaseReference.java new file mode 100644 index 000000000..10d7aa4c5 --- /dev/null +++ b/src/main/java/com/google/firebase/database/DatabaseReference.java @@ -0,0 +1,603 @@ +package com.google.firebase.database; + +import com.google.firebase.database.core.CompoundWrite; +import com.google.firebase.database.core.DatabaseConfig; +import com.google.firebase.database.core.Path; +import com.google.firebase.database.core.Repo; +import com.google.firebase.database.core.RepoManager; +import com.google.firebase.database.core.ValidationPath; +import com.google.firebase.database.snapshot.ChildKey; +import com.google.firebase.database.snapshot.Node; +import com.google.firebase.database.snapshot.NodeUtilities; +import com.google.firebase.database.snapshot.PriorityUtilities; +import com.google.firebase.database.utilities.Pair; +import com.google.firebase.database.utilities.ParsedUrl; +import com.google.firebase.database.utilities.PushIdGenerator; +import com.google.firebase.database.utilities.Utilities; +import com.google.firebase.database.utilities.Validation; +import com.google.firebase.database.utilities.encoding.CustomClassMapper; +import com.google.firebase.tasks.Task; +import java.io.UnsupportedEncodingException; +import java.net.URLEncoder; +import java.util.Map; + +/** + * A Firebase reference represents a particular location in your Database and can be used for + * reading or writing data to that Database location. + * + *

This class is the starting point for all Database operations. After you've initialized it with + * a URL, you can use it to read data, write data, and to create new DatabaseReferences. + */ +public class DatabaseReference extends Query { + + private static DatabaseConfig defaultConfig; + + /** + * This interface is used as a method of being notified when an operation has been acknowledged by + * the Database servers and can be considered complete + * + * @since 1.1 + */ + public interface CompletionListener { + + /** + * This method will be triggered when the operation has either succeeded or failed. If it has + * failed, an error will be given. If it has succeeded, the error will be null + * + * @param error A description of any errors that occurred or null on success + * @param ref A reference to the specified Firebase Database location + */ + void onComplete(final DatabaseError error, final DatabaseReference ref); + } + + /** + * @param repo The repo for this ref + * @param path The path to reference + */ + DatabaseReference(Repo repo, Path path) { + super(repo, path); + } + + /** + * Legacy method left here (as package private) for tests. + */ + DatabaseReference(String url, DatabaseConfig config) { + this(Utilities.parseUrl(url), config); + } + + private DatabaseReference(ParsedUrl parsedUrl, DatabaseConfig config) { + this(RepoManager.getRepo(config, parsedUrl.repoInfo), parsedUrl.path); + } + + /** + * Get a reference to location relative to this one + * + * @param pathString The relative path from this reference to the new one that should be created + * @return A new DatabaseReference to the given path + */ + public DatabaseReference child(String pathString) { + if (pathString == null) { + throw new NullPointerException("Can't pass null for argument 'pathString' in child()"); + } + if (getPath().isEmpty()) { + // If this is the root of the tree, allow '.info' nodes. + Validation.validateRootPathString(pathString); + } else { + Validation.validatePathString(pathString); + } + Path childPath = getPath().child(new Path(pathString)); + return new DatabaseReference(repo, childPath); + } + + /** + * Create a reference to an auto-generated child location. The child key is generated client-side + * and incorporates an estimate of the server's time for sorting purposes. Locations generated on + * a single client will be sorted in the order that they are created, and will be sorted + * approximately in order across all clients. + * + * @return A DatabaseReference pointing to the new location + */ + public DatabaseReference push() { + String childNameStr = PushIdGenerator.generatePushChildName(repo.getServerTime()); + ChildKey childKey = ChildKey.fromString(childNameStr); + return new DatabaseReference(repo, getPath().child(childKey)); + } + + /** + * Set the data at this location to the given value. Passing null to setValue() will delete the + * data at the specified location. The native types accepted by this method for the value + * correspond to the JSON types: + * + *

    + *
  • Boolean + *
  • Long + *
  • Double + *
  • Map<String, Object> + *
  • List<Object> + *
+ * + *
+ *
+ * In addition, you can set instances of your own class into this location, provided they satisfy + * the following constraints: + * + *
    + *
  1. The class must have a default constructor that takes no arguments + *
  2. The class must define public getters for the properties to be assigned. Properties + * without a public getter will be set to their default value when an instance is + * deserialized + *
+ * + *
+ *
+ * Generic collections of objects that satisfy the above constraints are also permitted, i.e. + * Map<String, MyPOJO>, as well as null values. + * + * @param value The value to set at this location + * @return The {@link Task} for this operation. + */ + public Task setValue(Object value) { + return setValueInternal(value, PriorityUtilities.parsePriority(null), null); + } + + /** + * Set the data and priority to the given values. Passing null to setValue() will delete the data + * at the specified location. The native types accepted by this method for the value correspond to + * the JSON types: + * + *
    + *
  • Boolean + *
  • Long + *
  • Double + *
  • Map<String, Object> + *
  • List<Object> + *
+ * + *
+ *
+ * In addition, you can set instances of your own class into this location, provided they satisfy + * the following constraints: + * + *
    + *
  1. The class must have a default constructor that takes no arguments + *
  2. The class must define public getters for the properties to be assigned. Properties + * without a public getter will be set to their default value when an instance is + * deserialized + *
+ * + *
+ *
+ * Generic collections of objects that satisfy the above constraints are also permitted, i.e. + * Map<String, MyPOJO>, as well as null values. + * + * @param value The value to set at this location + * @param priority The priority to set at this location + * @return The {@link Task} for this operation. + */ + public Task setValue(Object value, Object priority) { + return setValueInternal(value, PriorityUtilities.parsePriority(priority), null); + } + + /** + * Set the data at this location to the given value. Passing null to setValue() will delete the + * data at the specified location. The native types accepted by this method for the value + * correspond to the JSON types: + * + *
    + *
  • Boolean + *
  • Long + *
  • Double + *
  • Map<String, Object> + *
  • List<Object> + *
+ * + *
+ *
+ * In addition, you can set instances of your own class into this location, provided they satisfy + * the following constraints: + * + *
    + *
  1. The class must have a default constructor that takes no arguments + *
  2. The class must define public getters for the properties to be assigned. Properties + * without a public getter will be set to their default value when an instance is + * deserialized + *
+ * + *
+ *
+ * Generic collections of objects that satisfy the above constraints are also permitted, i.e. + * Map<String, MyPOJO>, as well as null values. + * + * @param value The value to set at this location + * @param listener A listener that will be triggered with the results of the operation + */ + public void setValue(Object value, CompletionListener listener) { + setValueInternal(value, PriorityUtilities.parsePriority(null), listener); + } + + /** + * Set the data and priority to the given values. The native types accepted by this method for the + * value correspond to the JSON types: + * + *
    + *
  • Boolean + *
  • Long + *
  • Double + *
  • Map<String, Object> + *
  • List<Object> + *
+ * + *
+ *
+ * In addition, you can set instances of your own class into this location, provided they satisfy + * the following constraints: + * + *
    + *
  1. The class must have a default constructor that takes no arguments + *
  2. The class must define public getters for the properties to be assigned. Properties + * without a public getter will be set to their default value when an instance is + * deserialized + *
+ * + *
+ *
+ * Generic collections of objects that satisfy the above constraints are also permitted, i.e. + * Map<String, MyPOJO>, as well as null values. + * + * @param value The value to set at this location + * @param priority The priority to set at this location + * @param listener A listener that will be triggered with the results of the operation + */ + public void setValue(Object value, Object priority, CompletionListener listener) { + setValueInternal(value, PriorityUtilities.parsePriority(priority), listener); + } + + private Task setValueInternal(Object value, Node priority, CompletionListener optListener) { + Validation.validateWritablePath(getPath()); + ValidationPath.validateWithObject(getPath(), value); + Object bouncedValue = CustomClassMapper.convertToPlainJavaTypes(value); + Validation.validateWritableObject(bouncedValue); + final Node node = NodeUtilities.NodeFromJSON(bouncedValue, priority); + final Pair, CompletionListener> wrapped = Utilities.wrapOnComplete(optListener); + repo.scheduleNow( + new Runnable() { + @Override + public void run() { + repo.setValue(getPath(), node, wrapped.getSecond()); + } + }); + return wrapped.getFirst(); + } + + // Set priority + + /** + * Set a priority for the data at this Database location. Priorities can be used to provide a + * custom ordering for the children at a location (if no priorities are specified, the children + * are ordered by key).
+ *
+ * You cannot set a priority on an empty location. For this reason setValue(data, priority) should + * be used when setting initial data with a specific priority and setPriority should be used when + * updating the priority of existing data.
+ *
+ * Children are sorted based on this priority using the following rules: + * + *
    + *
  • Children with no priority come first. + *
  • Children with a number as their priority come next. They are sorted numerically by + * priority (small to large). + *
  • Children with a string as their priority come last. They are sorted lexicographically by + * priority. + *
  • Whenever two children have the same priority (including no priority), they are sorted by + * key. Numeric keys come first (sorted numerically), followed by the remaining keys (sorted + * lexicographically). + *
+ * + * Note that numerical priorities are parsed and ordered as IEEE 754 double-precision + * floating-point numbers. Keys are always stored as strings and are treated as numeric only when + * they can be parsed as a 32-bit integer. + * + * @param priority The priority to set at the specified location. + * @return The {@link Task} for this operation. + */ + public Task setPriority(Object priority) { + return setPriorityInternal(PriorityUtilities.parsePriority(priority), null); + } + + /** + * Set a priority for the data at this Database location. Priorities can be used to provide a + * custom ordering for the children at a location (if no priorities are specified, the children + * are ordered by key).
+ *
+ * You cannot set a priority on an empty location. For this reason setValue(data, priority) should + * be used when setting initial data with a specific priority and setPriority should be used when + * updating the priority of existing data.
+ *
+ * Children are sorted based on this priority using the following rules: + * + *
    + *
  • Children with no priority come first. + *
  • Children with a number as their priority come next. They are sorted numerically by + * priority (small to large). + *
  • Children with a string as their priority come last. They are sorted lexicographically by + * priority. + *
  • Whenever two children have the same priority (including no priority), they are sorted by + * key. Numeric keys come first (sorted numerically), followed by the remaining keys (sorted + * lexicographically). + *
+ * + * Note that numerical priorities are parsed and ordered as IEEE 754 double-precision + * floating-point numbers. Keys are always stored as strings and are treated as numeric only when + * they can be parsed as a 32-bit integer. + * + * @param priority The priority to set at the specified location. + * @param listener A listener that will be triggered with results of the operation + */ + public void setPriority(Object priority, CompletionListener listener) { + setPriorityInternal(PriorityUtilities.parsePriority(priority), listener); + } + + private Task setPriorityInternal(final Node priority, CompletionListener optListener) { + Validation.validateWritablePath(getPath()); + + final Pair, CompletionListener> wrapped = Utilities.wrapOnComplete(optListener); + repo.scheduleNow( + new Runnable() { + @Override + public void run() { + repo.setValue( + getPath().child(ChildKey.getPriorityKey()), priority, wrapped.getSecond()); + } + }); + return wrapped.getFirst(); + } + + // Update + + /** + * Update the specific child keys to the specified values. Passing null in a map to + * updateChildren() will remove the value at the specified location. + * + * @param update The paths to update and their new values + * @return The {@link Task} for this operation. + */ + public Task updateChildren(Map update) { + return updateChildrenInternal(update, null); + } + + /** + * Update the specific child keys to the specified values. Passing null in a map to + * updateChildren() will remove the value at the specified location. + * + * @param update The paths to update and their new values + * @param listener A listener that will be triggered with results of the operation + */ + public void updateChildren(final Map update, final CompletionListener listener) { + updateChildrenInternal(update, listener); + } + + private Task updateChildrenInternal( + final Map update, final CompletionListener optListener) { + if (update == null) { + throw new NullPointerException("Can't pass null for argument 'update' in updateChildren()"); + } + final Map bouncedUpdate = CustomClassMapper.convertToPlainJavaTypes(update); + final Map parsedUpdate = + Validation.parseAndValidateUpdate(getPath(), bouncedUpdate); + final CompoundWrite merge = CompoundWrite.fromPathMerge(parsedUpdate); + + final Pair, CompletionListener> wrapped = Utilities.wrapOnComplete(optListener); + repo.scheduleNow( + new Runnable() { + @Override + public void run() { + repo.updateChildren(getPath(), merge, wrapped.getSecond(), bouncedUpdate); + } + }); + return wrapped.getFirst(); + } + + // Remove + + /** + * Set the value at this location to 'null' + * + * @return The {@link Task} for this operation. + */ + public Task removeValue() { + return setValue(null); + } + + /** + * Set the value at this location to 'null' + * + * @param listener A listener that will be triggered when the operation is complete + */ + public void removeValue(CompletionListener listener) { + setValue(null, listener); + } + + // Access to disconnect operations + + /** + * Provides access to disconnect operations at this location + * + * @return An object for managing disconnect operations at this location + */ + public OnDisconnect onDisconnect() { + Validation.validateWritablePath(getPath()); + return new OnDisconnect(repo, getPath()); + } + + // Transactions + + /** + * Run a transaction on the data at this location. For more information on running transactions, + * see {@link com.google.firebase.database.Transaction.Handler Transaction.Handler}. + * + * @param handler An object to handle running the transaction + */ + public void runTransaction(Transaction.Handler handler) { + runTransaction(handler, true); + } + + /** + * Run a transaction on the data at this location. For more information on running transactions, + * see {@link com.google.firebase.database.Transaction.Handler Transaction.Handler}. + * + * @param handler An object to handle running the transaction + * @param fireLocalEvents Defaults to true. If set to false, events will only be fired for the + * final result state of the transaction, and not for any intermediate states + */ + public void runTransaction(final Transaction.Handler handler, final boolean fireLocalEvents) { + if (handler == null) { + throw new NullPointerException("Can't pass null for argument 'handler' in runTransaction()"); + } + Validation.validateWritablePath(getPath()); + repo.scheduleNow( + new Runnable() { + @Override + public void run() { + repo.startTransaction(getPath(), handler, fireLocalEvents); + } + }); + } + + // Manual Connection Management + + /* + * The Firebase Database client automatically maintains a persistent connection to the Database + * server, which will remain active indefinitely and reconnect when disconnected. However, the + * goOffline( ) and goOnline( ) methods may be used to manually control the client connection in + * cases where a persistent connection is undesirable. + * + *

While offline, the Firebase Database client will no longer receive data updates from the + * server. However, all Database operations performed locally will continue to immediately fire + * events, allowing your application to continue behaving normally. Additionally, each operation + * performed locally will automatically be queued and retried upon reconnection to the Database + * server. + * + *

To reconnect to the Database server and begin receiving remote events, see goOnline( ). Once + * the connection is reestablished, the Database client will transmit the appropriate data and + * fire the appropriate events so that your client "catches up" automatically. + */ + + /** + * Manually disconnect the Firebase Database client from the server and disable automatic + * reconnection. + * + *

Note: Invoking this method will impact all Firebase Database connections. + */ + public static void goOffline() { + goOffline(getDefaultConfig()); + } + + static void goOffline(DatabaseConfig config) { + RepoManager.interrupt(config); + } + + /** + * Manually reestablish a connection to the Firebase Database server and enable automatic + * reconnection. + * + *

Note: Invoking this method will impact all Firebase Database connections. + */ + public static void goOnline() { + goOnline(getDefaultConfig()); + } + + static void goOnline(DatabaseConfig config) { + RepoManager.resume(config); + } + + // Getters and other auxiliary methods + + /** + * Gets the Database instance associated with this reference. + * + * @return The Database object for this reference. + */ + public FirebaseDatabase getDatabase() { + return this.repo.getDatabase(); + } + + /** + * @return The full location url for this reference + */ + @Override + public String toString() { + DatabaseReference parent = getParent(); + if (parent == null) { + return repo.toString(); + } else { + try { + return parent.toString() + "/" + URLEncoder.encode(getKey(), "UTF-8").replace("+", "%20"); + } catch (UnsupportedEncodingException e) { + throw new DatabaseException("Failed to URLEncode key: " + getKey(), e); + } + } + } + + /** + * @return A DatabaseReference to the parent location, or null if this instance references the + * root location + */ + public DatabaseReference getParent() { + Path parentPath = getPath().getParent(); + if (parentPath != null) { + return new DatabaseReference(repo, parentPath); + } else { + return null; + } + } + + /** + * @return A reference to the root location of this Firebase Database + */ + public DatabaseReference getRoot() { + return new DatabaseReference(repo, new Path("")); + } + + /** + * @return The last token in the location pointed to by this reference + */ + public String getKey() { + if (getPath().isEmpty()) { + return null; + } + return getPath().getBack().asString(); + } + + @Override + public boolean equals(Object other) { + return other instanceof DatabaseReference && toString().equals(other.toString()); + } + + @Override + public int hashCode() { + return toString().hashCode(); + } + + void setHijackHash(final boolean hijackHash) { + repo.scheduleNow( + new Runnable() { + @Override + public void run() { + repo.setHijackHash(hijackHash); + } + }); + } + + /** + * Legacy method for legacy creation of DatabaseReference for tests. + * + * @return A reference to the default config object. This can be modified up until your first + * Database call + */ + private static synchronized DatabaseConfig getDefaultConfig() { + if (defaultConfig == null) { + defaultConfig = new DatabaseConfig(); + } + return defaultConfig; + } +} diff --git a/src/main/java/com/google/firebase/database/Exclude.java b/src/main/java/com/google/firebase/database/Exclude.java new file mode 100644 index 000000000..77498e381 --- /dev/null +++ b/src/main/java/com/google/firebase/database/Exclude.java @@ -0,0 +1,15 @@ +package com.google.firebase.database; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Marks a field as excluded from the Database. + */ +@Retention(RetentionPolicy.RUNTIME) +@Target({ElementType.METHOD, ElementType.FIELD}) +public @interface Exclude { + +} diff --git a/src/main/java/com/google/firebase/database/FirebaseDatabase.java b/src/main/java/com/google/firebase/database/FirebaseDatabase.java new file mode 100644 index 000000000..b122d6ad7 --- /dev/null +++ b/src/main/java/com/google/firebase/database/FirebaseDatabase.java @@ -0,0 +1,328 @@ +package com.google.firebase.database; + +import com.google.firebase.FirebaseApp; +import com.google.firebase.FirebaseOptions; +import com.google.firebase.ImplFirebaseTrampolines; +import com.google.firebase.database.core.DatabaseConfig; +import com.google.firebase.database.core.Path; +import com.google.firebase.database.core.Repo; +import com.google.firebase.database.core.RepoInfo; +import com.google.firebase.database.core.RepoManager; +import com.google.firebase.database.utilities.ParsedUrl; +import com.google.firebase.database.utilities.Utilities; +import com.google.firebase.database.utilities.Validation; +import java.util.HashMap; +import java.util.Map; + +/** + * The entry point for accessing a Firebase Database. You can get an instance by calling {@link + * FirebaseDatabase#getInstance()}. To access a location in the database and read or write data, use + * {@link FirebaseDatabase#getReference()}. + */ +public class FirebaseDatabase { + + // This constant gets updated during the release process (see release-to-gh.sh script) + private static final String SDK_VERSION = "4.1.6"; + + /** + * A static map of FirebaseApp and RepoInfo to FirebaseDatabase instance. To ensure thread- + * safety, it should only be accessed in getInstance(), which is a synchronized method. + * + *

TODO(mikelehen): This serves a duplicate purpose as RepoManager. We should clean up. + * TODO(mikelehen): We should maybe be conscious of leaks and make this a weak map or similar but + * we have a lot of work to do to allow FirebaseDatabase/Repo etc. to be GC'd. + */ + private static final Map> + databaseInstances = new HashMap<>(); + + private final FirebaseApp app; + private final RepoInfo repoInfo; + private final DatabaseConfig config; + private Repo repo; // Usage must be guarded by a call to ensureRepo(). + + /** + * Gets the default FirebaseDatabase instance. + * + * @return A FirebaseDatabase instance. + */ + public static FirebaseDatabase getInstance() { + FirebaseApp instance = FirebaseApp.getInstance(); + if (instance == null) { + throw new DatabaseException("You must call FirebaseApp.initialize() first."); + } + return getInstance(instance, instance.getOptions().getDatabaseUrl()); + } + + /** + * Gets a FirebaseDatabase instance for the specified URL. + * + * @param url The URL to the Firebase Database instance you want to access. + * @return A FirebaseDatabase instance. + */ + public static FirebaseDatabase getInstance(String url) { + FirebaseApp instance = FirebaseApp.getInstance(); + if (instance == null) { + throw new DatabaseException("You must call FirebaseApp.initialize() first."); + } + return getInstance(instance, url); + } + + /** + * Gets an instance of FirebaseDatabase for a specific FirebaseApp. + * + * @param app The FirebaseApp to get a FirebaseDatabase for. + * @return A FirebaseDatabase instance. + */ + public static FirebaseDatabase getInstance(FirebaseApp app) { + return getInstance(app, app.getOptions().getDatabaseUrl()); + } + + /** + * Gets a FirebaseDatabase instance for the specified URL, using the specified FirebaseApp. + * + * @param app The FirebaseApp to get a FirebaseDatabase for. + * @param url The URL to the Firebase Database instance you want to access. + * @return A FirebaseDatabase instance. + */ + public static synchronized FirebaseDatabase getInstance(FirebaseApp app, String url) { + if (url == null || url.isEmpty()) { + throw new DatabaseException( + "Failed to get FirebaseDatabase instance: Specify DatabaseURL within " + + "FirebaseApp or from your getInstance() call."); + } + + Map instances = databaseInstances.get(app.getName()); + + if (instances == null) { + instances = new HashMap<>(); + databaseInstances.put(app.getName(), instances); + } + + ParsedUrl parsedUrl = Utilities.parseUrl(url); + if (!parsedUrl.path.isEmpty()) { + throw new DatabaseException( + "Specified Database URL '" + + url + + "' is invalid. It should point to the root of a " + + "Firebase Database but it includes a path: " + + parsedUrl.path.toString()); + } + + FirebaseDatabase database = instances.get(parsedUrl.repoInfo); + + if (database == null) { + DatabaseConfig config = new DatabaseConfig(); + // If this is the default app, don't set the session persistence key so that we use our + // default ("default") instead of the FirebaseApp default ("[DEFAULT]") so that we + // preserve the default location used by the legacy Firebase SDK. + if (!ImplFirebaseTrampolines.isDefaultApp(app)) { + config.setSessionPersistenceKey(app.getName()); + } + config.setFirebaseApp(app); + + database = new FirebaseDatabase(app, parsedUrl.repoInfo, config); + instances.put(parsedUrl.repoInfo, database); + } + + return database; + } + + /** + * This exists so Repo can create FirebaseDatabase objects to keep legacy tests working. + */ + static FirebaseDatabase createForTests( + FirebaseApp app, RepoInfo repoInfo, DatabaseConfig config) { + FirebaseDatabase db = new FirebaseDatabase(app, repoInfo, config); + db.ensureRepo(); + return db; + } + + private FirebaseDatabase(FirebaseApp app, RepoInfo repoInfo, DatabaseConfig config) { + this.app = app; + this.repoInfo = repoInfo; + this.config = config; + } + + /** + * Returns the FirebaseApp instance to which this FirebaseDatabase belongs. + * + * @return The FirebaseApp instance to which this FirebaseDatabase belongs. + */ + public FirebaseApp getApp() { + return this.app; + } + + /** + * Gets a DatabaseReference for the database root node. + * + * @return A DatabaseReference pointing to the root node. + */ + public DatabaseReference getReference() { + ensureRepo(); + return new DatabaseReference(this.repo, Path.getEmptyPath()); + } + + /** + * Gets a DatabaseReference for the provided path. + * + * @param path Path to a location in your FirebaseDatabase. + * @return A DatabaseReference pointing to the specified path. + */ + public DatabaseReference getReference(String path) { + ensureRepo(); + + if (path == null) { + throw new NullPointerException( + "Can't pass null for argument 'pathString' in " + "FirebaseDatabase.getReference()"); + } + Validation.validateRootPathString(path); + + Path childPath = new Path(path); + return new DatabaseReference(this.repo, childPath); + } + + /** + * Gets a DatabaseReference for the provided URL. The URL must be a URL to a path within this + * FirebaseDatabase. To create a DatabaseReference to a different database, create a {@link + * FirebaseApp} with a {@link FirebaseOptions} object configured with the appropriate database + * URL. + * + * @param url A URL to a path within your database. + * @return A DatabaseReference for the provided URL. + */ + public DatabaseReference getReferenceFromUrl(String url) { + ensureRepo(); + + if (url == null) { + throw new NullPointerException( + "Can't pass null for argument 'url' in " + "FirebaseDatabase.getReferenceFromUrl()"); + } + + ParsedUrl parsedUrl = Utilities.parseUrl(url); + if (!parsedUrl.repoInfo.host.equals(this.repo.getRepoInfo().host)) { + throw new DatabaseException( + "Invalid URL (" + + url + + ") passed to getReference(). " + + "URL was expected to match configured Database URL: " + + getReference().toString()); + } + + return new DatabaseReference(this.repo, parsedUrl.path); + } + + /** + * The Firebase Database client automatically queues writes and sends them to the server at the + * earliest opportunity, depending on network connectivity. In some cases (e.g. offline usage) + * there may be a large number of writes waiting to be sent. Calling this method will purge all + * outstanding writes so they are abandoned. + * + *

All writes will be purged, including transactions and {@link DatabaseReference#onDisconnect} + * writes. The writes will be rolled back locally, perhaps triggering events for affected event + * listeners, and the client will not (re-)send them to the Firebase backend. + */ + public void purgeOutstandingWrites() { + ensureRepo(); + this.repo.scheduleNow( + new Runnable() { + @Override + public void run() { + repo.purgeOutstandingWrites(); + } + }); + } + + /** + * Resumes our connection to the Firebase Database backend after a previous {@link #goOffline()} + * call. + */ + public void goOnline() { + ensureRepo(); + RepoManager.resume(this.repo); + } + + /** + * Shuts down our connection to the Firebase Database backend until {@link #goOnline()} is called. + */ + public void goOffline() { + ensureRepo(); + RepoManager.interrupt(this.repo); + } + + /** + * By default, this is set to {@link Logger.Level#INFO INFO}. This includes any internal errors + * ({@link Logger.Level#ERROR ERROR}) and any security debug messages ({@link Logger.Level#INFO + * INFO}) that the client receives. Set to {@link Logger.Level#DEBUG DEBUG} to turn on the + * diagnostic logging, and {@link Logger.Level#NONE NONE} to disable all logging. + * + * @param logLevel The desired minimum log level + */ + public synchronized void setLogLevel(Logger.Level logLevel) { + assertUnfrozen("setLogLevel"); + this.config.setLogLevel(logLevel); + } + + /** + * The Firebase Database client will cache synchronized data and keep track of all writes you've + * initiated while your application is running. It seamlessly handles intermittent network + * connections and re-sends write operations when the network connection is restored. + * + *

However by default your write operations and cached data are only stored in-memory and will + * be lost when your app restarts. By setting this value to `true`, the data will be persisted to + * on-device (disk) storage and will thus be available again when the app is restarted (even when + * there is no network connectivity at that time). Note that this method must be called before + * creating your first Database reference and only needs to be called once per application. + * + * @param isEnabled Set to true to enable disk persistence, set to false to disable it. + */ + public synchronized void setPersistenceEnabled(boolean isEnabled) { + assertUnfrozen("setPersistenceEnabled"); + this.config.setPersistenceEnabled(isEnabled); + } + + /** + * By default Firebase Database will use up to 10MB of disk space to cache data. If the cache + * grows beyond this size, Firebase Database will start removing data that hasn't been recently + * used. If you find that your application caches too little or too much data, call this method to + * change the cache size. This method must be called before creating your first Database reference + * and only needs to be called once per application. + * + *

Note that the specified cache size is only an approximation and the size on disk may + * temporarily exceed it at times. Cache sizes smaller than 1 MB or greater than 100 MB are not + * supported. + * + * @param cacheSizeInBytes The new size of the cache in bytes. + */ + public synchronized void setPersistenceCacheSizeBytes(long cacheSizeInBytes) { + assertUnfrozen("setPersistenceCacheSizeBytes"); + this.config.setPersistenceCacheSizeBytes(cacheSizeInBytes); + } + + /** + * @return The version for this build of the Firebase Database client + */ + public static String getSdkVersion() { + return SDK_VERSION; + } + + private void assertUnfrozen(String methodCalled) { + if (this.repo != null) { + throw new DatabaseException( + "Calls to " + + methodCalled + + "() must be made before any " + + "other usage of FirebaseDatabase instance."); + } + } + + private synchronized void ensureRepo() { + if (this.repo == null) { + repo = RepoManager.createRepo(this.config, this.repoInfo, this); + } + } + + // for testing + DatabaseConfig getConfig() { + return this.config; + } +} diff --git a/src/main/java/com/google/firebase/database/GenericTypeIndicator.java b/src/main/java/com/google/firebase/database/GenericTypeIndicator.java new file mode 100644 index 000000000..927248300 --- /dev/null +++ b/src/main/java/com/google/firebase/database/GenericTypeIndicator.java @@ -0,0 +1,47 @@ +package com.google.firebase.database; + +/** + * Due to the way that Java implements generics (type-erasure), it is necessary to use a slightly + * more complicated method to properly resolve types for generic collections at runtime. To solve + * this problem, Firebase Database accepts subclasses of this class in calls to getValue ({@link + * com.google.firebase.database.DataSnapshot#getValue(GenericTypeIndicator)}, {@link + * MutableData#getValue(GenericTypeIndicator)}) and returns a properly-typed generic collection. + * + *

As an example, you might do something like this to get a list of Message instances from a + * {@link DataSnapshot}:
+ *
+ * + *


+ *     class Message {
+ *         private String author;
+ *         private String text;
+ *
+ *         private Message() {}
+ *
+ *         public Message(String author, String text) {
+ *             this.author = author;
+ *             this.text = text;
+ *         }
+ *
+ *         public String getAuthor() {
+ *             return author;
+ *         }
+ *
+ *         public String getText() {
+ *             return text;
+ *         }
+ *     }
+ *
+ *     // Later ...
+ *
+ *     GenericTypeIndicator<List<Message>> t = new GenericTypeIndicator<List<Message>>() {};
+ *     List<Message> messages = snapshot.getValue(t);
+ *
+ * 
+ * + * @param The type of generic collection that this instance servers as an indicator for + */ +public abstract class GenericTypeIndicator { + // TODO(dimond): This is a legacy class that inherited from TypeIndicator from Jackson to be + // able to resolve generic types. We need a new solution going forward. +} diff --git a/src/main/java/com/google/firebase/database/IgnoreExtraProperties.java b/src/main/java/com/google/firebase/database/IgnoreExtraProperties.java new file mode 100644 index 000000000..0de7e1944 --- /dev/null +++ b/src/main/java/com/google/firebase/database/IgnoreExtraProperties.java @@ -0,0 +1,16 @@ +package com.google.firebase.database; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Properties that don't map to class fields are ignored when serializing to a class annotated with + * this annotation. + */ +@Retention(RetentionPolicy.RUNTIME) +@Target({ElementType.TYPE}) +public @interface IgnoreExtraProperties { + +} diff --git a/src/main/java/com/google/firebase/database/InternalHelpers.java b/src/main/java/com/google/firebase/database/InternalHelpers.java new file mode 100644 index 000000000..d3bc56277 --- /dev/null +++ b/src/main/java/com/google/firebase/database/InternalHelpers.java @@ -0,0 +1,46 @@ +package com.google.firebase.database; + +import com.google.firebase.FirebaseApp; +import com.google.firebase.database.core.DatabaseConfig; +import com.google.firebase.database.core.Path; +import com.google.firebase.database.core.Repo; +import com.google.firebase.database.core.RepoInfo; +import com.google.firebase.database.snapshot.IndexedNode; +import com.google.firebase.database.snapshot.Node; + +/** + * Internal helpers in com.google.firebase.database package (for use by core, tests, etc.) + * + * @hide + */ +public class InternalHelpers { + + /** + * So Repo, etc. can create DatabaseReference instances. + */ + public static DatabaseReference createReference(Repo repo, Path path) { + return new DatabaseReference(repo, path); + } + + /** + * So Repo, etc. can create DataSnapshots. + */ + public static DataSnapshot createDataSnapshot(DatabaseReference ref, IndexedNode node) { + return new DataSnapshot(ref, node); + } + + /** + * So Repo can create FirebaseDatabase objects to keep legacy tests working. + */ + public static FirebaseDatabase createDatabaseForTests( + FirebaseApp app, RepoInfo repoInfo, DatabaseConfig config) { + return FirebaseDatabase.createForTests(app, repoInfo, config); + } + + /** + * For Repo to create MutableData objects. + */ + public static MutableData createMutableData(Node node) { + return new MutableData(node); + } +} diff --git a/src/main/java/com/google/firebase/database/Logger.java b/src/main/java/com/google/firebase/database/Logger.java new file mode 100644 index 000000000..67334d887 --- /dev/null +++ b/src/main/java/com/google/firebase/database/Logger.java @@ -0,0 +1,19 @@ +package com.google.firebase.database; + +/** + * This interface is used to setup logging for Firebase Database. + */ +public interface Logger { + + /** + * The log levels used by the Firebase Database library + */ + enum Level { + DEBUG, + INFO, + WARN, + ERROR, + NONE + } + +} diff --git a/src/main/java/com/google/firebase/database/MutableData.java b/src/main/java/com/google/firebase/database/MutableData.java new file mode 100644 index 000000000..e63305d15 --- /dev/null +++ b/src/main/java/com/google/firebase/database/MutableData.java @@ -0,0 +1,318 @@ +package com.google.firebase.database; + +import com.google.firebase.database.core.Path; +import com.google.firebase.database.core.SnapshotHolder; +import com.google.firebase.database.core.ValidationPath; +import com.google.firebase.database.snapshot.ChildKey; +import com.google.firebase.database.snapshot.IndexedNode; +import com.google.firebase.database.snapshot.NamedNode; +import com.google.firebase.database.snapshot.Node; +import com.google.firebase.database.snapshot.NodeUtilities; +import com.google.firebase.database.snapshot.PriorityUtilities; +import com.google.firebase.database.utilities.Validation; +import com.google.firebase.database.utilities.encoding.CustomClassMapper; +import java.util.Iterator; +import java.util.NoSuchElementException; + +/** + * Instances of this class encapsulate the data and priority at a location. It is used in + * transactions, and it is intended to be inspected and then updated to the desired data at that + * location.
+ *
+ * Note that changes made to a child MutableData instance will be visible to the parent and vice + * versa. + */ +public class MutableData { + + private final SnapshotHolder holder; + private final Path prefixPath; + + /** @param node The data */ + MutableData(Node node) { + this(new SnapshotHolder(node), new Path("")); + } + + private MutableData(SnapshotHolder holder, Path path) { + this.holder = holder; + prefixPath = path; + ValidationPath.validateWithObject(prefixPath, getValue()); + } + + Node getNode() { + return holder.getNode(prefixPath); + } + + /** @return True if the data at this location has children, false otherwise */ + public boolean hasChildren() { + Node node = getNode(); + return !node.isLeafNode() && !node.isEmpty(); + } + + /** + * @param path A relative path + * @return True if data exists at the given path, otherwise false + */ + public boolean hasChild(String path) { + return !getNode().getChild(new Path(path)).isEmpty(); + } + + /** + * Used to obtain a MutableData instance that encapsulates the data and priority at the given + * relative path. + * + * @param path A relative path + * @return An instance encapsulating the data and priority at the given path + */ + public MutableData child(String path) { + Validation.validatePathString(path); + return new MutableData(holder, prefixPath.child(new Path(path))); + } + + /** @return The number of immediate children at this location */ + public long getChildrenCount() { + return getNode().getChildCount(); + } + + /** + * Used to iterate over the immediate children at this location + *
for (MutableData child : parent.getChildren()) { + *
    ... + *
} + *
+ * + * @return The immediate children at this location + */ + public Iterable getChildren() { + Node node = getNode(); + if (node.isEmpty() || node.isLeafNode()) { + return new Iterable() { + @Override + public Iterator iterator() { + return new Iterator() { + @Override + public boolean hasNext() { + return false; + } + + @Override + public MutableData next() { + throw new NoSuchElementException(); + } + + @Override + public void remove() { + throw new UnsupportedOperationException("remove called on immutable collection"); + } + }; + } + }; + } else { + final Iterator iter = IndexedNode.from(node).iterator(); + return new Iterable() { + @Override + public Iterator iterator() { + return new Iterator() { + @Override + public boolean hasNext() { + return iter.hasNext(); + } + + @Override + public MutableData next() { + NamedNode namedNode = iter.next(); + return new MutableData(holder, prefixPath.child(namedNode.getName())); + } + + @Override + public void remove() { + throw new UnsupportedOperationException("remove called on immutable collection"); + } + }; + } + }; + } + } + + /** @return The key name of this location, or null if it is the top-most location */ + public String getKey() { + return prefixPath.getBack() != null ? prefixPath.getBack().asString() : null; + } + + /** + * getValue() returns the data contained in this instance as native types. The possible types + * returned are: + * + *
    + *
  • Boolean + *
  • String + *
  • Long + *
  • Double + *
  • Map<String, Object> + *
  • List<Object> + *
+ * + * This list is recursive; the possible types for {@link java.lang.Object} in the above list is + * given by the same list. These types correspond to the types available in JSON. + * + * @return The data contained in this instance as native types + */ + public Object getValue() { + return getNode().getValue(); + } + + /** + * This method is used to marshall the data contained in this instance into a class of your + * choosing. The class must fit 2 simple constraints: + * + *
    + *
  1. The class must have a default constructor that takes no arguments + *
  2. The class must define public getters for the properties to be assigned. Properties + * without a public getter will be set to their default value when an instance is + * deserialized + *
+ * + * An example class might look like: + * + *

+   *     class Message {
+   *         private String author;
+   *         private String text;
+   *
+   *         private Message() {}
+   *
+   *         public Message(String author, String text) {
+   *             this.author = author;
+   *             this.text = text;
+   *         }
+   *
+   *         public String getAuthor() {
+   *             return author;
+   *         }
+   *
+   *         public String getText() {
+   *             return text;
+   *         }
+   *     }
+   *
+   *
+   *     // Later
+   *     Message m = mutableData.getValue(Message.class);
+   * 
+ * + * @param valueType The class into which this data in this instance should be marshalled + * @param The type to return. Implicitly defined from the class passed in + * @return An instance of the class passed in, populated with the data from this instance + */ + public T getValue(Class valueType) { + Object value = getNode().getValue(); + return CustomClassMapper.convertToCustomClass(value, valueType); + } + + /** + * Due to the way that Java implements generics, it takes an extra step to get back a + * properly-typed Collection. So, in the case where you want a {@link java.util.List} of Message + * instances, you will need to do something like the following: + * + *

+   *     GenericTypeIndicator<List<Message>> t =
+   *         new GenericTypeIndicator<List<Message>>() {};
+   *     List<Message> messages = mutableData.getValue(t);
+   * 
+ * + * It is important to use a subclass of {@link GenericTypeIndicator}. See {@link + * GenericTypeIndicator} for more details + * + * @param t A subclass of {@link GenericTypeIndicator} indicating the type of generic collection + * to be returned. + * @param The type to return. Implicitly defined from the {@link GenericTypeIndicator} passed + * in + * @return A properly typed collection, populated with the data from this instance + */ + public T getValue(GenericTypeIndicator t) { + Object value = getNode().getValue(); + return CustomClassMapper.convertToCustomClass(value, t); + } + + /** + * Set the data at this location to the given value. The native types accepted by this method for + * the value correspond to the JSON types: + * + *
    + *
  • Boolean + *
  • Long + *
  • Double + *
  • Map<String, Object> + *
  • List<Object> + *
+ * + *
+ *
+ * In addition, you can set instances of your own class into this location, provided they satisfy + * the following constraints: + * + *
    + *
  1. The class must have a default constructor that takes no arguments + *
  2. The class must define public getters for the properties to be assigned. Properties + * without a public getter will be set to their default value when an instance is + * deserialized + *
+ * + *
+ *
+ * Generic collections of objects that satisfy the above constraints are also permitted, i.e. + * Map<String, MyPOJO>, as well as null values. + * + *

Note that this overrides the priority, which must be set separately. + * + * @param value The value to set at this location + */ + public void setValue(Object value) throws DatabaseException { + ValidationPath.validateWithObject(prefixPath, value); + Object bouncedValue = CustomClassMapper.convertToPlainJavaTypes(value); + Validation.validateWritableObject(bouncedValue); + holder.update(prefixPath, NodeUtilities.NodeFromJSON(bouncedValue)); + } + + /** + * Sets the priority at this location + * + * @param priority The desired priority + */ + public void setPriority(Object priority) { + holder.update(prefixPath, getNode().updatePriority(PriorityUtilities.parsePriority(priority))); + } + + /** + * Gets the current priority at this location. The possible return types are: + * + *

    + *
  • Double + *
  • String + *
+ * + * Note that null is allowed + * + * @return The priority at this location as a native type + */ + public Object getPriority() { + return getNode().getPriority().getValue(); + } + + @Override + public boolean equals(Object o) { + // Look for the same snapshot holder and the same prefix path + return o instanceof MutableData + && holder.equals(((MutableData) o).holder) + && prefixPath.equals(((MutableData) o).prefixPath); + } + + @Override + public String toString() { + ChildKey front = this.prefixPath.getFront(); + return "MutableData { key = " + + (front != null ? front.asString() : "") + + ", value = " + + this.holder.getRootNode().getValue(true) + + " }"; + } +} diff --git a/src/main/java/com/google/firebase/database/OnDisconnect.java b/src/main/java/com/google/firebase/database/OnDisconnect.java new file mode 100644 index 000000000..c91e5d6d1 --- /dev/null +++ b/src/main/java/com/google/firebase/database/OnDisconnect.java @@ -0,0 +1,244 @@ +package com.google.firebase.database; + +import com.google.firebase.database.DatabaseReference.CompletionListener; +import com.google.firebase.database.core.Path; +import com.google.firebase.database.core.Repo; +import com.google.firebase.database.core.ValidationPath; +import com.google.firebase.database.snapshot.Node; +import com.google.firebase.database.snapshot.NodeUtilities; +import com.google.firebase.database.snapshot.PriorityUtilities; +import com.google.firebase.database.utilities.Pair; +import com.google.firebase.database.utilities.Utilities; +import com.google.firebase.database.utilities.Validation; +import com.google.firebase.database.utilities.encoding.CustomClassMapper; +import com.google.firebase.tasks.Task; +import java.util.Map; + +/** + * The OnDisconnect class is used to manage operations that will be run on the server when this + * client disconnects. It can be used to add or remove data based on a client's connection status. + * It is very useful in applications looking for 'presence' functionality.
+ *
+ * Instances of this class are obtained by calling {@link DatabaseReference#onDisconnect() + * onDisconnect} on a Firebase Database ref. + */ +@SuppressWarnings("rawtypes") +public class OnDisconnect { + + private Repo repo; + private Path path; + + OnDisconnect(Repo repo, Path path) { + this.repo = repo; + this.path = path; + } + + /** + * Ensure the data at this location is set to the specified value when the client is disconnected + * (due to closing the browser, navigating to a new page, or network issues).
+ *
+ * This method is especially useful for implementing "presence" systems, where a value should be + * changed or cleared when a user disconnects so that they appear "offline" to other users. + * + * @param value The value to be set when a disconnect occurs + * @return The {@link Task} for this operation. + */ + public Task setValue(Object value) { + return onDisconnectSetInternal(value, PriorityUtilities.NullPriority(), null); + } + + /** + * Ensure the data at this location is set to the specified value and priority when the client is + * disconnected (due to closing the browser, navigating to a new page, or network issues).
+ *
+ * This method is especially useful for implementing "presence" systems, where a value should be + * changed or cleared when a user disconnects so that they appear "offline" to other users. + * + * @param value The value to be set when a disconnect occurs + * @param priority The priority to be set when a disconnect occurs + * @return The {@link Task} for this operation. + */ + public Task setValue(Object value, String priority) { + return onDisconnectSetInternal(value, PriorityUtilities.parsePriority(priority), null); + } + + /** + * Ensure the data at this location is set to the specified value and priority when the client is + * disconnected (due to closing the browser, navigating to a new page, or network issues).
+ *
+ * This method is especially useful for implementing "presence" systems, where a value should be + * changed or cleared when a user disconnects so that they appear "offline" to other users. + * + * @param value The value to be set when a disconnect occurs + * @param priority The priority to be set when a disconnect occurs + * @return The {@link Task} for this operation. + */ + public Task setValue(Object value, double priority) { + return onDisconnectSetInternal(value, PriorityUtilities.parsePriority(priority), null); + } + + /** + * Ensure the data at this location is set to the specified value when the client is disconnected + * (due to closing the browser, navigating to a new page, or network issues).
+ *
+ * This method is especially useful for implementing "presence" systems, where a value should be + * changed or cleared when a user disconnects so that they appear "offline" to other users. + * + * @param value The value to be set when a disconnect occurs + * @param listener A listener that will be triggered once the server has queued up the operation + */ + public void setValue(Object value, CompletionListener listener) { + onDisconnectSetInternal(value, PriorityUtilities.NullPriority(), listener); + } + + /** + * Ensure the data at this location is set to the specified value and priority when the client is + * disconnected (due to closing the browser, navigating to a new page, or network issues).
+ *
+ * This method is especially useful for implementing "presence" systems, where a value should be + * changed or cleared when a user disconnects so that they appear "offline" to other users. + * + * @param value The value to be set when a disconnect occurs + * @param priority The priority to be set when a disconnect occurs + * @param listener A listener that will be triggered once the server has queued up the operation + */ + public void setValue(Object value, String priority, CompletionListener listener) { + onDisconnectSetInternal(value, PriorityUtilities.parsePriority(priority), listener); + } + + /** + * Ensure the data at this location is set to the specified value and priority when the client is + * disconnected (due to closing the browser, navigating to a new page, or network issues).
+ *
+ * This method is especially useful for implementing "presence" systems, where a value should be + * changed or cleared when a user disconnects so that they appear "offline" to other users. + * + * @param value The value to be set when a disconnect occurs + * @param priority The priority to be set when a disconnect occurs + * @param listener A listener that will be triggered once the server has queued up the operation + */ + public void setValue(Object value, double priority, CompletionListener listener) { + onDisconnectSetInternal(value, PriorityUtilities.parsePriority(priority), listener); + } + + /** + * Ensure the data at this location is set to the specified value and priority when the client is + * disconnected (due to closing the browser, navigating to a new page, or network issues).
+ *
+ * This method is especially useful for implementing "presence" systems, where a value should be + * changed or cleared when a user disconnects so that they appear "offline" to other users. + * + * @param value The value to be set when a disconnect occurs + * @param priority The priority to be set when a disconnect occurs + * @param listener A listener that will be triggered once the server has queued up the operation + */ + public void setValue(Object value, Map priority, CompletionListener listener) { + onDisconnectSetInternal(value, PriorityUtilities.parsePriority(priority), listener); + } + + private Task onDisconnectSetInternal( + Object value, Node priority, final CompletionListener optListener) { + Validation.validateWritablePath(path); + ValidationPath.validateWithObject(path, value); + Object bouncedValue = CustomClassMapper.convertToPlainJavaTypes(value); + Validation.validateWritableObject(bouncedValue); + final Node node = NodeUtilities.NodeFromJSON(bouncedValue, priority); + final Pair, CompletionListener> wrapped = Utilities.wrapOnComplete(optListener); + repo.scheduleNow( + new Runnable() { + @Override + public void run() { + repo.onDisconnectSetValue(path, node, wrapped.getSecond()); + } + }); + return wrapped.getFirst(); + } + + // Update + + /** + * Ensure the data has the specified child values updated when the client is disconnected + * + * @param update The paths to update, along with their desired values + * @return The {@link Task} for this operation. + */ + public Task updateChildren(Map update) { + return updateChildrenInternal(update, null); + } + + /** + * Ensure the data has the specified child values updated when the client is disconnected + * + * @param update The paths to update, along with their desired values + * @param listener A listener that will be triggered once the server has queued up the operation + */ + public void updateChildren(final Map update, final CompletionListener listener) { + updateChildrenInternal(update, listener); + } + + private Task updateChildrenInternal( + final Map update, final CompletionListener optListener) { + final Map parsedUpdate = Validation.parseAndValidateUpdate(path, update); + final Pair, CompletionListener> wrapped = Utilities.wrapOnComplete(optListener); + repo.scheduleNow( + new Runnable() { + @Override + public void run() { + repo.onDisconnectUpdate(path, parsedUpdate, wrapped.getSecond(), update); + } + }); + return wrapped.getFirst(); + } + + // Remove + + /** + * Remove the value at this location when the client disconnects + * + * @return The {@link Task} for this operation. + */ + public Task removeValue() { + return setValue(null); + } + + /** + * Remove the value at this location when the client disconnects + * + * @param listener A listener that will be triggered once the server has queued up the operation + */ + public void removeValue(CompletionListener listener) { + setValue(null, listener); + } + + // Cancel the operation + + /** + * Cancel any disconnect operations that are queued up at this location + * + * @return The {@link Task} for this operation. + */ + public Task cancel() { + return cancelInternal(null); + } + + /** + * Cancel any disconnect operations that are queued up at this location + * + * @param listener A listener that will be triggered once the server has cancelled the operations + */ + public void cancel(final CompletionListener listener) { + cancelInternal(listener); + } + + private Task cancelInternal(final CompletionListener optListener) { + final Pair, CompletionListener> wrapped = Utilities.wrapOnComplete(optListener); + repo.scheduleNow( + new Runnable() { + @Override + public void run() { + repo.onDisconnectCancel(path, wrapped.getSecond()); + } + }); + return wrapped.getFirst(); + } +} diff --git a/src/main/java/com/google/firebase/database/PropertyName.java b/src/main/java/com/google/firebase/database/PropertyName.java new file mode 100644 index 000000000..1eaa6ea12 --- /dev/null +++ b/src/main/java/com/google/firebase/database/PropertyName.java @@ -0,0 +1,16 @@ +package com.google.firebase.database; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Marks a field to be renamed when serialized. + */ +@Retention(RetentionPolicy.RUNTIME) +@Target({ElementType.METHOD, ElementType.FIELD}) +public @interface PropertyName { + + String value(); +} diff --git a/src/main/java/com/google/firebase/database/Query.java b/src/main/java/com/google/firebase/database/Query.java new file mode 100644 index 000000000..0f3131186 --- /dev/null +++ b/src/main/java/com/google/firebase/database/Query.java @@ -0,0 +1,663 @@ +package com.google.firebase.database; + +import static com.google.firebase.database.utilities.Utilities.hardAssert; + +import com.google.firebase.database.core.ChildEventRegistration; +import com.google.firebase.database.core.EventRegistration; +import com.google.firebase.database.core.Path; +import com.google.firebase.database.core.Repo; +import com.google.firebase.database.core.ValueEventRegistration; +import com.google.firebase.database.core.ZombieEventManager; +import com.google.firebase.database.core.view.QueryParams; +import com.google.firebase.database.core.view.QuerySpec; +import com.google.firebase.database.snapshot.BooleanNode; +import com.google.firebase.database.snapshot.ChildKey; +import com.google.firebase.database.snapshot.DoubleNode; +import com.google.firebase.database.snapshot.EmptyNode; +import com.google.firebase.database.snapshot.Index; +import com.google.firebase.database.snapshot.KeyIndex; +import com.google.firebase.database.snapshot.Node; +import com.google.firebase.database.snapshot.PathIndex; +import com.google.firebase.database.snapshot.PriorityIndex; +import com.google.firebase.database.snapshot.PriorityUtilities; +import com.google.firebase.database.snapshot.StringNode; +import com.google.firebase.database.snapshot.ValueIndex; +import com.google.firebase.database.utilities.Validation; + +/** + * The Query class (and its subclass, {@link DatabaseReference}) are used for reading data. + * Listeners are attached, and they will be triggered when the corresponding data changes.
+ *
+ * Instances of Query are obtained by calling startAt(), endAt(), or limit() on a DatabaseReference. + */ +public class Query { + + /** + * @hide + */ + protected final Repo repo; + /** + * @hide + */ + protected final Path path; + /** + * @hide + */ + protected final QueryParams params; + // we can't use params index, because the default query params have priority index set as default, + // but we don't want to allow multiple orderByPriority calls, so track them here + private final boolean orderByCalled; + + Query(Repo repo, Path path, QueryParams params, boolean orderByCalled) throws DatabaseException { + this.repo = repo; + this.path = path; + this.params = params; + this.orderByCalled = orderByCalled; + hardAssert(params.isValid(), "Validation of queries failed."); + } + + Query(Repo repo, Path path) { + this.repo = repo; + this.path = path; + this.params = QueryParams.DEFAULT_PARAMS; + this.orderByCalled = false; + } + + /** + * This method validates that key index has been called with the correct combination of parameters + */ + private void validateQueryEndpoints(QueryParams params) { + if (params.getIndex().equals(KeyIndex.getInstance())) { + String message = + "You must use startAt(String value), endAt(String value) or " + + "equalTo(String value) in combination with orderByKey(). Other type of values or " + + "using the version with 2 parameters is not supported"; + if (params.hasStart()) { + Node startNode = params.getIndexStartValue(); + ChildKey startName = params.getIndexStartName(); + if (startName != ChildKey.getMinName() || !(startNode instanceof StringNode)) { + throw new IllegalArgumentException(message); + } + } + if (params.hasEnd()) { + Node endNode = params.getIndexEndValue(); + ChildKey endName = params.getIndexEndName(); + if (endName != ChildKey.getMaxName() || !(endNode instanceof StringNode)) { + throw new IllegalArgumentException(message); + } + } + } else if (params.getIndex().equals(PriorityIndex.getInstance())) { + if ((params.hasStart() && !PriorityUtilities.isValidPriority(params.getIndexStartValue())) + || (params.hasEnd() && !PriorityUtilities.isValidPriority(params.getIndexEndValue()))) { + throw new IllegalArgumentException( + "When using orderByPriority(), values provided to startAt(), " + + "endAt(), or equalTo() must be valid priorities."); + } + } + } + + /** + * This method validates that limit has been called with the correct combination or parameters + */ + private void validateLimit(QueryParams params) { + if (params.hasStart() && params.hasEnd() && params.hasLimit() && !params.hasAnchoredLimit()) { + throw new IllegalArgumentException( + "Can't combine startAt(), endAt() and limit(). " + + "Use limitToFirst() or limitToLast() instead"); + } + } + + /** + * This method validates that the equalTo call can be made + */ + private void validateEqualToCall() { + if (params.hasStart()) { + throw new IllegalArgumentException("Can't call equalTo() and startAt() combined"); + } + if (params.hasEnd()) { + throw new IllegalArgumentException("Can't call equalTo() and endAt() combined"); + } + } + + /** + * This method validates that only one order by call has been made + */ + private void validateNoOrderByCall() { + if (this.orderByCalled) { + throw new IllegalArgumentException("You can't combine multiple orderBy calls!"); + } + } + + /** + * Add a listener for changes in the data at this location. Each time time the data changes, your + * listener will be called with an immutable snapshot of the data. + * + * @param listener The listener to be called with changes + * @return A reference to the listener provided. Save this to remove the listener later. + */ + public ValueEventListener addValueEventListener(ValueEventListener listener) { + addEventRegistration(new ValueEventRegistration(repo, listener, getSpec())); + return listener; + } + + /** + * Add a listener for child events occurring at this location. When child locations are added, + * removed, changed, or moved, the listener will be triggered for the appropriate event + * + * @param listener The listener to be called with changes + * @return A reference to the listener provided. Save this to remove the listener later. + */ + public ChildEventListener addChildEventListener(ChildEventListener listener) { + addEventRegistration(new ChildEventRegistration(repo, listener, getSpec())); + return listener; + } + + /** + * Add a listener for a single change in the data at this location. This listener will be + * triggered once with the value of the data at the location. + * + * @param listener The listener to be called with the data + */ + public void addListenerForSingleValueEvent(final ValueEventListener listener) { + addEventRegistration( + new ValueEventRegistration( + repo, + new ValueEventListener() { + @Override + public void onDataChange(DataSnapshot snapshot) { + // Removing the event listener will also prevent any further calls into onDataChange + removeEventListener(this); + listener.onDataChange(snapshot); + } + + @Override + public void onCancelled(DatabaseError error) { + listener.onCancelled(error); + } + }, + getSpec())); + } + + /** + * Remove the specified listener from this location. + * + * @param listener The listener to remove + */ + public void removeEventListener(final ValueEventListener listener) { + if (listener == null) { + throw new NullPointerException("listener must not be null"); + } + removeEventRegistration(new ValueEventRegistration(repo, listener, getSpec())); + } + + /** + * Remove the specified listener from this location. + * + * @param listener The listener to remove + */ + public void removeEventListener(final ChildEventListener listener) { + if (listener == null) { + throw new NullPointerException("listener must not be null"); + } + removeEventRegistration(new ChildEventRegistration(repo, listener, getSpec())); + } + + private void removeEventRegistration(final EventRegistration registration) { + ZombieEventManager.getInstance().zombifyForRemove(registration); + repo.scheduleNow( + new Runnable() { + @Override + public void run() { + repo.removeEventCallback(registration); + } + }); + } + + private void addEventRegistration(final EventRegistration listener) { + ZombieEventManager.getInstance().recordEventRegistration(listener); + repo.scheduleNow( + new Runnable() { + @Override + public void run() { + repo.addEventCallback(listener); + } + }); + } + + /** + * By calling `keepSynced(true)` on a location, the data for that location will automatically be + * downloaded and kept in sync, even when no listeners are attached for that location. + * Additionally, while a location is kept synced, it will not be evicted from the persistent disk + * cache. + * + * @param keepSynced Pass `true` to keep this location synchronized, pass `false` to stop + * synchronization. + * @since 2.3 + */ + public void keepSynced(final boolean keepSynced) { + if (!this.path.isEmpty() && this.path.getFront().equals(ChildKey.getInfoKey())) { + throw new DatabaseException("Can't call keepSynced() on .info paths."); + } + + repo.scheduleNow( + new Runnable() { + @Override + public void run() { + repo.keepSynced(getSpec(), keepSynced); + } + }); + } + + /* Removes all of the event listeners at this location */ + /*public void removeAllEventListeners() { + Query.scheduleNow(new Runnable() { + + @Override + public void run() { + repo.removeEventCallback(Query.this, null); + } + }); + }*/ + + /** + * Create a query constrained to only return child nodes with a value greater than or equal to the + * given value, using the given orderBy directive or priority as default. + * + * @param value The value to start at, inclusive + * @return A Query with the new constraint + */ + public Query startAt(String value) { + return startAt(value, null); + } + + /** + * Create a query constrained to only return child nodes with a value greater than or equal to the + * given value, using the given orderBy directive or priority as default. + * + * @param value The value to start at, inclusive + * @return A Query with the new constraint + */ + public Query startAt(double value) { + return startAt(value, null); + } + + /** + * Create a query constrained to only return child nodes with a value greater than or equal to the + * given value, using the given orderBy directive or priority as default. + * + * @param value The value to start at, inclusive + * @return A Query with the new constraint + * @since 2.0 + */ + public Query startAt(boolean value) { + return startAt(value, null); + } + + /** + * Create a query constrained to only return child nodes with a value greater than or equal to the + * given value, using the given orderBy directive or priority as default, and additionally only + * child nodes with a key greater than or equal to the given key. + * + * @param value The priority to start at, inclusive + * @param key The key to start at, inclusive + * @return A Query with the new constraint + */ + public Query startAt(String value, String key) { + Node node = + value != null ? new StringNode(value, PriorityUtilities.NullPriority()) : EmptyNode.Empty(); + return startAt(node, key); + } + + /** + * Create a query constrained to only return child nodes with a value greater than or equal to the + * given value, using the given orderBy directive or priority as default, and additionally only + * child nodes with a key greater than or equal to the given key. + * + * @param value The priority to start at, inclusive + * @param key The key name to start at, inclusive + * @return A Query with the new constraint + */ + public Query startAt(double value, String key) { + return startAt(new DoubleNode(value, PriorityUtilities.NullPriority()), key); + } + + /** + * Create a query constrained to only return child nodes with a value greater than or equal to the + * given value, using the given orderBy directive or priority as default, and additionally only + * child nodes with a key greater than or equal to the given key. + * + * @param value The priority to start at, inclusive + * @param key The key to start at, inclusive + * @return A Query with the new constraint + * @since 2.0 + */ + public Query startAt(boolean value, String key) { + return startAt(new BooleanNode(value, PriorityUtilities.NullPriority()), key); + } + + private Query startAt(Node node, String key) { + Validation.validateNullableKey(key); + if (!(node.isLeafNode() || node.isEmpty())) { + throw new IllegalArgumentException("Can only use simple values for startAt()"); + } + if (params.hasStart()) { + throw new IllegalArgumentException("Can't call startAt() or equalTo() multiple times"); + } + ChildKey childKey = key != null ? ChildKey.fromString(key) : null; + QueryParams newParams = params.startAt(node, childKey); + validateLimit(newParams); + validateQueryEndpoints(newParams); + assert newParams.isValid(); + return new Query(repo, path, newParams, orderByCalled); + } + + /** + * Create a query constrained to only return child nodes with a value less than or equal to the + * given value, using the given orderBy directive or priority as default. + * + * @param value The value to end at, inclusive + * @return A Query with the new constraint + */ + public Query endAt(String value) { + return endAt(value, null); + } + + /** + * Create a query constrained to only return child nodes with a value less than or equal to the + * given value, using the given orderBy directive or priority as default. + * + * @param value The value to end at, inclusive + * @return A Query with the new constraint + */ + public Query endAt(double value) { + return endAt(value, null); + } + + /** + * Create a query constrained to only return child nodes with a value less than or equal to the + * given value, using the given orderBy directive or priority as default. + * + * @param value The value to end at, inclusive + * @return A Query with the new constraint + * @since 2.0 + */ + public Query endAt(boolean value) { + return endAt(value, null); + } + + /** + * Create a query constrained to only return child nodes with a value less than or equal to the + * given value, using the given orderBy directive or priority as default, and additionally only + * child nodes with a key key less than or equal to the given key. + * + * @param value The value to end at, inclusive + * @param key The key to end at, inclusive + * @return A Query with the new constraint + */ + public Query endAt(String value, String key) { + Node node = + value != null ? new StringNode(value, PriorityUtilities.NullPriority()) : EmptyNode.Empty(); + return endAt(node, key); + } + + /** + * Create a query constrained to only return child nodes with a value less than or equal to the + * given value, using the given orderBy directive or priority as default, and additionally only + * child nodes with a key less than or equal to the given key. + * + * @param value The value to end at, inclusive + * @param key The key to end at, inclusive + * @return A Query with the new constraint + */ + public Query endAt(double value, String key) { + return endAt(new DoubleNode(value, PriorityUtilities.NullPriority()), key); + } + + /** + * Create a query constrained to only return child nodes with a value less than or equal to the + * given value, using the given orderBy directive or priority as default, and additionally only + * child nodes with a key less than or equal to the given key. + * + * @param value The value to end at, inclusive + * @param key The key to end at, inclusive + * @return A Query with the new constraint + * @since 2.0 + */ + public Query endAt(boolean value, String key) { + return endAt(new BooleanNode(value, PriorityUtilities.NullPriority()), key); + } + + private Query endAt(Node node, String key) { + Validation.validateNullableKey(key); + if (!(node.isLeafNode() || node.isEmpty())) { + throw new IllegalArgumentException("Can only use simple values for endAt()"); + } + ChildKey childKey = key != null ? ChildKey.fromString(key) : null; + if (params.hasEnd()) { + throw new IllegalArgumentException("Can't call endAt() or equalTo() multiple times"); + } + QueryParams newParams = params.endAt(node, childKey); + validateLimit(newParams); + validateQueryEndpoints(newParams); + assert newParams.isValid(); + return new Query(repo, path, newParams, orderByCalled); + } + + /** + * Create a query constrained to only return child nodes with the given value + * + * @param value The value to query for + * @return A query with the new constraint + */ + public Query equalTo(String value) { + validateEqualToCall(); + return this.startAt(value).endAt(value); + } + + /** + * Create a query constrained to only return child nodes with the given value + * + * @param value The value to query for + * @return A query with the new constraint + */ + public Query equalTo(double value) { + validateEqualToCall(); + return this.startAt(value).endAt(value); + } + + /** + * Create a query constrained to only return child nodes with the given value. + * + * @param value The value to query for + * @return A query with the new constraint + * @since 2.0 + */ + public Query equalTo(boolean value) { + validateEqualToCall(); + return this.startAt(value).endAt(value); + } + + /** + * Create a query constrained to only return the child node with the given key and value. Note + * that there is at most one such child as names are unique. + * + * @param value The value to query for + * @param key The key of the child + * @return A query with the new constraint + */ + public Query equalTo(String value, String key) { + validateEqualToCall(); + return this.startAt(value, key).endAt(value, key); + } + + /** + * Create a query constrained to only return the child node with the given key and value. Note + * that there is at most one such child as keys are unique. + * + * @param value The value to query for + * @param key The key of the child + * @return A query with the new constraint + */ + public Query equalTo(double value, String key) { + validateEqualToCall(); + return this.startAt(value, key).endAt(value, key); + } + + /** + * Create a query constrained to only return the child node with the given key and value. Note + * that there is at most one such child as keys are unique. + * + * @param value The value to query for + * @param key The name of the child + * @return A query with the new constraint + */ + public Query equalTo(boolean value, String key) { + validateEqualToCall(); + return this.startAt(value, key).endAt(value, key); + } + + /** + * Create a query with limit and anchor it to the start of the window + * + * @param limit The maximum number of child nodes to return + * @return A Query with the new constraint + * @since 2.0 + */ + public Query limitToFirst(int limit) { + if (limit <= 0) { + throw new IllegalArgumentException("Limit must be a positive integer!"); + } + if (params.hasLimit()) { + throw new IllegalArgumentException( + "Can't call limitToLast on query with previously set limit!"); + } + return new Query(repo, path, params.limitToFirst(limit), orderByCalled); + } + + /** + * Create a query with limit and anchor it to the end of the window + * + * @param limit The maximum number of child nodes to return + * @return A Query with the new constraint + * @since 2.0 + */ + public Query limitToLast(int limit) { + if (limit <= 0) { + throw new IllegalArgumentException("Limit must be a positive integer!"); + } + if (params.hasLimit()) { + throw new IllegalArgumentException( + "Can't call limitToLast on query with previously set limit!"); + } + return new Query(repo, path, params.limitToLast(limit), orderByCalled); + } + + /** + * Create a query in which child nodes are ordered by the values of the specified path. + * + * @param path The path to the child node to use for sorting + * @return A Query with the new constraint + * @since 2.0 + */ + public Query orderByChild(String path) { + if (path == null) { + throw new NullPointerException("Key can't be null"); + } + if (path.equals("$key") || path.equals(".key")) { + throw new IllegalArgumentException( + "Can't use '" + path + "' as path, please use orderByKey() instead!"); + } + if (path.equals("$priority") || path.equals(".priority")) { + throw new IllegalArgumentException( + "Can't use '" + path + "' as path, please use orderByPriority() instead!"); + } + if (path.equals("$value") || path.equals(".value")) { + throw new IllegalArgumentException( + "Can't use '" + path + "' as path, please use orderByValue() instead!"); + } + Validation.validatePathString(path); + validateNoOrderByCall(); + Path indexPath = new Path(path); + if (indexPath.size() == 0) { + throw new IllegalArgumentException("Can't use empty path, use orderByValue() instead!"); + } + Index index = new PathIndex(indexPath); + return new Query(repo, this.path, params.orderBy(index), true); + } + + /** + * Create a query in which child nodes are ordered by their priorities. + * + * @return A Query with the new constraint + * @since 2.0 + */ + public Query orderByPriority() { + validateNoOrderByCall(); + QueryParams newParams = params.orderBy(PriorityIndex.getInstance()); + validateQueryEndpoints(newParams); + return new Query(repo, path, newParams, true); + } + + /** + * Create a query in which child nodes are ordered by their keys. + * + * @return A Query with the new constraint + * @since 2.0 + */ + public Query orderByKey() { + validateNoOrderByCall(); + QueryParams newParams = this.params.orderBy(KeyIndex.getInstance()); + validateQueryEndpoints(newParams); + return new Query(repo, path, newParams, true); + } + + /** + * Create a query in which nodes are ordered by their value + * + * @return A Query with the new constraint + * @since 2.2 + */ + public Query orderByValue() { + validateNoOrderByCall(); + return new Query(repo, path, params.orderBy(ValueIndex.getInstance()), true); + } + + /** + * @return A DatabaseReference to this location + */ + public DatabaseReference getRef() { + return new DatabaseReference(repo, getPath()); + } + + // Need to hide these... + + /** + * For internal use + * + * @return The path to this location + * @hide + */ + public Path getPath() { + return path; + } + + /** + * For internal use + * + * @return The repo + * @hide + */ + public Repo getRepo() { + return repo; + } + + /** + * For internal use + * + * @return The constraints + * @hide + */ + public QuerySpec getSpec() { + return new QuerySpec(path, params); + } +} diff --git a/src/main/java/com/google/firebase/database/ServerValue.java b/src/main/java/com/google/firebase/database/ServerValue.java new file mode 100644 index 000000000..24fdcb5d3 --- /dev/null +++ b/src/main/java/com/google/firebase/database/ServerValue.java @@ -0,0 +1,24 @@ +package com.google.firebase.database; + +// Server values + +import com.google.firebase.database.core.ServerValues; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +/** Contains placeholder values to use when writing data to the Firebase Database. */ +public class ServerValue { + + /** + * A placeholder value for auto-populating the current timestamp (time since the Unix epoch, in + * milliseconds) by the Firebase Database servers. + */ + public static final Map TIMESTAMP = createServerValuePlaceholder("timestamp"); + + private static Map createServerValuePlaceholder(String key) { + Map result = new HashMap<>(); + result.put(ServerValues.NAME_SUBKEY_SERVERVALUE, key); + return Collections.unmodifiableMap(result); + } +} diff --git a/src/main/java/com/google/firebase/database/ThrowOnExtraProperties.java b/src/main/java/com/google/firebase/database/ThrowOnExtraProperties.java new file mode 100644 index 000000000..50b59baf2 --- /dev/null +++ b/src/main/java/com/google/firebase/database/ThrowOnExtraProperties.java @@ -0,0 +1,16 @@ +package com.google.firebase.database; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Properties that don't map to class fields when serializing to a class annotated with this + * annotation cause an exception to be thrown. + */ +@Retention(RetentionPolicy.RUNTIME) +@Target({ElementType.TYPE}) +public @interface ThrowOnExtraProperties { + +} diff --git a/src/main/java/com/google/firebase/database/Transaction.java b/src/main/java/com/google/firebase/database/Transaction.java new file mode 100644 index 000000000..75b972fb5 --- /dev/null +++ b/src/main/java/com/google/firebase/database/Transaction.java @@ -0,0 +1,103 @@ +package com.google.firebase.database; + +import com.google.firebase.database.snapshot.Node; + +/** + * The Transaction class encapsulates the functionality needed to perform a transaction on the data + * at a location.
+ *
+ * To run a transaction, provide a {@link Handler} to {@link + * DatabaseReference#runTransaction(com.google.firebase.database.Transaction.Handler)}. That handler + * will be passed the current data at the location, and must return a {@link Result}. A {@link + * Result} can be created using either {@link Transaction#success(MutableData)} or {@link + * com.google.firebase.database.Transaction#abort()}. + */ +public class Transaction { + + /** + * Instances of this class represent the desired outcome of a single run of a {@link Handler}'s + * doTransaction method. The options are: + * + *
    + *
  • Set the data to the new value (success) + *
  • abort the transaction + *
+ * + * Instances are created using {@link Transaction#success(MutableData)} or {@link + * com.google.firebase.database.Transaction#abort()}. + */ + public static class Result { + + private boolean success; + private Node data; + + private Result(boolean success, Node data) { + this.success = success; + this.data = data; + } + + /** @return Whether or not this result is a success */ + public boolean isSuccess() { + return success; + } + + /** + * For internal use + * + * @hide + * @return The data + */ + public Node getNode() { + return data; + } + } + + /** + * An object implementing this interface is used to run a transaction, and will be notified of the + * results of the transaction. + */ + public interface Handler { + + /** + * This method will be called, possibly multiple times, with the current data at this + * location. It is responsible for inspecting that data and returning a {@link Result} + * specifying either the desired new data at the location or that the transaction should be + * aborted.
+ *
+ * Since this method may be called repeatedly for the same transaction, be extremely careful of + * any side effects that may be triggered by this method. In addition, this method is called + * from within the Firebase Database library's run loop, so care is also required when accessing + * data that may be in use by other threads in your application.
+ *
+ * Best practices for this method are to rely only on the data that is passed in. + * + * @param currentData The current data at the location. Update this to the desired data at the + * location + * @return Either the new data, or an indication to abort the transaction + */ + Result doTransaction(MutableData currentData); + + /** + * This method will be called once with the results of the transaction. + * + * @param error null if no errors occurred, otherwise it contains a description of the error + * @param committed True if the transaction successfully completed, false if it was aborted or + * an error occurred + * @param currentData The current data at the location + */ + void onComplete(DatabaseError error, boolean committed, DataSnapshot currentData); + } + + /** @return A {@link Result} that aborts the transaction */ + public static Result abort() { + return new Result(false, null); + } + + /** + * @param resultData The desired data at the location + * @return A {@link Result} indicating the new data to be stored at the location + */ + public static Result success(MutableData resultData) { + return new Result(true, resultData.getNode()); + } +} diff --git a/src/main/java/com/google/firebase/database/ValueEventListener.java b/src/main/java/com/google/firebase/database/ValueEventListener.java new file mode 100644 index 000000000..d50bf9ba5 --- /dev/null +++ b/src/main/java/com/google/firebase/database/ValueEventListener.java @@ -0,0 +1,28 @@ +package com.google.firebase.database; + +/** + * Classes implementing this interface can be used to receive events about data changes at a + * location. Attach the listener to a location user {@link + * DatabaseReference#addValueEventListener(ValueEventListener)}. + */ +public interface ValueEventListener { + + /** + * This method will be called with a snapshot of the data at this location. It will also be called + * each time that data changes. + * + * @param snapshot The current data at the location + */ + void onDataChange(DataSnapshot snapshot); + + /** + * This method will be triggered in the event that this listener either failed at the server, or + * is removed as a result of the security and Firebase Database rules. For more information on + * securing your data, see: Security + * Quickstart + * + * @param error A description of the error that occurred + */ + void onCancelled(DatabaseError error); +} diff --git a/src/main/java/com/google/firebase/database/annotations/NotNull.java b/src/main/java/com/google/firebase/database/annotations/NotNull.java new file mode 100644 index 000000000..d74b96ff2 --- /dev/null +++ b/src/main/java/com/google/firebase/database/annotations/NotNull.java @@ -0,0 +1,19 @@ +package com.google.firebase.database.annotations; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * An element annotated with this class indicates that it cannot be null. This is used by lint tools + * to ensure callers properly check for null values before sending values as parameters. It can also + * be used on return values to indicate to the caller that the return cannot be null so therefore no + * null checks need to be made. + */ +@Retention(RetentionPolicy.CLASS) +@Target({ElementType.METHOD, ElementType.FIELD, ElementType.PARAMETER, ElementType.LOCAL_VARIABLE}) +public @interface NotNull { + + String value() default ""; +} diff --git a/src/main/java/com/google/firebase/database/annotations/Nullable.java b/src/main/java/com/google/firebase/database/annotations/Nullable.java new file mode 100644 index 000000000..1c41b9941 --- /dev/null +++ b/src/main/java/com/google/firebase/database/annotations/Nullable.java @@ -0,0 +1,18 @@ +package com.google.firebase.database.annotations; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * An element annotated with this class indicates that it can be null. This is used by lint tools to + * inform callers that they may send in null values as parameters. It can also be used on return + * values to indicate to the caller that he or she must check for null. + */ +@Retention(RetentionPolicy.CLASS) +@Target({ElementType.METHOD, ElementType.FIELD, ElementType.PARAMETER, ElementType.LOCAL_VARIABLE}) +public @interface Nullable { + + String value() default ""; +} diff --git a/src/main/java/com/google/firebase/database/collection/ArraySortedMap.java b/src/main/java/com/google/firebase/database/collection/ArraySortedMap.java new file mode 100644 index 000000000..f9816d539 --- /dev/null +++ b/src/main/java/com/google/firebase/database/collection/ArraySortedMap.java @@ -0,0 +1,272 @@ +package com.google.firebase.database.collection; + +import java.util.AbstractMap; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.HashMap; +import java.util.Iterator; +import java.util.List; +import java.util.Map; + +/** + * This is an array backed implementation of ImmutableSortedMap. It uses arrays and linear lookups + * to achieve good memory efficiency while maintaining good performance for small collections. To + * avoid degrading performance with increasing collection size it will automatically convert to a + * RBTreeSortedMap after an insert call above a certain threshold. + */ +public class ArraySortedMap extends ImmutableSortedMap { + + @SuppressWarnings("unchecked") + public static ArraySortedMap buildFrom(List keys, Map values, + Builder.KeyTranslator translator, + Comparator comparator) { + Collections.sort(keys, comparator); + int size = keys.size(); + A[] keyArray = (A[]) new Object[size]; + C[] valueArray = (C[]) new Object[size]; + int pos = 0; + for (A k : keys) { + keyArray[pos] = k; + C value = values.get(translator.translate(k)); + valueArray[pos] = value; + pos++; + } + return new ArraySortedMap<>(comparator, keyArray, valueArray); + } + + public static ArraySortedMap fromMap(Map map, Comparator comparator) { + return buildFrom(new ArrayList<>(map.keySet()), map, Builder.identityTranslator(), + comparator); + } + + private final K[] keys; + private final V[] values; + private final Comparator comparator; + + @SuppressWarnings("unchecked") + public ArraySortedMap(Comparator comparator) { + this.keys = (K[]) new Object[0]; + this.values = (V[]) new Object[0]; + this.comparator = comparator; + } + + @SuppressWarnings("unchecked") + private ArraySortedMap(Comparator comparator, K[] keys, V[] values) { + this.keys = keys; + this.values = values; + this.comparator = comparator; + } + + @Override + public boolean containsKey(K key) { + return findKey(key) != -1; + } + + @Override + public V get(K key) { + int pos = findKey(key); + return pos != -1 ? this.values[pos] : null; + } + + @Override + public ImmutableSortedMap remove(K key) { + int pos = findKey(key); + if (pos == -1) { + return this; + } else { + K[] keys = removeFromArray(this.keys, pos); + V[] values = removeFromArray(this.values, pos); + return new ArraySortedMap<>(this.comparator, keys, values); + } + } + + @Override + public ImmutableSortedMap insert(K key, V value) { + int pos = findKey(key); + if (pos != -1) { + if (this.keys[pos] == key && this.values[pos] == value) { + return this; + } else { + // The key and/or value might have changed, even though the comparison might still yield 0 + K[] newKeys = replaceInArray(this.keys, pos, key); + V[] newValues = replaceInArray(this.values, pos, value); + return new ArraySortedMap<>(this.comparator, newKeys, newValues); + } + } else { + if (this.keys.length > Builder.ARRAY_TO_RB_TREE_SIZE_THRESHOLD) { + @SuppressWarnings("unchecked") + Map map = new HashMap<>(this.keys.length + 1); + for (int i = 0; i < this.keys.length; i++) { + map.put(this.keys[i], this.values[i]); + } + map.put(key, value); + return RBTreeSortedMap.fromMap(map, this.comparator); + } else { + int newPos = findKeyOrInsertPosition(key); + K[] keys = addToArray(this.keys, newPos, key); + V[] values = addToArray(this.values, newPos, value); + return new ArraySortedMap<>(this.comparator, keys, values); + } + } + } + + @Override + public K getMinKey() { + return this.keys.length > 0 ? this.keys[0] : null; + } + + @Override + public K getMaxKey() { + return this.keys.length > 0 ? this.keys[this.keys.length - 1] : null; + } + + @Override + public int size() { + return this.keys.length; + } + + @Override + public boolean isEmpty() { + return this.keys.length == 0; + } + + @Override + public void inOrderTraversal(LLRBNode.NodeVisitor visitor) { + for (int i = 0; i < this.keys.length; i++) { + visitor.visitEntry(this.keys[i], this.values[i]); + } + } + + private Iterator> iterator(final int pos, final boolean reverse) { + return new Iterator>() { + int currentPos = pos; + + @Override + public boolean hasNext() { + return reverse ? currentPos >= 0 : currentPos < keys.length; + } + + @Override + public Map.Entry next() { + final K key = keys[currentPos]; + final V value = values[currentPos]; + currentPos = reverse ? currentPos - 1 : currentPos + 1; + return new AbstractMap.SimpleImmutableEntry<>(key, value); + } + + @Override + public void remove() { + throw new UnsupportedOperationException("Can't remove elements from ImmutableSortedMap"); + } + }; + } + + @Override + public Iterator> iterator() { + return iterator(0, false); + } + + @Override + public Iterator> iteratorFrom(K key) { + int pos = findKeyOrInsertPosition(key); + return iterator(pos, false); + } + + @Override + public Iterator> reverseIteratorFrom(K key) { + int pos = findKeyOrInsertPosition(key); + // if there's no exact match, findKeyOrInsertPosition will return the index *after* the closest match, but + // since this is a reverse iterator, we want to start just *before* the closest match. + if (pos < this.keys.length && this.comparator.compare(this.keys[pos], key) == 0) { + return iterator(pos, true); + } else { + return iterator(pos - 1, true); + } + } + + @Override + public Iterator> reverseIterator() { + return iterator(this.keys.length - 1, true); + } + + @Override + public K getPredecessorKey(K key) { + int pos = findKey(key); + if (pos == -1) { + throw new IllegalArgumentException("Can't find predecessor of nonexistent key"); + } else { + return (pos > 0) ? this.keys[pos - 1] : null; + } + } + + @Override + public K getSuccessorKey(K key) { + int pos = findKey(key); + if (pos == -1) { + throw new IllegalArgumentException("Can't find successor of nonexistent key"); + } else { + return (pos < this.keys.length - 1) ? this.keys[pos + 1] : null; + } + } + + @Override + public Comparator getComparator() { + return comparator; + } + + @SuppressWarnings("unchecked") + private static T[] removeFromArray(T[] arr, int pos) { + int newSize = arr.length - 1; + T[] newArray = (T[]) new Object[newSize]; + System.arraycopy(arr, 0, newArray, 0, pos); + System.arraycopy(arr, pos + 1, newArray, pos, newSize - pos); + return newArray; + } + + @SuppressWarnings("unchecked") + private static T[] addToArray(T[] arr, int pos, T value) { + int newSize = arr.length + 1; + T[] newArray = (T[]) new Object[newSize]; + System.arraycopy(arr, 0, newArray, 0, pos); + newArray[pos] = value; + System.arraycopy(arr, pos, newArray, pos + 1, newSize - pos - 1); + return newArray; + } + + @SuppressWarnings("unchecked") + private static T[] replaceInArray(T[] arr, int pos, T value) { + int size = arr.length; + T[] newArray = (T[]) new Object[size]; + System.arraycopy(arr, 0, newArray, 0, size); + newArray[pos] = value; + return newArray; + } + + /** + * This does a linear scan which is simpler than a binary search. For a small collection size this + * still should be as fast a as binary search. + */ + private int findKeyOrInsertPosition(K key) { + int newPos = 0; + while (newPos < this.keys.length && this.comparator.compare(this.keys[newPos], key) < 0) { + newPos++; + } + return newPos; + } + + /** + * This does a linear scan which is simpler than a binary search. For a small collection size this + * still should be as fast a as binary search. + */ + private int findKey(K key) { + int i = 0; + for (K otherKey : this.keys) { + if (this.comparator.compare(key, otherKey) == 0) { + return i; + } + i++; + } + return -1; + } +} diff --git a/src/main/java/com/google/firebase/database/collection/ImmutableSortedMap.java b/src/main/java/com/google/firebase/database/collection/ImmutableSortedMap.java new file mode 100644 index 000000000..edd99dd58 --- /dev/null +++ b/src/main/java/com/google/firebase/database/collection/ImmutableSortedMap.java @@ -0,0 +1,154 @@ +package com.google.firebase.database.collection; + +import java.util.Comparator; +import java.util.Iterator; +import java.util.List; +import java.util.Map; + +public abstract class ImmutableSortedMap implements Iterable> { + + public abstract boolean containsKey(K key); + + public abstract V get(K key); + + public abstract ImmutableSortedMap remove(K key); + + public abstract ImmutableSortedMap insert(K key, V value); + + public abstract K getMinKey(); + + public abstract K getMaxKey(); + + public abstract int size(); + + public abstract boolean isEmpty(); + + public abstract void inOrderTraversal(LLRBNode.NodeVisitor visitor); + + public abstract Iterator> iterator(); + + public abstract Iterator> iteratorFrom(K key); + + public abstract Iterator> reverseIteratorFrom(K key); + + public abstract Iterator> reverseIterator(); + + public abstract K getPredecessorKey(K key); + + public abstract K getSuccessorKey(K key); + + public abstract Comparator getComparator(); + + @Override + @SuppressWarnings("unchecked") + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof ImmutableSortedMap)) { + return false; + } + + ImmutableSortedMap that = (ImmutableSortedMap) o; + + if (!this.getComparator().equals(that.getComparator())) { + return false; + } + if (this.size() != that.size()) { + return false; + } + + Iterator> thisIterator = this.iterator(); + Iterator> thatIterator = that.iterator(); + while (thisIterator.hasNext()) { + if (!thisIterator.next().equals(thatIterator.next())) { + return false; + } + } + + return true; + } + + @Override + public int hashCode() { + int result = this.getComparator().hashCode(); + for (Map.Entry entry : this) { + result = 31 * result + entry.hashCode(); + } + + return result; + } + + public String toString() { + StringBuilder b = new StringBuilder(); + b.append(this.getClass().getSimpleName()); + b.append("{"); + boolean first = true; + for (Map.Entry entry : this) { + if (first) { + first = false; + } else { + b.append(", "); + } + b.append("("); + b.append(entry.getKey()); + b.append("=>"); + b.append(entry.getValue()); + b.append(")"); + } + b.append("};"); + return b.toString(); + } + + public static class Builder { + + /** + * The size threshold where we use a tree backed sorted map instead of an array backed sorted + * map. This is a more or less arbitrary chosen value, that was chosen to be large enough to fit + * most of object kind of Database data, but small enough to not notice degradation in + * performance for inserting and lookups. Feel free to empirically determine this constant, but + * don't expect much gain in real world performance. + */ + static final int ARRAY_TO_RB_TREE_SIZE_THRESHOLD = 25; + + public static ImmutableSortedMap emptyMap(Comparator comparator) { + return new ArraySortedMap<>(comparator); + } + + public interface KeyTranslator { + + D translate(C key); + } + + private static final KeyTranslator IDENTITY_TRANSLATOR = new KeyTranslator() { + @Override + public Object translate(Object key) { + return key; + } + }; + + @SuppressWarnings("unchecked") + public static KeyTranslator identityTranslator() { + return IDENTITY_TRANSLATOR; + } + + public static ImmutableSortedMap fromMap(Map values, + Comparator comparator) { + if (values.size() < ARRAY_TO_RB_TREE_SIZE_THRESHOLD) { + return ArraySortedMap.fromMap(values, comparator); + } else { + return RBTreeSortedMap.fromMap(values, comparator); + } + } + + public static ImmutableSortedMap buildFrom(List keys, Map values, + ImmutableSortedMap.Builder.KeyTranslator translator, + Comparator comparator) { + if (keys.size() < ARRAY_TO_RB_TREE_SIZE_THRESHOLD) { + return ArraySortedMap.buildFrom(keys, values, translator, comparator); + } else { + return RBTreeSortedMap.buildFrom(keys, values, translator, comparator); + } + } + } +} diff --git a/src/main/java/com/google/firebase/database/collection/ImmutableSortedMapIterator.java b/src/main/java/com/google/firebase/database/collection/ImmutableSortedMapIterator.java new file mode 100644 index 000000000..d3f721e72 --- /dev/null +++ b/src/main/java/com/google/firebase/database/collection/ImmutableSortedMapIterator.java @@ -0,0 +1,92 @@ +package com.google.firebase.database.collection; + +import java.util.AbstractMap; +import java.util.Comparator; +import java.util.EmptyStackException; +import java.util.Iterator; +import java.util.Map; +import java.util.NoSuchElementException; +import java.util.Stack; + +/** + * User: greg + * Date: 5/21/13 + * Time: 10:31 AM + */ +public class ImmutableSortedMapIterator implements Iterator> { + + private final Stack> nodeStack; + + private final boolean isReverse; + + ImmutableSortedMapIterator(LLRBNode root, K startKey, Comparator comparator, + boolean isReverse) { + this.nodeStack = new Stack<>(); + this.isReverse = isReverse; + + LLRBNode node = root; + while (!node.isEmpty()) { + int cmp; + if (startKey != null) { + cmp = isReverse ? comparator.compare(startKey, node.getKey()) + : comparator.compare(node.getKey(), startKey); + } else { + cmp = 1; + } + if (cmp < 0) { + // This node is less than our start key. ignore it + if (isReverse) { + node = node.getLeft(); + } else { + node = node.getRight(); + } + } else if (cmp == 0) { + // This node is exactly equal to our start key. Push it on the stack, but stop iterating; + this.nodeStack.push((LLRBValueNode) node); + break; + } else { + this.nodeStack.push((LLRBValueNode) node); + if (isReverse) { + node = node.getRight(); + } else { + node = node.getLeft(); + } + } + } + } + + @Override + public boolean hasNext() { + return nodeStack.size() > 0; + } + + @Override + public Map.Entry next() { + try { + final LLRBValueNode node = nodeStack.pop(); + Map.Entry entry = new AbstractMap.SimpleEntry<>(node.getKey(), node.getValue()); + if (this.isReverse) { + LLRBNode next = node.getLeft(); + while (!next.isEmpty()) { + this.nodeStack.push((LLRBValueNode) next); + next = next.getRight(); + } + } else { + LLRBNode next = node.getRight(); + while (!next.isEmpty()) { + this.nodeStack.push((LLRBValueNode) next); + next = next.getLeft(); + } + } + return entry; + } catch (EmptyStackException e) { + // No more children + throw new NoSuchElementException(); + } + } + + @Override + public void remove() { + throw new UnsupportedOperationException("remove called on immutable collection"); + } +} diff --git a/src/main/java/com/google/firebase/database/collection/ImmutableSortedSet.java b/src/main/java/com/google/firebase/database/collection/ImmutableSortedSet.java new file mode 100644 index 000000000..2261099ae --- /dev/null +++ b/src/main/java/com/google/firebase/database/collection/ImmutableSortedSet.java @@ -0,0 +1,111 @@ +package com.google.firebase.database.collection; + +import java.util.Collections; +import java.util.Comparator; +import java.util.Iterator; +import java.util.List; +import java.util.Map; + +public class ImmutableSortedSet implements Iterable { + + private final ImmutableSortedMap map; + + private static class WrappedEntryIterator implements Iterator { + + final Iterator> iterator; + + public WrappedEntryIterator(Iterator> iterator) { + this.iterator = iterator; + } + + @Override + public boolean hasNext() { + return this.iterator.hasNext(); + } + + @Override + public T next() { + return this.iterator.next().getKey(); + } + + @Override + public void remove() { + this.iterator.remove(); + } + } + + public ImmutableSortedSet(List elems, Comparator comparator) { + this.map = ImmutableSortedMap.Builder.buildFrom(elems, Collections.emptyMap(), + ImmutableSortedMap.Builder.identityTranslator(), comparator); + } + + private ImmutableSortedSet(ImmutableSortedMap map) { + this.map = map; + } + + @Override + public boolean equals(Object other) { + if (this == other) { + return true; + } + if (!(other instanceof ImmutableSortedSet)) { + return false; + } + ImmutableSortedSet otherSet = (ImmutableSortedSet) other; + return map.equals(otherSet.map); + } + + @Override + public int hashCode() { + return map.hashCode(); + } + + public boolean contains(T entry) { + return this.map.containsKey(entry); + } + + public ImmutableSortedSet remove(T entry) { + ImmutableSortedMap newMap = this.map.remove(entry); + return (newMap == this.map) ? this : new ImmutableSortedSet<>(newMap); + } + + public ImmutableSortedSet insert(T entry) { + return new ImmutableSortedSet<>(map.insert(entry, null)); + } + + public T getMinEntry() { + return this.map.getMinKey(); + } + + public T getMaxEntry() { + return this.map.getMaxKey(); + } + + public int size() { + return this.map.size(); + } + + public boolean isEmpty() { + return this.map.isEmpty(); + } + + public Iterator iterator() { + return new WrappedEntryIterator<>(this.map.iterator()); + } + + public Iterator iteratorFrom(T entry) { + return new WrappedEntryIterator<>(this.map.iteratorFrom(entry)); + } + + public Iterator reverseIteratorFrom(T entry) { + return new WrappedEntryIterator<>(this.map.reverseIteratorFrom(entry)); + } + + public Iterator reverseIterator() { + return new WrappedEntryIterator<>(this.map.reverseIterator()); + } + + public T getPredecessorEntry(T entry) { + return this.map.getPredecessorKey(entry); + } +} diff --git a/src/main/java/com/google/firebase/database/collection/LLRBBlackValueNode.java b/src/main/java/com/google/firebase/database/collection/LLRBBlackValueNode.java new file mode 100644 index 000000000..100464882 --- /dev/null +++ b/src/main/java/com/google/firebase/database/collection/LLRBBlackValueNode.java @@ -0,0 +1,28 @@ +package com.google.firebase.database.collection; + +public class LLRBBlackValueNode extends LLRBValueNode { + + LLRBBlackValueNode(K key, V value, LLRBNode left, LLRBNode right) { + super(key, value, left, right); + } + + @Override + protected Color getColor() { + return Color.BLACK; + } + + @Override + public boolean isRed() { + return false; + } + + @Override + protected LLRBValueNode copy(K key, V value, LLRBNode left, LLRBNode right) { + K newKey = key == null ? this.getKey() : key; + V newValue = value == null ? this.getValue() : value; + LLRBNode newLeft = left == null ? this.getLeft() : left; + LLRBNode newRight = right == null ? this.getRight() : right; + return new LLRBBlackValueNode<>(newKey, newValue, newLeft, newRight); + } + +} diff --git a/src/main/java/com/google/firebase/database/collection/LLRBEmptyNode.java b/src/main/java/com/google/firebase/database/collection/LLRBEmptyNode.java new file mode 100644 index 000000000..97b12bd4e --- /dev/null +++ b/src/main/java/com/google/firebase/database/collection/LLRBEmptyNode.java @@ -0,0 +1,100 @@ +package com.google.firebase.database.collection; + +import java.util.Comparator; + +/** + * User: greg + * Date: 5/17/13 + * Time: 8:48 AM + */ +public class LLRBEmptyNode implements LLRBNode { + + private static final LLRBEmptyNode INSTANCE = new LLRBEmptyNode(); + + @SuppressWarnings("unchecked") + public static LLRBEmptyNode getInstance() { + return INSTANCE; + } + + private LLRBEmptyNode() { + + } + + @Override + public LLRBNode copy(K key, V value, Color color, LLRBNode left, + LLRBNode right) { + return this; + } + + @Override + public LLRBNode insert(K key, V value, Comparator comparator) { + return new LLRBRedValueNode<>(key, value); + } + + @Override + public LLRBNode remove(K key, Comparator comparator) { + return this; + } + + @Override + public boolean isEmpty() { + return true; + } + + @Override + public boolean isRed() { + return false; + } + + @Override + public K getKey() { + return null; + } + + @Override + public V getValue() { + return null; + } + + @Override + public LLRBNode getLeft() { + return this; + } + + @Override + public LLRBNode getRight() { + return this; + } + + @Override + public LLRBNode getMin() { + return this; + } + + @Override + public LLRBNode getMax() { + return this; + } + + @Override + public int count() { + return 0; + } + + @Override + public void inOrderTraversal(NodeVisitor visitor) { + // No-op + } + + @Override + public boolean shortCircuitingInOrderTraversal(ShortCircuitingNodeVisitor visitor) { + // No-op + return true; + } + + @Override + public boolean shortCircuitingReverseOrderTraversal(ShortCircuitingNodeVisitor visitor) { + // No-op + return true; + } +} diff --git a/src/main/java/com/google/firebase/database/collection/LLRBNode.java b/src/main/java/com/google/firebase/database/collection/LLRBNode.java new file mode 100644 index 000000000..e8517956e --- /dev/null +++ b/src/main/java/com/google/firebase/database/collection/LLRBNode.java @@ -0,0 +1,59 @@ +package com.google.firebase.database.collection; + +import java.util.Comparator; + +/** + * User: greg + * Date: 5/17/13 + * Time: 8:48 AM + */ +public interface LLRBNode { + + interface ShortCircuitingNodeVisitor { + + boolean shouldContinue(K key, V value); + } + + abstract class NodeVisitor implements ShortCircuitingNodeVisitor { + + @Override + public boolean shouldContinue(K key, V value) { + visitEntry(key, value); + return true; + } + + abstract public void visitEntry(K key, V value); + } + + enum Color {RED, BLACK} + + LLRBNode copy(K key, V value, Color color, LLRBNode left, LLRBNode right); + + LLRBNode insert(K key, V value, Comparator comparator); + + LLRBNode remove(K key, Comparator comparator); + + boolean isEmpty(); + + boolean isRed(); + + K getKey(); + + V getValue(); + + LLRBNode getLeft(); + + LLRBNode getRight(); + + LLRBNode getMin(); + + LLRBNode getMax(); + + int count(); + + void inOrderTraversal(NodeVisitor visitor); + + boolean shortCircuitingInOrderTraversal(ShortCircuitingNodeVisitor visitor); + + boolean shortCircuitingReverseOrderTraversal(ShortCircuitingNodeVisitor visitor); +} diff --git a/src/main/java/com/google/firebase/database/collection/LLRBRedValueNode.java b/src/main/java/com/google/firebase/database/collection/LLRBRedValueNode.java new file mode 100644 index 000000000..d7e4517db --- /dev/null +++ b/src/main/java/com/google/firebase/database/collection/LLRBRedValueNode.java @@ -0,0 +1,33 @@ +package com.google.firebase.database.collection; + +import static com.google.firebase.database.collection.LLRBNode.Color.RED; + +public class LLRBRedValueNode extends LLRBValueNode { + + LLRBRedValueNode(K key, V value) { + super(key, value, LLRBEmptyNode.getInstance(), LLRBEmptyNode.getInstance()); + } + + LLRBRedValueNode(K key, V value, LLRBNode left, LLRBNode right) { + super(key, value, left, right); + } + + @Override + protected Color getColor() { + return RED; + } + + @Override + public boolean isRed() { + return true; + } + + @Override + protected LLRBValueNode copy(K key, V value, LLRBNode left, LLRBNode right) { + K newKey = key == null ? this.getKey() : key; + V newValue = value == null ? this.getValue() : value; + LLRBNode newLeft = left == null ? this.getLeft() : left; + LLRBNode newRight = right == null ? this.getRight() : right; + return new LLRBRedValueNode<>(newKey, newValue, newLeft, newRight); + } +} diff --git a/src/main/java/com/google/firebase/database/collection/LLRBValueNode.java b/src/main/java/com/google/firebase/database/collection/LLRBValueNode.java new file mode 100644 index 000000000..af1a80a31 --- /dev/null +++ b/src/main/java/com/google/firebase/database/collection/LLRBValueNode.java @@ -0,0 +1,244 @@ +package com.google.firebase.database.collection; + +import java.util.Comparator; + +/** + * User: greg + * Date: 5/17/13 + * Time: 8:51 AM + */ +abstract public class LLRBValueNode implements LLRBNode { + + private static Color oppositeColor(LLRBNode node) { + return node.isRed() ? Color.BLACK : Color.RED; + } + + final private K key; + final private V value; + private LLRBNode left; + final private LLRBNode right; + + LLRBValueNode(K key, V value, LLRBNode left, LLRBNode right) { + this.key = key; + this.value = value; + this.left = left == null ? LLRBEmptyNode.getInstance() : left; + this.right = right == null ? LLRBEmptyNode.getInstance() : right; + } + + @Override + public LLRBNode getLeft() { + return left; + } + + @Override + public LLRBNode getRight() { + return right; + } + + @Override + public K getKey() { + return key; + } + + @Override + public V getValue() { + return value; + } + + protected abstract Color getColor(); + + protected abstract LLRBValueNode copy(K key, V value, LLRBNode left, + LLRBNode right); + + @Override + public LLRBValueNode copy(K key, V value, Color color, LLRBNode left, + LLRBNode right) { + K newKey = key == null ? this.key : key; + V newValue = value == null ? this.value : value; + LLRBNode newLeft = left == null ? this.left : left; + LLRBNode newRight = right == null ? this.right : right; + if (color == Color.RED) { + return new LLRBRedValueNode<>(newKey, newValue, newLeft, newRight); + } else { + return new LLRBBlackValueNode<>(newKey, newValue, newLeft, newRight); + } + } + + @Override + public LLRBNode insert(K key, V value, Comparator comparator) { + int cmp = comparator.compare(key, this.key); + LLRBValueNode n; + if (cmp < 0) { + // new key is less than current key + LLRBNode newLeft = this.left.insert(key, value, comparator); + n = copy(null, null, newLeft, null); + } else if (cmp == 0) { + // same key + n = copy(key, value, null, null); + } else { + // new key is greater than current key + LLRBNode newRight = this.right.insert(key, value, comparator); + n = copy(null, null, null, newRight); + } + return n.fixUp(); + } + + @Override + public LLRBNode remove(K key, Comparator comparator) { + LLRBValueNode n = this; + + if (comparator.compare(key, n.key) < 0) { + if (!n.left.isEmpty() && !n.left.isRed() && !((LLRBValueNode) n.left).left.isRed()) { + n = n.moveRedLeft(); + } + n = n.copy(null, null, n.left.remove(key, comparator), null); + } else { + if (n.left.isRed()) { + n = n.rotateRight(); + } + + if (!n.right.isEmpty() && !n.right.isRed() && !((LLRBValueNode) n.right).left.isRed()) { + n = n.moveRedRight(); + } + + if (comparator.compare(key, n.key) == 0) { + if (n.right.isEmpty()) { + return LLRBEmptyNode.getInstance(); + } else { + LLRBNode smallest = n.right.getMin(); + n = n.copy(smallest.getKey(), smallest.getValue(), null, + ((LLRBValueNode) n.right).removeMin()); + } + } + n = n.copy(null, null, null, n.right.remove(key, comparator)); + } + return n.fixUp(); + } + + @Override + public boolean isEmpty() { + return false; + } + + @Override + public LLRBNode getMin() { + if (left.isEmpty()) { + return this; + } else { + return left.getMin(); + } + } + + @Override + public LLRBNode getMax() { + if (right.isEmpty()) { + return this; + } else { + return right.getMax(); + } + } + + @Override + public int count() { + return left.count() + 1 + right.count(); + } + + @Override + public void inOrderTraversal(NodeVisitor visitor) { + left.inOrderTraversal(visitor); + visitor.visitEntry(key, value); + right.inOrderTraversal(visitor); + } + + @Override + public boolean shortCircuitingInOrderTraversal(ShortCircuitingNodeVisitor visitor) { + if (left.shortCircuitingInOrderTraversal(visitor)) { + if (visitor.shouldContinue(key, value)) { + return right.shortCircuitingInOrderTraversal(visitor); + } + } + return false; + } + + @Override + public boolean shortCircuitingReverseOrderTraversal(ShortCircuitingNodeVisitor visitor) { + if (right.shortCircuitingReverseOrderTraversal(visitor)) { + if (visitor.shouldContinue(key, value)) { + return left.shortCircuitingReverseOrderTraversal(visitor); + } + } + return false; + } + + // For use by the builder, which is package local + void setLeft(LLRBNode left) { + this.left = left; + } + + private LLRBNode removeMin() { + if (left.isEmpty()) { + return LLRBEmptyNode.getInstance(); + } else { + LLRBValueNode n = this; + if (!n.getLeft().isRed() && !n.getLeft().getLeft().isRed()) { + n = n.moveRedLeft(); + } + + n = n.copy(null, null, ((LLRBValueNode) n.left).removeMin(), null); + return n.fixUp(); + } + } + + private LLRBValueNode moveRedLeft() { + LLRBValueNode n = colorFlip(); + if (n.getRight().getLeft().isRed()) { + n = n.copy(null, null, null, ((LLRBValueNode) n.getRight()).rotateRight()); + n = n.rotateLeft(); + n = n.colorFlip(); + } + return n; + } + + private LLRBValueNode moveRedRight() { + LLRBValueNode n = colorFlip(); + if (n.getLeft().getLeft().isRed()) { + n = n.rotateRight(); + n = n.colorFlip(); + } + return n; + } + + private LLRBValueNode fixUp() { + LLRBValueNode n = this; + if (n.right.isRed() && !n.left.isRed()) { + n = n.rotateLeft(); + } + if (n.left.isRed() && ((LLRBValueNode) (n.left)).left.isRed()) { + n = n.rotateRight(); + } + if (n.left.isRed() && n.right.isRed()) { + n = n.colorFlip(); + } + return n; + } + + private LLRBValueNode rotateLeft() { + LLRBValueNode newLeft = this + .copy(null, null, Color.RED, null, ((LLRBValueNode) (this.right)).left); + return (LLRBValueNode) this.right.copy(null, null, this.getColor(), newLeft, null); + } + + private LLRBValueNode rotateRight() { + LLRBValueNode newRight = this + .copy(null, null, Color.RED, ((LLRBValueNode) (this.left)).right, null); + return (LLRBValueNode) this.left.copy(null, null, this.getColor(), null, newRight); + } + + private LLRBValueNode colorFlip() { + LLRBNode newLeft = this.left.copy(null, null, oppositeColor(this.left), null, null); + LLRBNode newRight = this.right.copy(null, null, oppositeColor(this.right), null, null); + + return this.copy(null, null, oppositeColor(this), newLeft, newRight); + } + +} diff --git a/src/main/java/com/google/firebase/database/collection/RBTreeSortedMap.java b/src/main/java/com/google/firebase/database/collection/RBTreeSortedMap.java new file mode 100644 index 000000000..282f5ff7e --- /dev/null +++ b/src/main/java/com/google/firebase/database/collection/RBTreeSortedMap.java @@ -0,0 +1,326 @@ +package com.google.firebase.database.collection; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.Iterator; +import java.util.List; +import java.util.Map; + +/** + * This is a red-black tree backed implementation of ImmutableSortedMap. This has better asymptotic + * complexity for large collections, but performs worse in practice than an ArraySortedMap for small + * collections. It also uses about twice as much memory. + */ +public class RBTreeSortedMap extends ImmutableSortedMap { + + private LLRBNode root; + private Comparator comparator; + + RBTreeSortedMap(Comparator comparator) { + this.root = LLRBEmptyNode.getInstance(); + this.comparator = comparator; + } + + private RBTreeSortedMap(LLRBNode root, Comparator comparator) { + this.root = root; + this.comparator = comparator; + } + + // For testing purposes + LLRBNode getRoot() { + return root; + } + + private LLRBNode getNode(K key) { + LLRBNode node = root; + while (!node.isEmpty()) { + int cmp = this.comparator.compare(key, node.getKey()); + if (cmp < 0) { + node = node.getLeft(); + } else if (cmp == 0) { + return node; + } else { + node = node.getRight(); + } + } + return null; + } + + @Override + public boolean containsKey(K key) { + return getNode(key) != null; + } + + @Override + public V get(K key) { + LLRBNode node = getNode(key); + return node != null ? node.getValue() : null; + } + + @Override + public ImmutableSortedMap remove(K key) { + if (!this.containsKey(key)) { + return this; + } else { + LLRBNode newRoot = root.remove(key, this.comparator) + .copy(null, null, LLRBNode.Color.BLACK, null, null); + return new RBTreeSortedMap<>(newRoot, this.comparator); + } + } + + @Override + public ImmutableSortedMap insert(K key, V value) { + LLRBNode newRoot = root.insert(key, value, this.comparator) + .copy(null, null, LLRBNode.Color.BLACK, null, null); + return new RBTreeSortedMap<>(newRoot, this.comparator); + } + + @Override + public K getMinKey() { + return root.getMin().getKey(); + } + + @Override + public K getMaxKey() { + return root.getMax().getKey(); + } + + @Override + public int size() { + return root.count(); + } + + @Override + public boolean isEmpty() { + return root.isEmpty(); + } + + @Override + public void inOrderTraversal(LLRBNode.NodeVisitor visitor) { + root.inOrderTraversal(visitor); + } + + @Override + public Iterator> iterator() { + return new ImmutableSortedMapIterator<>(root, null, this.comparator, false); + } + + @Override + public Iterator> iteratorFrom(K key) { + return new ImmutableSortedMapIterator<>(root, key, this.comparator, false); + } + + @Override + public Iterator> reverseIteratorFrom(K key) { + return new ImmutableSortedMapIterator<>(root, key, this.comparator, true); + } + + @Override + public Iterator> reverseIterator() { + return new ImmutableSortedMapIterator<>(root, null, this.comparator, true); + } + + @Override + public K getPredecessorKey(K key) { + LLRBNode node = root; + LLRBNode rightParent = null; + while (!node.isEmpty()) { + int cmp = this.comparator.compare(key, node.getKey()); + if (cmp == 0) { + if (!node.getLeft().isEmpty()) { + node = node.getLeft(); + while (!node.getRight().isEmpty()) { + node = node.getRight(); + } + return node.getKey(); + } else if (rightParent != null) { + return rightParent.getKey(); + } else { + return null; + } + } else if (cmp < 0) { + node = node.getLeft(); + } else { + rightParent = node; + node = node.getRight(); + } + } + throw new IllegalArgumentException("Couldn't find predecessor key of non-present key: " + key); + } + + @Override + public K getSuccessorKey(K key) { + LLRBNode node = root; + LLRBNode leftParent = null; + while (!node.isEmpty()) { + int cmp = this.comparator.compare(node.getKey(), key); + if (cmp == 0) { + if (!node.getRight().isEmpty()) { + node = node.getRight(); + while (!node.getLeft().isEmpty()) { + node = node.getLeft(); + } + return node.getKey(); + } else if (leftParent != null) { + return leftParent.getKey(); + } else { + return null; + } + } else if (cmp < 0) { + node = node.getRight(); + } else { + leftParent = node; + node = node.getLeft(); + } + } + throw new IllegalArgumentException("Couldn't find successor key of non-present key: " + key); + } + + @Override + public Comparator getComparator() { + return comparator; + } + + public static RBTreeSortedMap buildFrom(List keys, Map values, + ImmutableSortedMap.Builder.KeyTranslator translator, + Comparator comparator) { + return Builder.buildFrom(keys, values, translator, comparator); + } + + public static RBTreeSortedMap fromMap(Map values, Comparator comparator) { + return Builder.buildFrom(new ArrayList<>(values.keySet()), values, + ImmutableSortedMap.Builder.identityTranslator(), comparator); + } + + private static class Builder { + + static class BooleanChunk { + + public boolean isOne; + public int chunkSize; + } + + static class Base1_2 implements Iterable { + + private long value; + final private int length; + + + public Base1_2(int size) { + int toCalc = size + 1; + length = (int) Math.floor(Math.log(toCalc) / Math.log(2)); + long mask = (long) (Math.pow(2, length)) - 1; + value = toCalc & mask; + } + + /** + * Iterates over booleans for whether or not a particular digit is a '1' in base {1, 2} + * + * @return A reverse iterator over the base {1, 2} number + */ + @Override + public Iterator iterator() { + return new Iterator() { + + private int current = length - 1; + + @Override + public boolean hasNext() { + return current >= 0; + } + + @Override + public BooleanChunk next() { + long result = value & ((byte) 1 << current); + BooleanChunk next = new BooleanChunk(); + next.isOne = result == 0; + next.chunkSize = (int) Math.pow(2, current); + current--; + return next; + } + + @Override + public void remove() { + // No-op + } + }; + } + } + + private final List keys; + private final Map values; + private final ImmutableSortedMap.Builder.KeyTranslator keyTranslator; + + private LLRBValueNode root; + private LLRBValueNode leaf; + + private Builder(List keys, Map values, + ImmutableSortedMap.Builder.KeyTranslator translator) { + this.keys = keys; + this.values = values; + this.keyTranslator = translator; + } + + + private C getValue(A key) { + return values.get(keyTranslator.translate(key)); + } + + private LLRBNode buildBalancedTree(int start, int size) { + if (size == 0) { + return LLRBEmptyNode.getInstance(); + } else if (size == 1) { + A key = this.keys.get(start); + return new LLRBBlackValueNode<>(key, getValue(key), null, null); + } else { + int half = size / 2; + int middle = start + half; + LLRBNode left = buildBalancedTree(start, half); + LLRBNode right = buildBalancedTree(middle + 1, half); + A key = this.keys.get(middle); + return new LLRBBlackValueNode<>(key, getValue(key), left, right); + } + } + + private void buildPennant(LLRBNode.Color color, int chunkSize, int start) { + LLRBNode treeRoot = buildBalancedTree(start + 1, chunkSize - 1); + A key = this.keys.get(start); + LLRBValueNode node; + if (color == LLRBNode.Color.RED) { + node = new LLRBRedValueNode<>(key, getValue(key), null, treeRoot); + } else { + node = new LLRBBlackValueNode<>(key, getValue(key), null, treeRoot); + } + if (root == null) { + root = node; + leaf = node; + } else { + leaf.setLeft(node); + leaf = node; + } + } + + public static RBTreeSortedMap buildFrom(List keys, Map values, + ImmutableSortedMap.Builder.KeyTranslator translator, + Comparator comparator) { + Builder builder = new Builder<>(keys, values, translator); + Collections.sort(keys, comparator); + Iterator iter = (new Base1_2(keys.size())).iterator(); + int index = keys.size(); + while (iter.hasNext()) { + BooleanChunk next = iter.next(); + index -= next.chunkSize; + if (next.isOne) { + builder.buildPennant(LLRBNode.Color.BLACK, next.chunkSize, index); + } else { + builder.buildPennant(LLRBNode.Color.BLACK, next.chunkSize, index); + index -= next.chunkSize; + builder.buildPennant(LLRBNode.Color.RED, next.chunkSize, index); + } + } + return new RBTreeSortedMap<>( + builder.root == null ? LLRBEmptyNode.getInstance() : builder.root, comparator); + } + } +} diff --git a/src/main/java/com/google/firebase/database/collection/StandardComparator.java b/src/main/java/com/google/firebase/database/collection/StandardComparator.java new file mode 100644 index 000000000..c570f79dd --- /dev/null +++ b/src/main/java/com/google/firebase/database/collection/StandardComparator.java @@ -0,0 +1,21 @@ +package com.google.firebase.database.collection; + +import java.util.Comparator; + +public class StandardComparator> implements Comparator { + + private static StandardComparator INSTANCE = new StandardComparator(); + + private StandardComparator() { + } + + @SuppressWarnings("unchecked") + public static > StandardComparator getComparator(Class clazz) { + return INSTANCE; + } + + @Override + public int compare(A o1, A o2) { + return o1.compareTo(o2); + } +} diff --git a/src/main/java/com/google/firebase/database/connection/CompoundHash.java b/src/main/java/com/google/firebase/database/connection/CompoundHash.java new file mode 100644 index 000000000..b5272013f --- /dev/null +++ b/src/main/java/com/google/firebase/database/connection/CompoundHash.java @@ -0,0 +1,27 @@ +package com.google.firebase.database.connection; + +import java.util.Collections; +import java.util.List; + +public class CompoundHash { + + private final List> posts; + private final List hashes; + + public CompoundHash(List> posts, List hashes) { + if (posts.size() != hashes.size() - 1) { + throw new IllegalArgumentException("Number of posts need to be n-1 for n hashes in " + + "CompoundHash"); + } + this.posts = posts; + this.hashes = hashes; + } + + public List> getPosts() { + return Collections.unmodifiableList(this.posts); + } + + public List getHashes() { + return Collections.unmodifiableList(this.hashes); + } +} diff --git a/src/main/java/com/google/firebase/database/connection/Connection.java b/src/main/java/com/google/firebase/database/connection/Connection.java new file mode 100644 index 000000000..a75685699 --- /dev/null +++ b/src/main/java/com/google/firebase/database/connection/Connection.java @@ -0,0 +1,253 @@ +package com.google.firebase.database.connection; + +import com.google.firebase.database.logging.LogWrapper; +import java.util.HashMap; +import java.util.Map; + +class Connection implements WebsocketConnection.Delegate { + + public enum DisconnectReason { + SERVER_RESET, + OTHER + } + + public interface Delegate { + + void onCacheHost(String host); + + void onReady(long timestamp, String sessionId); + + void onDataMessage(Map message); + + void onDisconnect(DisconnectReason reason); + + void onKill(String reason); + } + + private static long connectionIds = 0; + + private enum State {REALTIME_CONNECTING, REALTIME_CONNECTED, REALTIME_DISCONNECTED} + + private static final String REQUEST_TYPE = "t"; + private static final String REQUEST_TYPE_DATA = "d"; + private static final String REQUEST_PAYLOAD = "d"; + + private static final String SERVER_ENVELOPE_TYPE = "t"; + private static final String SERVER_DATA_MESSAGE = "d"; + private static final String SERVER_CONTROL_MESSAGE = "c"; + private static final String SERVER_ENVELOPE_DATA = "d"; + + private static final String SERVER_CONTROL_MESSAGE_TYPE = "t"; + private static final String SERVER_CONTROL_MESSAGE_SHUTDOWN = "s"; + private static final String SERVER_CONTROL_MESSAGE_RESET = "r"; + private static final String SERVER_CONTROL_MESSAGE_HELLO = "h"; + private static final String SERVER_CONTROL_MESSAGE_DATA = "d"; + + private static final String SERVER_HELLO_TIMESTAMP = "ts"; + private static final String SERVER_HELLO_HOST = "h"; + private static final String SERVER_HELLO_SESSION_ID = "s"; + + private HostInfo hostInfo; + private WebsocketConnection conn; + private Delegate delegate; + private State state; + private final LogWrapper logger; + + public Connection(ConnectionContext context, HostInfo hostInfo, + String cachedHost, Delegate delegate, String optLastSessionId) { + long connId = connectionIds++; + this.hostInfo = hostInfo; + this.delegate = delegate; + this.logger = new LogWrapper(context.getLogger(), "Connection", "conn_" + connId); + this.state = State.REALTIME_CONNECTING; + this.conn = new WebsocketConnection(context, hostInfo, cachedHost, this, + optLastSessionId); + } + + public void open() { + if (logger.logsDebug()) { + logger.debug("Opening a connection"); + } + conn.open(); + } + + public void close(DisconnectReason reason) { + if (state != State.REALTIME_DISCONNECTED) { + if (logger.logsDebug()) { + logger.debug("closing realtime connection"); + } + state = State.REALTIME_DISCONNECTED; + + if (conn != null) { + conn.close(); + conn = null; + } + + delegate.onDisconnect(reason); + } + } + + public void close() { + close(DisconnectReason.OTHER); + } + + public void sendRequest(Map message, boolean isSensitive) { + // This came from the persistent connection. Wrap it in an envelope and send it + + Map request = new HashMap<>(); + request.put(REQUEST_TYPE, REQUEST_TYPE_DATA); + request.put(REQUEST_PAYLOAD, message); + + sendData(request, isSensitive); + } + + @Override + public void onMessage(Map message) { + try { + String messageType = (String) message.get(SERVER_ENVELOPE_TYPE); + if (messageType != null) { + if (messageType.equals(SERVER_DATA_MESSAGE)) { + @SuppressWarnings("unchecked") Map data = (Map) message + .get(SERVER_ENVELOPE_DATA); + onDataMessage(data); + } else if (messageType.equals(SERVER_CONTROL_MESSAGE)) { + @SuppressWarnings("unchecked") Map data = (Map) message + .get(SERVER_ENVELOPE_DATA); + onControlMessage(data); + } else { + if (logger.logsDebug()) { + logger.debug("Ignoring unknown server message type: " + messageType); + } + } + } else { + if (logger.logsDebug()) { + logger + .debug("Failed to parse server message: missing message type:" + message.toString()); + } + close(); + } + } catch (ClassCastException e) { + if (logger.logsDebug()) { + logger.debug("Failed to parse server message: " + e.toString()); + } + close(); + } + } + + @Override + public void onDisconnect(boolean wasEverConnected) { + conn = null; + if (!wasEverConnected && state == State.REALTIME_CONNECTING) { + if (logger.logsDebug()) { + logger.debug("Realtime connection failed"); + } + } else { + if (logger.logsDebug()) { + logger.debug("Realtime connection lost"); + } + } + + close(); + } + + private void onDataMessage(Map data) { + if (logger.logsDebug()) { + logger.debug("received data message: " + data.toString()); + } + // We don't do anything with data messages, just kick them up a level + delegate.onDataMessage(data); + } + + private void onControlMessage(Map data) { + if (logger.logsDebug()) { + logger.debug("Got control message: " + data.toString()); + } + try { + String messageType = (String) data.get(SERVER_CONTROL_MESSAGE_TYPE); + if (messageType != null) { + if (messageType.equals(SERVER_CONTROL_MESSAGE_SHUTDOWN)) { + String reason = (String) data.get(SERVER_CONTROL_MESSAGE_DATA); + onConnectionShutdown(reason); + } else if (messageType.equals(SERVER_CONTROL_MESSAGE_RESET)) { + String host = (String) data.get(SERVER_CONTROL_MESSAGE_DATA); + onReset(host); + } else if (messageType.equals(SERVER_CONTROL_MESSAGE_HELLO)) { + @SuppressWarnings("unchecked") Map handshakeData = + (Map) data.get(SERVER_CONTROL_MESSAGE_DATA); + onHandshake(handshakeData); + } else { + if (logger.logsDebug()) { + logger.debug("Ignoring unknown control message: " + messageType); + } + } + } else { + if (logger.logsDebug()) { + logger.debug("Got invalid control message: " + data.toString()); + } + close(); + } + } catch (ClassCastException e) { + if (logger.logsDebug()) { + logger.debug("Failed to parse control message: " + e.toString()); + } + close(); + } + } + + private void onConnectionShutdown(String reason) { + if (logger.logsDebug()) { + logger.debug("Connection shutdown command received. Shutting down..."); + } + delegate.onKill(reason); + close(); + } + + private void onHandshake(Map handshake) { + long timestamp = (Long) handshake.get(SERVER_HELLO_TIMESTAMP); + String host = (String) handshake.get(SERVER_HELLO_HOST); + delegate.onCacheHost(host); + String sessionId = (String) handshake.get(SERVER_HELLO_SESSION_ID); + + if (state == State.REALTIME_CONNECTING) { + conn.start(); + onConnectionReady(timestamp, sessionId); + } + } + + private void onConnectionReady(long timestamp, String sessionId) { + if (logger.logsDebug()) { + logger.debug("realtime connection established"); + } + state = State.REALTIME_CONNECTED; + delegate.onReady(timestamp, sessionId); + } + + private void onReset(String host) { + if (logger.logsDebug()) { + logger.debug("Got a reset; killing connection to " + this.hostInfo.getHost() + + "; Updating internalHost to " + host); + } + delegate.onCacheHost(host); + + // Explicitly close the connection with SERVER_RESET so calling code knows to reconnect immediately. + close(DisconnectReason.SERVER_RESET); + } + + private void sendData(Map data, boolean isSensitive) { + if (state != State.REALTIME_CONNECTED) { + logger.debug("Tried to send on an unconnected connection"); + } else { + if (isSensitive) { + logger.debug("Sending data (contents hidden)"); + } else { + logger.debug("Sending data: %s", data); + } + conn.send(data); + } + } + + // For testing + public void injectConnectionFailure() { + this.close(); + } +} diff --git a/src/main/java/com/google/firebase/database/connection/ConnectionAuthTokenProvider.java b/src/main/java/com/google/firebase/database/connection/ConnectionAuthTokenProvider.java new file mode 100644 index 000000000..fcba328b5 --- /dev/null +++ b/src/main/java/com/google/firebase/database/connection/ConnectionAuthTokenProvider.java @@ -0,0 +1,29 @@ +package com.google.firebase.database.connection; + +public interface ConnectionAuthTokenProvider { + + interface GetTokenCallback { + + /** + * Called if the getToken operation completed successfully. Token may be null + * if there is no auth state currently. + */ + void onSuccess(String token); + + /** + * Called if the getToken operation fails. + * + * TODO: Figure out sane way to plumb errors through. + */ + void onError(String error); + } + + /** + * Gets the token that should currently be used for authenticated requests. + * + * @param forceRefresh Pass true to get a new, up-to-date token rather than a (potentially + * expired) cached token. + * @param callback Callback to be notified after operation completes. + */ + void getToken(boolean forceRefresh, GetTokenCallback callback); +} diff --git a/src/main/java/com/google/firebase/database/connection/ConnectionContext.java b/src/main/java/com/google/firebase/database/connection/ConnectionContext.java new file mode 100644 index 000000000..76dff4497 --- /dev/null +++ b/src/main/java/com/google/firebase/database/connection/ConnectionContext.java @@ -0,0 +1,52 @@ +package com.google.firebase.database.connection; + +import com.google.firebase.database.logging.Logger; +import java.util.concurrent.ScheduledExecutorService; + +public class ConnectionContext { + + private final ScheduledExecutorService executorService; + private final ConnectionAuthTokenProvider authTokenProvider; + private final Logger logger; + private final boolean persistenceEnabled; + private final String clientSdkVersion; + private final String userAgent; + + public ConnectionContext(Logger logger, + ConnectionAuthTokenProvider authTokenProvider, + ScheduledExecutorService executorService, + boolean persistenceEnabled, + String clientSdkVersion, + String userAgent) { + this.logger = logger; + this.authTokenProvider = authTokenProvider; + this.executorService = executorService; + this.persistenceEnabled = persistenceEnabled; + this.clientSdkVersion = clientSdkVersion; + this.userAgent = userAgent; + } + + public Logger getLogger() { + return this.logger; + } + + public ConnectionAuthTokenProvider getAuthTokenProvider() { + return this.authTokenProvider; + } + + public ScheduledExecutorService getExecutorService() { + return this.executorService; + } + + public boolean isPersistenceEnabled() { + return this.persistenceEnabled; + } + + public String getClientSdkVersion() { + return this.clientSdkVersion; + } + + public String getUserAgent() { + return this.userAgent; + } +} diff --git a/src/main/java/com/google/firebase/database/connection/ConnectionUtils.java b/src/main/java/com/google/firebase/database/connection/ConnectionUtils.java new file mode 100644 index 000000000..bbe4fe05a --- /dev/null +++ b/src/main/java/com/google/firebase/database/connection/ConnectionUtils.java @@ -0,0 +1,57 @@ +package com.google.firebase.database.connection; + +import java.util.ArrayList; +import java.util.List; + +public class ConnectionUtils { + + public static List stringToPath(String string) { + List path = new ArrayList<>(); + // OMG, why does Java not have filter ?!? !121111!~ + String[] segments = string.split("/"); + for (int i = 0; i < segments.length; i++) { + if (!segments[i].isEmpty()) { + path.add(segments[i]); + } + } + return path; + } + + public static String pathToString(List segments) { + if (segments.isEmpty()) { + return "/"; + } else { + StringBuilder path = new StringBuilder(); + boolean first = true; + for (String segment : segments) { + if (!first) { + path.append("/"); + } + first = false; + path.append(segment); + } + return path.toString(); + } + } + + public static Long longFromObject(Object o) { + if (o instanceof Integer) { + return Long.valueOf((Integer) o); + } else if (o instanceof Long) { + return (Long) o; + } else { + return null; + } + } + + // TODO(dimond): Merge these with Utils from firebase-database + public static void hardAssert(boolean condition) { + hardAssert(condition, ""); + } + + public static void hardAssert(boolean condition, String message, Object... args) { + if (!condition) { + throw new AssertionError("hardAssert failed: " + String.format(message, args)); + } + } +} diff --git a/src/main/java/com/google/firebase/database/connection/Constants.java b/src/main/java/com/google/firebase/database/connection/Constants.java new file mode 100644 index 000000000..a2838ebc0 --- /dev/null +++ b/src/main/java/com/google/firebase/database/connection/Constants.java @@ -0,0 +1,8 @@ +package com.google.firebase.database.connection; + +class Constants { + + public static final String DOT_INFO_SERVERTIME_OFFSET = "serverTimeOffset"; + + public static final String WIRE_PROTOCOL_VERSION = "5"; +} diff --git a/src/main/java/com/google/firebase/database/connection/HostInfo.java b/src/main/java/com/google/firebase/database/connection/HostInfo.java new file mode 100644 index 000000000..40eb67919 --- /dev/null +++ b/src/main/java/com/google/firebase/database/connection/HostInfo.java @@ -0,0 +1,47 @@ +package com.google.firebase.database.connection; + +import java.net.URI; + +public class HostInfo { + + private static final String VERSION_PARAM = "v"; + private static final String LAST_SESSION_ID_PARAM = "ls"; + + private final String host; + private final String namespace; + private final boolean secure; + + public HostInfo(String host, String namespace, boolean secure) { + this.host = host; + this.namespace = namespace; + this.secure = secure; + } + + @Override + public String toString() { + return "http" + (secure ? "s" : "") + "://" + host; + } + + public static URI getConnectionUrl(String host, boolean secure, String namespace, + String optLastSessionId) { + String scheme = secure ? "wss" : "ws"; + String url = scheme + "://" + host + "/.ws?ns=" + namespace + "&" + + VERSION_PARAM + "=" + Constants.WIRE_PROTOCOL_VERSION; + if (optLastSessionId != null) { + url += "&" + LAST_SESSION_ID_PARAM + "=" + optLastSessionId; + } + return URI.create(url); + } + + public String getHost() { + return this.host; + } + + public String getNamespace() { + return this.namespace; + } + + public boolean isSecure() { + return secure; + } +} diff --git a/src/main/java/com/google/firebase/database/connection/ListenHashProvider.java b/src/main/java/com/google/firebase/database/connection/ListenHashProvider.java new file mode 100644 index 000000000..10b583633 --- /dev/null +++ b/src/main/java/com/google/firebase/database/connection/ListenHashProvider.java @@ -0,0 +1,10 @@ +package com.google.firebase.database.connection; + +public interface ListenHashProvider { + + String getSimpleHash(); + + boolean shouldIncludeCompoundHash(); + + CompoundHash getCompoundHash(); +} diff --git a/src/main/java/com/google/firebase/database/connection/PersistentConnection.java b/src/main/java/com/google/firebase/database/connection/PersistentConnection.java new file mode 100644 index 000000000..eb00830ca --- /dev/null +++ b/src/main/java/com/google/firebase/database/connection/PersistentConnection.java @@ -0,0 +1,71 @@ +package com.google.firebase.database.connection; + +import java.util.List; +import java.util.Map; + +public interface PersistentConnection { + + interface Delegate { + + void onDataUpdate(List path, Object message, boolean isMerge, Long optTag); + + void onRangeMergeUpdate(List path, List merges, Long optTag); + + void onConnect(); + + void onDisconnect(); + + void onAuthStatus(boolean authOk); + + void onServerInfoUpdate(Map updates); + } + + // Lifecycle + + void initialize(); + + void shutdown(); + + // Auth + + void refreshAuthToken(); + + void refreshAuthToken(String token); + + // Listens + + void listen(List path, Map queryParams, + ListenHashProvider currentHashFn, Long tag, + RequestResultCallback onComplete); + + void unlisten(List path, Map queryParams); + + // Writes + + void purgeOutstandingWrites(); + + void put(List path, Object data, RequestResultCallback onComplete); + + void compareAndPut(List path, Object data, String hash, + RequestResultCallback onComplete); + + void merge(List path, Map data, RequestResultCallback onComplete); + + // Disconnects + + void onDisconnectPut(List path, Object data, RequestResultCallback onComplete); + + void onDisconnectMerge(List path, Map updates, + RequestResultCallback onComplete); + + void onDisconnectCancel(List path, RequestResultCallback onComplete); + + // Connection management + + void interrupt(String reason); + + void resume(String reason); + + boolean isInterrupted(String reason); + +} diff --git a/src/main/java/com/google/firebase/database/connection/PersistentConnectionImpl.java b/src/main/java/com/google/firebase/database/connection/PersistentConnectionImpl.java new file mode 100644 index 000000000..fe9dd18bc --- /dev/null +++ b/src/main/java/com/google/firebase/database/connection/PersistentConnectionImpl.java @@ -0,0 +1,1293 @@ +package com.google.firebase.database.connection; + +import static com.google.firebase.database.connection.ConnectionUtils.hardAssert; + +import com.google.firebase.database.connection.util.RetryHelper; +import com.google.firebase.database.logging.LogWrapper; +import com.google.firebase.database.util.AndroidSupport; +import com.google.firebase.database.util.GAuthToken; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; + +public class PersistentConnectionImpl implements Connection.Delegate, PersistentConnection { + + private interface ConnectionRequestCallback { + + void onResponse(Map response); + } + + private static class ListenQuerySpec { + + private final List path; + private final Map queryParams; + + public ListenQuerySpec(List path, Map queryParams) { + this.path = path; + this.queryParams = queryParams; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof ListenQuerySpec)) { + return false; + } + + ListenQuerySpec that = (ListenQuerySpec) o; + + if (!path.equals(that.path)) { + return false; + } + return queryParams.equals(that.queryParams); + } + + @Override + public int hashCode() { + int result = path.hashCode(); + result = 31 * result + queryParams.hashCode(); + return result; + } + + @Override + public String toString() { + return ConnectionUtils.pathToString(this.path) + " (params: " + queryParams + ")"; + } + } + + private static class OutstandingListen { + + private final RequestResultCallback resultCallback; + private final ListenQuerySpec query; + private final ListenHashProvider hashFunction; + private final Long tag; + + private OutstandingListen( + RequestResultCallback callback, + ListenQuerySpec query, + Long tag, + ListenHashProvider hashFunction) { + this.resultCallback = callback; + this.query = query; + this.hashFunction = hashFunction; + this.tag = tag; + } + + public ListenQuerySpec getQuery() { + return query; + } + + public Long getTag() { + return this.tag; + } + + public ListenHashProvider getHashFunction() { + return this.hashFunction; + } + + @Override + public String toString() { + return query.toString() + " (Tag: " + this.tag + ")"; + } + } + + private static class OutstandingPut { + + private String action; + private Map request; + private RequestResultCallback onComplete; + private boolean sent; + + private OutstandingPut( + String action, Map request, RequestResultCallback onComplete) { + this.action = action; + this.request = request; + this.onComplete = onComplete; + } + + public String getAction() { + return action; + } + + public Map getRequest() { + return request; + } + + public RequestResultCallback getOnComplete() { + return onComplete; + } + + public void markSent() { + this.sent = true; + } + + public boolean wasSent() { + return this.sent; + } + } + + private static class OutstandingDisconnect { + + private final String action; + private final List path; + private final Object data; + private final RequestResultCallback onComplete; + + private OutstandingDisconnect( + String action, List path, Object data, RequestResultCallback onComplete) { + this.action = action; + this.path = path; + this.data = data; + this.onComplete = onComplete; + } + + public String getAction() { + return action; + } + + public List getPath() { + return path; + } + + public Object getData() { + return data; + } + + public RequestResultCallback getOnComplete() { + return onComplete; + } + } + + private enum ConnectionState { + Disconnected, + GettingToken, + Connecting, + Authenticating, + Connected + } + + private static final String REQUEST_ERROR = "error"; + private static final String REQUEST_QUERIES = "q"; + private static final String REQUEST_TAG = "t"; + private static final String REQUEST_STATUS = "s"; + private static final String REQUEST_PATH = "p"; + private static final String REQUEST_NUMBER = "r"; + private static final String REQUEST_PAYLOAD = "b"; + private static final String REQUEST_COUNTERS = "c"; + private static final String REQUEST_DATA_PAYLOAD = "d"; + private static final String REQUEST_DATA_HASH = "h"; + private static final String REQUEST_COMPOUND_HASH = "ch"; + private static final String REQUEST_COMPOUND_HASH_PATHS = "ps"; + private static final String REQUEST_COMPOUND_HASH_HASHES = "hs"; + private static final String REQUEST_CREDENTIAL = "cred"; + private static final String REQUEST_AUTHVAR = "authvar"; + private static final String REQUEST_ACTION = "a"; + private static final String REQUEST_ACTION_STATS = "s"; + private static final String REQUEST_ACTION_QUERY = "q"; + private static final String REQUEST_ACTION_PUT = "p"; + private static final String REQUEST_ACTION_MERGE = "m"; + private static final String REQUEST_ACTION_QUERY_UNLISTEN = "n"; + private static final String REQUEST_ACTION_ONDISCONNECT_PUT = "o"; + private static final String REQUEST_ACTION_ONDISCONNECT_MERGE = "om"; + private static final String REQUEST_ACTION_ONDISCONNECT_CANCEL = "oc"; + private static final String REQUEST_ACTION_AUTH = "auth"; + private static final String REQUEST_ACTION_GAUTH = "gauth"; + private static final String REQUEST_ACTION_UNAUTH = "unauth"; + private static final String REQUEST_NOAUTH = "noauth"; + private static final String RESPONSE_FOR_REQUEST = "b"; + private static final String SERVER_ASYNC_ACTION = "a"; + private static final String SERVER_ASYNC_PAYLOAD = "b"; + private static final String SERVER_ASYNC_DATA_UPDATE = "d"; + private static final String SERVER_ASYNC_DATA_MERGE = "m"; + private static final String SERVER_ASYNC_DATA_RANGE_MERGE = "rm"; + private static final String SERVER_ASYNC_AUTH_REVOKED = "ac"; + private static final String SERVER_ASYNC_LISTEN_CANCELLED = "c"; + private static final String SERVER_ASYNC_SECURITY_DEBUG = "sd"; + private static final String SERVER_DATA_UPDATE_PATH = "p"; + private static final String SERVER_DATA_UPDATE_BODY = "d"; + private static final String SERVER_DATA_START_PATH = "s"; + private static final String SERVER_DATA_END_PATH = "e"; + private static final String SERVER_DATA_RANGE_MERGE = "m"; + private static final String SERVER_DATA_TAG = "t"; + private static final String SERVER_DATA_WARNINGS = "w"; + private static final String SERVER_RESPONSE_DATA = "d"; + + /** + * Delay after which a established connection is considered successful + */ + private static final long SUCCESSFUL_CONNECTION_ESTABLISHED_DELAY = 30 * 1000; + + private static final long IDLE_TIMEOUT = 60 * 1000; + + /** + * If auth fails repeatedly, we'll assume something is wrong and log a warning / back off. + */ + private static final long INVALID_AUTH_TOKEN_THRESHOLD = 3; + + private static final String SERVER_KILL_INTERRUPT_REASON = "server_kill"; + private static final String IDLE_INTERRUPT_REASON = "connection_idle"; + private static final String TOKEN_REFRESH_INTERRUPT_REASON = "token_refresh"; + + private static long connectionIds = 0; + + private final Delegate delegate; + private final HostInfo hostInfo; + private String cachedHost; + private HashSet interruptReasons = new HashSet<>(); + private boolean firstConnection = true; + private long lastConnectionEstablishedTime; + private Connection realtime; + private ConnectionState connectionState = ConnectionState.Disconnected; + private long writeCounter = 0; + private long requestCounter = 0; + private Map requestCBHash; + + private List onDisconnectRequestQueue; + private Map outstandingPuts; + + private Map listens; + private String authToken; + private boolean forceAuthTokenRefresh; + private final ConnectionContext context; + private final ConnectionAuthTokenProvider authTokenProvider; + private final ScheduledExecutorService executorService; + private final LogWrapper logger; + private final RetryHelper retryHelper; + private String lastSessionId; + /** + * Counter to check whether the callback is for the last getToken call + */ + private long currentGetTokenAttempt = 0; + + private int invalidAuthTokenCount = 0; + + private ScheduledFuture inactivityTimer = null; + private long lastWriteTimestamp; + private boolean hasOnDisconnects; + + public PersistentConnectionImpl( + ConnectionContext context, HostInfo info, final Delegate delegate) { + this.delegate = delegate; + this.context = context; + this.executorService = context.getExecutorService(); + this.authTokenProvider = context.getAuthTokenProvider(); + this.hostInfo = info; + this.listens = new HashMap<>(); + this.requestCBHash = new HashMap<>(); + this.outstandingPuts = new HashMap<>(); + this.onDisconnectRequestQueue = new ArrayList<>(); + this.retryHelper = + new RetryHelper.Builder(this.executorService, context.getLogger(), "ConnectionRetryHelper") + .withMinDelayAfterFailure(1000) + .withRetryExponent(1.3) + .withMaxDelay(30 * 1000) + .withJitterFactor(0.7) + .build(); + + long connId = connectionIds++; + this.logger = new LogWrapper(context.getLogger(), "PersistentConnection", "pc_" + connId); + this.lastSessionId = null; + doIdleCheck(); + } + + // Connection.Delegate methods + @Override + public void onReady(long timestamp, String sessionId) { + if (logger.logsDebug()) { + logger.debug("onReady"); + } + lastConnectionEstablishedTime = System.currentTimeMillis(); + handleTimestamp(timestamp); + + if (this.firstConnection) { + sendConnectStats(); + } + + restoreAuth(); + this.firstConnection = false; + this.lastSessionId = sessionId; + delegate.onConnect(); + } + + @Override + public void onCacheHost(String host) { + this.cachedHost = host; + } + + @Override + public void listen( + List path, + Map queryParams, + ListenHashProvider currentHashFn, + Long tag, + RequestResultCallback listener) { + ListenQuerySpec query = new ListenQuerySpec(path, queryParams); + if (logger.logsDebug()) { + logger.debug("Listening on " + query); + } + // TODO(dimond): Fix this somehow? + //hardAssert(query.isDefault() || !query.loadsAllData(), "listen() called for non-default but " + // + "complete query"); + hardAssert(!listens.containsKey(query), "listen() called twice for same QuerySpec."); + if (logger.logsDebug()) { + logger.debug("Adding listen query: " + query); + } + OutstandingListen outstandingListen = + new OutstandingListen(listener, query, tag, currentHashFn); + listens.put(query, outstandingListen); + if (connected()) { + sendListen(outstandingListen); + } + doIdleCheck(); + } + + @Override + public void initialize() { + this.tryScheduleReconnect(); + } + + @Override + public void shutdown() { + this.interrupt("shutdown"); + } + + @Override + public void put(List path, Object data, RequestResultCallback onComplete) { + putInternal(REQUEST_ACTION_PUT, path, data, /*hash=*/ null, onComplete); + } + + @Override + public void compareAndPut( + List path, Object data, String hash, RequestResultCallback onComplete) { + putInternal(REQUEST_ACTION_PUT, path, data, hash, onComplete); + } + + @Override + public void merge(List path, Map data, RequestResultCallback onComplete) { + putInternal(REQUEST_ACTION_MERGE, path, data, /*hash=*/ null, onComplete); + } + + @Override + public void purgeOutstandingWrites() { + for (OutstandingPut put : this.outstandingPuts.values()) { + if (put.onComplete != null) { + put.onComplete.onRequestResult("write_canceled", null); + } + } + for (OutstandingDisconnect onDisconnect : this.onDisconnectRequestQueue) { + if (onDisconnect.onComplete != null) { + onDisconnect.onComplete.onRequestResult("write_canceled", null); + } + } + this.outstandingPuts.clear(); + this.onDisconnectRequestQueue.clear(); + // Only if we are not connected can we reliably determine that we don't have onDisconnects + // (outstanding) anymore. Otherwise we leave the flag untouched. + if (!connected()) { + this.hasOnDisconnects = false; + } + doIdleCheck(); + } + + @Override + public void onDataMessage(Map message) { + if (message.containsKey(REQUEST_NUMBER)) { + // this is a response to a request we sent + // TODO: this is a hack. Make the json parser give us a Long + long rn = (Integer) message.get(REQUEST_NUMBER); + ConnectionRequestCallback responseListener = requestCBHash.remove(rn); + if (responseListener != null) { + // jackson gives up Map for json objects + @SuppressWarnings("unchecked") + Map response = (Map) message.get(RESPONSE_FOR_REQUEST); + responseListener.onResponse(response); + } + } else if (message.containsKey(REQUEST_ERROR)) { + // TODO: log the error? probably shouldn't throw here... + } else if (message.containsKey(SERVER_ASYNC_ACTION)) { + String action = (String) message.get(SERVER_ASYNC_ACTION); + // jackson gives up Map for json objects + @SuppressWarnings("unchecked") + Map body = (Map) message.get(SERVER_ASYNC_PAYLOAD); + onDataPush(action, body); + } else { + if (logger.logsDebug()) { + logger.debug("Ignoring unknown message: " + message); + } + } + } + + @Override + public void onDisconnect(Connection.DisconnectReason reason) { + if (logger.logsDebug()) { + logger.debug("Got on disconnect due to " + reason.name()); + } + this.connectionState = ConnectionState.Disconnected; + this.realtime = null; + this.hasOnDisconnects = false; + requestCBHash.clear(); + cancelSentTransactions(); + if (shouldReconnect()) { + long timeSinceLastConnectSucceeded = + System.currentTimeMillis() - lastConnectionEstablishedTime; + boolean lastConnectionWasSuccessful; + if (lastConnectionEstablishedTime > 0) { + lastConnectionWasSuccessful = + timeSinceLastConnectSucceeded > SUCCESSFUL_CONNECTION_ESTABLISHED_DELAY; + } else { + lastConnectionWasSuccessful = false; + } + if (reason == Connection.DisconnectReason.SERVER_RESET || lastConnectionWasSuccessful) { + retryHelper.signalSuccess(); + } + tryScheduleReconnect(); + } + lastConnectionEstablishedTime = 0; + delegate.onDisconnect(); + } + + @Override + public void onKill(String reason) { + if (logger.logsDebug()) { + logger.debug( + "Firebase Database connection was forcefully killed by the server. Will not attempt " + + "reconnect. Reason: " + + reason); + } + interrupt(SERVER_KILL_INTERRUPT_REASON); + } + + @Override + public void unlisten(List path, Map queryParams) { + ListenQuerySpec query = new ListenQuerySpec(path, queryParams); + if (logger.logsDebug()) { + logger.debug("unlistening on " + query); + } + + // TODO(dimond): fix this by understanding query params? + //Utilities.hardAssert(query.isDefault() || !query.loadsAllData(), + // "unlisten() called for non-default but complete query"); + OutstandingListen listen = removeListen(query); + if (listen != null && connected()) { + sendUnlisten(listen); + } + doIdleCheck(); + } + + private boolean connected() { + return connectionState == ConnectionState.Authenticating + || connectionState == ConnectionState.Connected; + } + + @Override + public void onDisconnectPut(List path, Object data, RequestResultCallback onComplete) { + this.hasOnDisconnects = true; + if (canSendWrites()) { + sendOnDisconnect(REQUEST_ACTION_ONDISCONNECT_PUT, path, data, onComplete); + } else { + onDisconnectRequestQueue.add( + new OutstandingDisconnect(REQUEST_ACTION_ONDISCONNECT_PUT, path, data, onComplete)); + } + doIdleCheck(); + } + + private boolean canSendWrites() { + return connectionState == ConnectionState.Connected; + } + + @Override + public void onDisconnectMerge( + List path, Map updates, final RequestResultCallback onComplete) { + this.hasOnDisconnects = true; + if (canSendWrites()) { + sendOnDisconnect(REQUEST_ACTION_ONDISCONNECT_MERGE, path, updates, onComplete); + } else { + onDisconnectRequestQueue.add( + new OutstandingDisconnect(REQUEST_ACTION_ONDISCONNECT_MERGE, path, updates, onComplete)); + } + doIdleCheck(); + } + + @Override + public void onDisconnectCancel(List path, RequestResultCallback onComplete) { + // We do not mark hasOnDisconnects true here, because we only are removing disconnects. + // However, we can also not reliably determine whether we had onDisconnects, so we can't + // and do not reset the flag. + if (canSendWrites()) { + sendOnDisconnect(REQUEST_ACTION_ONDISCONNECT_CANCEL, path, null, onComplete); + } else { + onDisconnectRequestQueue.add( + new OutstandingDisconnect(REQUEST_ACTION_ONDISCONNECT_CANCEL, path, null, onComplete)); + } + doIdleCheck(); + } + + @Override + public void interrupt(String reason) { + if (logger.logsDebug()) { + logger.debug("Connection interrupted for: " + reason); + } + interruptReasons.add(reason); + + if (realtime != null) { + // Will call onDisconnect and set the connection state to Disconnected + realtime.close(); + realtime = null; + } else { + retryHelper.cancel(); + this.connectionState = ConnectionState.Disconnected; + } + // Reset timeouts + retryHelper.signalSuccess(); + } + + @Override + public void resume(String reason) { + if (logger.logsDebug()) { + logger.debug("Connection no longer interrupted for: " + reason); + } + + interruptReasons.remove(reason); + + if (shouldReconnect() && connectionState == ConnectionState.Disconnected) { + tryScheduleReconnect(); + } + } + + @Override + public boolean isInterrupted(String reason) { + return interruptReasons.contains(reason); + } + + boolean shouldReconnect() { + return interruptReasons.size() == 0; + } + + @Override + public void refreshAuthToken() { + // Old versions of the database client library didn't have synchronous access to the + // new token and call this instead of the overload that includes the new token. + + // After a refresh token any subsequent operations are expected to have the authentication + // status at the point of this call. To avoid race conditions with delays after getToken, + // we close the connection to make sure any writes/listens are queued until the connection + // is reauthed with the current token after reconnecting. Note that this will trigger + // onDisconnects which isn't ideal. + logger.debug("Auth token refresh requested"); + + // By using interrupt instead of closing the connection we make sure there are no race + // conditions with other fetch token attempts (interrupt/resume is expected to handle those + // correctly) + interrupt(TOKEN_REFRESH_INTERRUPT_REASON); + resume(TOKEN_REFRESH_INTERRUPT_REASON); + } + + @Override + public void refreshAuthToken(String token) { + logger.debug("Auth token refreshed."); + this.authToken = token; + if (connected()) { + if (token != null) { + upgradeAuth(); + } else { + sendUnauth(); + } + } + } + + private void tryScheduleReconnect() { + if (shouldReconnect()) { + hardAssert( + this.connectionState == ConnectionState.Disconnected, + "Not in disconnected state: %s", + this.connectionState); + final boolean forceRefresh = this.forceAuthTokenRefresh; + logger.debug("Scheduling connection attempt"); + this.forceAuthTokenRefresh = false; + retryHelper.retry( + new Runnable() { + @Override + public void run() { + logger.debug("Trying to fetch auth token"); + hardAssert( + connectionState == ConnectionState.Disconnected, + "Not in disconnected state: %s", + connectionState); + connectionState = ConnectionState.GettingToken; + currentGetTokenAttempt++; + final long thisGetTokenAttempt = currentGetTokenAttempt; + authTokenProvider.getToken( + forceRefresh, + new ConnectionAuthTokenProvider.GetTokenCallback() { + @Override + public void onSuccess(String token) { + if (thisGetTokenAttempt == currentGetTokenAttempt) { + // Someone could have interrupted us while fetching the token, + // marking the connection as Disconnected + if (connectionState == ConnectionState.GettingToken) { + logger.debug("Successfully fetched token, opening connection"); + openNetworkConnection(token); + } else { + hardAssert( + connectionState == ConnectionState.Disconnected, + "Expected connection state disconnected, but was %s", + connectionState); + logger.debug( + "Not opening connection after token refresh, " + + "because connection was set to disconnected"); + } + } else { + logger.debug( + "Ignoring getToken result, because this was not the " + + "latest attempt."); + } + } + + @Override + public void onError(String error) { + if (thisGetTokenAttempt == currentGetTokenAttempt) { + connectionState = ConnectionState.Disconnected; + logger.debug("Error fetching token: " + error); + tryScheduleReconnect(); + } else { + logger.debug( + "Ignoring getToken error, because this was not the " + + "latest attempt."); + } + } + }); + } + }); + } + } + + public void openNetworkConnection(String token) { + hardAssert( + this.connectionState == ConnectionState.GettingToken, + "Trying to open network connection while in the wrong state: %s", + this.connectionState); + // User might have logged out. Positive auth status is handled after authenticating with + // the server + if (token == null) { + this.delegate.onAuthStatus(false); + } + this.authToken = token; + this.connectionState = ConnectionState.Connecting; + realtime = + new Connection(this.context, this.hostInfo, this.cachedHost, this, this.lastSessionId); + realtime.open(); + } + + private void sendOnDisconnect( + String action, List path, Object data, final RequestResultCallback onComplete) { + Map request = new HashMap<>(); + request.put(REQUEST_PATH, ConnectionUtils.pathToString(path)); + request.put(REQUEST_DATA_PAYLOAD, data); + // + //if (logger.logsDebug()) logger.debug("onDisconnect " + action + " " + request); + sendAction( + action, + request, + new ConnectionRequestCallback() { + @Override + public void onResponse(Map response) { + String status = (String) response.get(REQUEST_STATUS); + String errorMessage = null; + String errorCode = null; + if (!status.equals("ok")) { + errorCode = status; + errorMessage = (String) response.get(SERVER_DATA_UPDATE_BODY); + } + if (onComplete != null) { + onComplete.onRequestResult(errorCode, errorMessage); + } + } + }); + } + + private void cancelSentTransactions() { + List cancelledTransactionWrites = new ArrayList<>(); + + Iterator> iter = outstandingPuts.entrySet().iterator(); + while (iter.hasNext()) { + Map.Entry entry = iter.next(); + OutstandingPut put = entry.getValue(); + if (put.getRequest().containsKey(REQUEST_DATA_HASH) && put.wasSent()) { + cancelledTransactionWrites.add(put); + iter.remove(); + } + } + + for (OutstandingPut put : cancelledTransactionWrites) { + // onRequestResult() may invoke rerunTransactions() and enqueue new writes. We defer calling + // it until we've finished enumerating all existing writes. + put.getOnComplete().onRequestResult("disconnected", null); + } + } + + private void sendUnlisten(OutstandingListen listen) { + Map request = new HashMap<>(); + request.put(REQUEST_PATH, ConnectionUtils.pathToString(listen.query.path)); + + Long tag = listen.getTag(); + if (tag != null) { + request.put(REQUEST_QUERIES, listen.getQuery().queryParams); + request.put(REQUEST_TAG, tag); + } + + sendAction(REQUEST_ACTION_QUERY_UNLISTEN, request, null); + } + + private OutstandingListen removeListen(ListenQuerySpec query) { + if (logger.logsDebug()) { + logger.debug("removing query " + query); + } + if (!listens.containsKey(query)) { + if (logger.logsDebug()) { + logger.debug( + "Trying to remove listener for QuerySpec " + query + " but no listener exists."); + } + return null; + } else { + OutstandingListen oldListen = listens.get(query); + listens.remove(query); + doIdleCheck(); + return oldListen; + } + } + + private Collection removeListens(List path) { + if (logger.logsDebug()) { + logger.debug("removing all listens at path " + path); + } + List removedListens = new ArrayList<>(); + for (Map.Entry entry : listens.entrySet()) { + ListenQuerySpec query = entry.getKey(); + OutstandingListen listen = entry.getValue(); + if (query.path.equals(path)) { + removedListens.add(listen); + } + } + + for (OutstandingListen toRemove : removedListens) { + listens.remove(toRemove.getQuery()); + } + + doIdleCheck(); + + return removedListens; + } + + private void onDataPush(String action, Map body) { + if (logger.logsDebug()) { + logger.debug("handleServerMessage: " + action + " " + body); + } + if (action.equals(SERVER_ASYNC_DATA_UPDATE) || action.equals(SERVER_ASYNC_DATA_MERGE)) { + boolean isMerge = action.equals(SERVER_ASYNC_DATA_MERGE); + + String pathString = (String) body.get(SERVER_DATA_UPDATE_PATH); + Object payloadData = body.get(SERVER_DATA_UPDATE_BODY); + Long tagNumber = ConnectionUtils.longFromObject(body.get(SERVER_DATA_TAG)); + // ignore empty merges + if (isMerge && (payloadData instanceof Map) && ((Map) payloadData).size() == 0) { + if (logger.logsDebug()) { + logger.debug("ignoring empty merge for path " + pathString); + } + } else { + List path = ConnectionUtils.stringToPath(pathString); + delegate.onDataUpdate(path, payloadData, isMerge, tagNumber); + } + } else if (action.equals(SERVER_ASYNC_DATA_RANGE_MERGE)) { + String pathString = (String) body.get(SERVER_DATA_UPDATE_PATH); + List path = ConnectionUtils.stringToPath(pathString); + Object payloadData = body.get(SERVER_DATA_UPDATE_BODY); + Long tag = ConnectionUtils.longFromObject(body.get(SERVER_DATA_TAG)); + @SuppressWarnings("unchecked") + List> ranges = (List>) payloadData; + List rangeMerges = new ArrayList<>(); + for (Map range : ranges) { + String startString = (String) range.get(SERVER_DATA_START_PATH); + String endString = (String) range.get(SERVER_DATA_END_PATH); + List start = startString != null ? ConnectionUtils.stringToPath(startString) : null; + List end = endString != null ? ConnectionUtils.stringToPath(endString) : null; + Object update = range.get(SERVER_DATA_RANGE_MERGE); + rangeMerges.add(new RangeMerge(start, end, update)); + } + if (rangeMerges.isEmpty()) { + if (logger.logsDebug()) { + logger.debug("Ignoring empty range merge for path " + pathString); + } + } else { + this.delegate.onRangeMergeUpdate(path, rangeMerges, tag); + } + } else if (action.equals(SERVER_ASYNC_LISTEN_CANCELLED)) { + String pathString = (String) body.get(SERVER_DATA_UPDATE_PATH); + List path = ConnectionUtils.stringToPath(pathString); + onListenRevoked(path); + } else if (action.equals(SERVER_ASYNC_AUTH_REVOKED)) { + String status = (String) body.get(REQUEST_STATUS); + String reason = (String) body.get(SERVER_DATA_UPDATE_BODY); + onAuthRevoked(status, reason); + } else if (action.equals(SERVER_ASYNC_SECURITY_DEBUG)) { + onSecurityDebugPacket(body); + } else { + if (logger.logsDebug()) { + logger.debug("Unrecognized action from server: " + action); + } + } + } + + private void onListenRevoked(List path) { + // Remove the listen and manufacture a "permission denied" error for the failed listen + + Collection listens = removeListens(path); + // The listen may have already been removed locally. If so, skip it + if (listens != null) { + for (OutstandingListen listen : listens) { + listen.resultCallback.onRequestResult("permission_denied", null); + } + } + } + + private void onAuthRevoked(String errorCode, String errorMessage) { + // This might be for an earlier token than we just recently sent. But since we need to close + // the connection anyways, we can set it to null here and we will refresh the token later + // on reconnect. + logger.debug("Auth token revoked: " + errorCode + " (" + errorMessage + ")"); + this.authToken = null; + this.forceAuthTokenRefresh = true; + this.delegate.onAuthStatus(false); + // Close connection and reconnect + this.realtime.close(); + } + + private void onSecurityDebugPacket(Map message) { + // TODO: implement on iOS too + logger.info((String) message.get("msg")); + } + + private void upgradeAuth() { + sendAuthHelper(/*restoreStateAfterComplete=*/ false); + } + + private void sendAuthAndRestoreState() { + sendAuthHelper(/*restoreStateAfterComplete=*/ true); + } + + private void sendAuthHelper(final boolean restoreStateAfterComplete) { + hardAssert(connected(), "Must be connected to send auth, but was: %s", this.connectionState); + hardAssert(this.authToken != null, "Auth token must be set to authenticate!"); + + ConnectionRequestCallback onComplete = + new ConnectionRequestCallback() { + @Override + public void onResponse(Map response) { + connectionState = ConnectionState.Connected; + + String status = (String) response.get(REQUEST_STATUS); + if (status.equals("ok")) { + invalidAuthTokenCount = 0; + delegate.onAuthStatus(true); + if (restoreStateAfterComplete) { + restoreState(); + } + } else { + authToken = null; + forceAuthTokenRefresh = true; + delegate.onAuthStatus(false); + String reason = (String) response.get(SERVER_RESPONSE_DATA); + logger.debug("Authentication failed: " + status + " (" + reason + ")"); + realtime.close(); + + if (status.equals("invalid_token")) { + // We'll wait a couple times before logging the warning / increasing the + // retry period since oauth tokens will report as "invalid" if they're + // just expired. Plus there may be transient issues that resolve themselves. + invalidAuthTokenCount++; + if (invalidAuthTokenCount >= INVALID_AUTH_TOKEN_THRESHOLD) { + // Set a long reconnect delay because recovery is unlikely. + retryHelper.setMaxDelay(); + logger.warn( + "Provided authentication credentials are invalid. This " + + "usually indicates your FirebaseApp instance was not initialized " + + "correctly. Make sure your database URL is correct and that your " + + "service account is for the correct project and is authorized to " + + "access it."); + } + } + } + } + }; + + Map request = new HashMap<>(); + GAuthToken gAuthToken = GAuthToken.tryParseFromString(this.authToken); + if (gAuthToken != null) { + request.put(REQUEST_CREDENTIAL, gAuthToken.getToken()); + if (gAuthToken.getAuth() != null) { + if (!gAuthToken.getAuth().isEmpty()) { + request.put(REQUEST_AUTHVAR, gAuthToken.getAuth()); + } + } else { + request.put(REQUEST_NOAUTH, true); + } + sendSensitive(REQUEST_ACTION_GAUTH, /*isSensitive=*/ true, request, onComplete); + } else { + request.put(REQUEST_CREDENTIAL, authToken); + sendSensitive(REQUEST_ACTION_AUTH, /*isSensitive=*/ true, request, onComplete); + } + } + + private void sendUnauth() { + hardAssert(connected(), "Must be connected to send unauth."); + hardAssert(authToken == null, "Auth token must not be set."); + sendAction(REQUEST_ACTION_UNAUTH, Collections.emptyMap(), null); + } + + private void restoreAuth() { + if (logger.logsDebug()) { + logger.debug("calling restore state"); + } + + hardAssert( + this.connectionState == ConnectionState.Connecting, + "Wanted to restore auth, but was in wrong state: %s", + this.connectionState); + + if (authToken == null) { + if (logger.logsDebug()) { + logger.debug("Not restoring auth because token is null."); + } + this.connectionState = ConnectionState.Connected; + restoreState(); + } else { + if (logger.logsDebug()) { + logger.debug("Restoring auth."); + } + this.connectionState = ConnectionState.Authenticating; + sendAuthAndRestoreState(); + } + } + + private void restoreState() { + hardAssert( + this.connectionState == ConnectionState.Connected, + "Should be connected if we're restoring state, but we are: %s", + this.connectionState); + + // Restore listens + if (logger.logsDebug()) { + logger.debug("Restoring outstanding listens"); + } + for (OutstandingListen listen : listens.values()) { + if (logger.logsDebug()) { + logger.debug("Restoring listen " + listen.getQuery()); + } + sendListen(listen); + } + + if (logger.logsDebug()) { + logger.debug("Restoring writes."); + } + // Restore puts + ArrayList outstanding = new ArrayList<>(outstandingPuts.keySet()); + // Make sure puts are restored in order + Collections.sort(outstanding); + for (Long put : outstanding) { + sendPut(put); + } + + // Restore disconnect operations + for (OutstandingDisconnect disconnect : onDisconnectRequestQueue) { + sendOnDisconnect( + disconnect.getAction(), + disconnect.getPath(), + disconnect.getData(), + disconnect.getOnComplete()); + } + onDisconnectRequestQueue.clear(); + } + + private void handleTimestamp(long timestamp) { + if (logger.logsDebug()) { + logger.debug("handling timestamp"); + } + long timestampDelta = timestamp - System.currentTimeMillis(); + Map updates = new HashMap<>(); + updates.put(Constants.DOT_INFO_SERVERTIME_OFFSET, timestampDelta); + delegate.onServerInfoUpdate(updates); + } + + private Map getPutObject(List path, Object data, String hash) { + Map request = new HashMap<>(); + request.put(REQUEST_PATH, ConnectionUtils.pathToString(path)); + request.put(REQUEST_DATA_PAYLOAD, data); + if (hash != null) { + request.put(REQUEST_DATA_HASH, hash); + } + return request; + } + + private void putInternal( + String action, + List path, + Object data, + String hash, + RequestResultCallback onComplete) { + Map request = getPutObject(path, data, hash); + + // local to PersistentConnection + long writeId = this.writeCounter++; + + outstandingPuts.put(writeId, new OutstandingPut(action, request, onComplete)); + if (canSendWrites()) { + sendPut(writeId); + } + this.lastWriteTimestamp = System.currentTimeMillis(); + doIdleCheck(); + } + + private void sendPut(final long putId) { + assert canSendWrites() + : "sendPut called when we can't send writes (we're disconnected or writes are paused)."; + final OutstandingPut put = outstandingPuts.get(putId); + final RequestResultCallback onComplete = put.getOnComplete(); + final String action = put.getAction(); + + put.markSent(); + sendAction( + action, + put.getRequest(), + new ConnectionRequestCallback() { + @Override + public void onResponse(Map response) { + if (logger.logsDebug()) { + logger.debug(action + " response: " + response); + } + + OutstandingPut currentPut = outstandingPuts.get(putId); + if (currentPut == put) { + outstandingPuts.remove(putId); + + if (onComplete != null) { + String status = (String) response.get(REQUEST_STATUS); + if (status.equals("ok")) { + onComplete.onRequestResult(null, null); + } else { + String errorMessage = (String) response.get(SERVER_DATA_UPDATE_BODY); + onComplete.onRequestResult(status, errorMessage); + } + } + } else { + if (logger.logsDebug()) { + logger.debug( + "Ignoring on complete for put " + putId + " because it was removed already."); + } + } + doIdleCheck(); + } + }); + } + + private void sendListen(final OutstandingListen listen) { + Map request = new HashMap<>(); + request.put(REQUEST_PATH, ConnectionUtils.pathToString(listen.getQuery().path)); + Long tag = listen.getTag(); + // Only bother to send query if it's non-default + if (tag != null) { + request.put(REQUEST_QUERIES, listen.query.queryParams); + request.put(REQUEST_TAG, tag); + } + + ListenHashProvider hashFunction = listen.getHashFunction(); + request.put(REQUEST_DATA_HASH, hashFunction.getSimpleHash()); + + if (hashFunction.shouldIncludeCompoundHash()) { + CompoundHash compoundHash = hashFunction.getCompoundHash(); + + List posts = new ArrayList<>(); + for (List path : compoundHash.getPosts()) { + posts.add(ConnectionUtils.pathToString(path)); + } + Map hash = new HashMap<>(); + hash.put(REQUEST_COMPOUND_HASH_HASHES, compoundHash.getHashes()); + hash.put(REQUEST_COMPOUND_HASH_PATHS, posts); + request.put(REQUEST_COMPOUND_HASH, hash); + } + + sendAction( + REQUEST_ACTION_QUERY, + request, + new ConnectionRequestCallback() { + + @Override + public void onResponse(Map response) { + String status = (String) response.get(REQUEST_STATUS); + // log warnings in any case, even if listener was already removed + if (status.equals("ok")) { + @SuppressWarnings("unchecked") + Map serverBody = + (Map) response.get(SERVER_DATA_UPDATE_BODY); + if (serverBody.containsKey(SERVER_DATA_WARNINGS)) { + @SuppressWarnings("unchecked") + List warnings = (List) serverBody.get(SERVER_DATA_WARNINGS); + warnOnListenerWarnings(warnings, listen.query); + } + } + + OutstandingListen currentListen = listens.get(listen.getQuery()); + // only trigger actions if the listen hasn't been removed (and maybe readded) + if (currentListen == listen) { + if (!status.equals("ok")) { + removeListen(listen.getQuery()); + String errorMessage = (String) response.get(SERVER_DATA_UPDATE_BODY); + listen.resultCallback.onRequestResult(status, errorMessage); + } else { + listen.resultCallback.onRequestResult(null, null); + } + } + } + }); + } + + private void sendStats(final Map stats) { + if (!stats.isEmpty()) { + Map request = new HashMap<>(); + request.put(REQUEST_COUNTERS, stats); + sendAction( + REQUEST_ACTION_STATS, + request, + new ConnectionRequestCallback() { + @Override + public void onResponse(Map response) { + String status = (String) response.get(REQUEST_STATUS); + if (!status.equals("ok")) { + String errorMessage = (String) response.get(SERVER_DATA_UPDATE_BODY); + if (logger.logsDebug()) { + logger.debug( + "Failed to send stats: " + status + " (message: " + errorMessage + ")"); + } + } + } + }); + } else { + if (logger.logsDebug()) { + logger.debug("Not sending stats because stats are empty"); + } + } + } + + @SuppressWarnings("unchecked") + private void warnOnListenerWarnings(List warnings, ListenQuerySpec query) { + if (warnings.contains("no_index")) { + String indexSpec = "\".indexOn\": \"" + query.queryParams.get("i") + '\"'; + logger.warn( + "Using an unspecified index. Consider adding '" + + indexSpec + + "' at " + + ConnectionUtils.pathToString(query.path) + + " to your security and Firebase Database rules for better performance"); + } + } + + private void sendConnectStats() { + Map stats = new HashMap<>(); + if (AndroidSupport.isAndroid()) { + if (this.context.isPersistenceEnabled()) { + stats.put("persistence.android.enabled", 1); + } + stats.put("sdk.android." + context.getClientSdkVersion().replace('.', '-'), 1); + // TODO(dimond): Also send stats for connection version + } else { + assert !this.context.isPersistenceEnabled() + : "Stats for persistence on JVM missing (persistence not yet supported)"; + stats.put("sdk.admin_java." + context.getClientSdkVersion().replace('.', '-'), 1); + } + if (logger.logsDebug()) { + logger.debug("Sending first connection stats"); + } + sendStats(stats); + } + + private void sendAction( + String action, Map message, ConnectionRequestCallback onResponse) { + sendSensitive(action, /*isSensitive=*/ false, message, onResponse); + } + + private void sendSensitive( + String action, + boolean isSensitive, + Map message, + ConnectionRequestCallback onResponse) { + long rn = nextRequestNumber(); + Map request = new HashMap<>(); + request.put(REQUEST_NUMBER, rn); + request.put(REQUEST_ACTION, action); + request.put(REQUEST_PAYLOAD, message); + realtime.sendRequest(request, isSensitive); + requestCBHash.put(rn, onResponse); + } + + private long nextRequestNumber() { + return requestCounter++; + } + + private void doIdleCheck() { + if (isIdle()) { + if (this.inactivityTimer != null) { + this.inactivityTimer.cancel(false); + } + + this.inactivityTimer = + this.executorService.schedule( + new Runnable() { + @Override + public void run() { + inactivityTimer = null; + if (idleHasTimedOut()) { + interrupt(IDLE_INTERRUPT_REASON); + } else { + doIdleCheck(); + } + } + }, + IDLE_TIMEOUT, + TimeUnit.MILLISECONDS); + } else if (isInterrupted(IDLE_INTERRUPT_REASON)) { + hardAssert(!isIdle()); + this.resume(IDLE_INTERRUPT_REASON); + } + } + + /** + * @return Returns true if the connection is currently not being used (for listen, outstanding + * operations). + */ + private boolean isIdle() { + return this.listens.isEmpty() + && this.requestCBHash.isEmpty() + && !this.hasOnDisconnects + && this.outstandingPuts.isEmpty(); + } + + private boolean idleHasTimedOut() { + long now = System.currentTimeMillis(); + return isIdle() && now > (this.lastWriteTimestamp + IDLE_TIMEOUT); + } + + // For testing + public void injectConnectionFailure() { + if (this.realtime != null) { + this.realtime.injectConnectionFailure(); + } + } +} diff --git a/src/main/java/com/google/firebase/database/connection/RangeMerge.java b/src/main/java/com/google/firebase/database/connection/RangeMerge.java new file mode 100644 index 000000000..3b4299500 --- /dev/null +++ b/src/main/java/com/google/firebase/database/connection/RangeMerge.java @@ -0,0 +1,28 @@ +package com.google.firebase.database.connection; + +import java.util.List; + +public class RangeMerge { + + private final List optExclusiveStart; + private final List optInclusiveEnd; + private final Object snap; + + public RangeMerge(List optExclusiveStart, List optInclusiveEnd, Object snap) { + this.optExclusiveStart = optExclusiveStart; + this.optInclusiveEnd = optInclusiveEnd; + this.snap = snap; + } + + public List getOptExclusiveStart() { + return this.optExclusiveStart; + } + + public List getOptInclusiveEnd() { + return this.optInclusiveEnd; + } + + public Object getSnap() { + return this.snap; + } +} diff --git a/src/main/java/com/google/firebase/database/connection/RequestResultCallback.java b/src/main/java/com/google/firebase/database/connection/RequestResultCallback.java new file mode 100644 index 000000000..dd4734899 --- /dev/null +++ b/src/main/java/com/google/firebase/database/connection/RequestResultCallback.java @@ -0,0 +1,6 @@ +package com.google.firebase.database.connection; + +public interface RequestResultCallback { + + void onRequestResult(String optErrorCode, String optErrorMessage); +} diff --git a/src/main/java/com/google/firebase/database/connection/WebsocketConnection.java b/src/main/java/com/google/firebase/database/connection/WebsocketConnection.java new file mode 100644 index 000000000..7b8f46fdd --- /dev/null +++ b/src/main/java/com/google/firebase/database/connection/WebsocketConnection.java @@ -0,0 +1,385 @@ +package com.google.firebase.database.connection; + +import com.google.firebase.database.connection.util.StringListReader; +import com.google.firebase.database.logging.LogWrapper; +import com.google.firebase.database.tubesock.WebSocket; +import com.google.firebase.database.tubesock.WebSocketEventHandler; +import com.google.firebase.database.tubesock.WebSocketException; +import com.google.firebase.database.tubesock.WebSocketMessage; +import com.google.firebase.database.util.JsonMapper; +import java.io.EOFException; +import java.io.IOException; +import java.net.URI; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; + +class WebsocketConnection { + + private static long connectionId = 0; + private static final long KEEP_ALIVE_TIMEOUT_MS = 45 * 1000; // 45 seconds + private static final long CONNECT_TIMEOUT_MS = 30 * 1000; // 30 seconds + private static final int MAX_FRAME_SIZE = 16384; + + public interface Delegate { + + void onMessage(Map message); + + void onDisconnect(boolean wasEverConnected); + } + + private interface WSClient { + + void connect(); + + void close(); + + void send(String msg); + } + + private class WSClientTubesock implements WSClient, WebSocketEventHandler { + + private WebSocket ws; + + private WSClientTubesock(WebSocket ws) { + this.ws = ws; + this.ws.setEventHandler(this); + } + + @Override + public void onOpen() { + executorService.execute( + new Runnable() { + @Override + public void run() { + connectTimeout.cancel(false); + everConnected = true; + if (logger.logsDebug()) { + logger.debug("websocket opened"); + } + resetKeepAlive(); + } + }); + } + + @Override + public void onMessage(WebSocketMessage msg) { + final String str = msg.getText(); + if (logger.logsDebug()) { + logger.debug("ws message: " + str); + } + executorService.execute( + new Runnable() { + @Override + public void run() { + handleIncomingFrame(str); + } + }); + } + + @Override + public void onClose() { + final String logMessage = "closed"; + executorService.execute( + new Runnable() { + @Override + public void run() { + if (logger.logsDebug()) { + logger.debug(logMessage); + } + onClosed(); + } + }); + } + + @Override + public void onError(final WebSocketException e) { + executorService.execute( + new Runnable() { + @Override + public void run() { + if (e.getCause() != null && e.getCause() instanceof EOFException) { + logger.debug("WebSocket reached EOF."); + } else { + logger.debug("WebSocket error.", e); + } + onClosed(); + } + }); + } + + @Override + public void onLogMessage(String msg) { + if (logger.logsDebug()) { + logger.debug("Tubesock: " + msg); + } + } + + @Override + public void send(String msg) { + ws.send(msg); + } + + @Override + public void close() { + ws.close(); + } + + private void shutdown() { + ws.close(); + try { + ws.blockClose(); + } catch (InterruptedException e) { + logger.error("Interrupted while shutting down websocket threads", e); + } + } + + @Override + public void connect() { + try { + ws.connect(); + } catch (WebSocketException e) { + if (logger.logsDebug()) { + logger.debug("Error connecting", e); + } + shutdown(); + } + } + } + + private WSClient conn; + private boolean everConnected = false; + private boolean isClosed = false; + private long totalFrames = 0; + private StringListReader frameReader; + private Delegate delegate; + private ScheduledFuture keepAlive; + private ScheduledFuture connectTimeout; + private final ConnectionContext connectionContext; + private final ScheduledExecutorService executorService; + private final LogWrapper logger; + + public WebsocketConnection( + ConnectionContext connectionContext, + HostInfo hostInfo, + String optCachedHost, + Delegate delegate, + String optLastSessionId) { + this.connectionContext = connectionContext; + this.executorService = connectionContext.getExecutorService(); + this.delegate = delegate; + long connId = connectionId++; + logger = new LogWrapper(connectionContext.getLogger(), "WebSocket", "ws_" + connId); + conn = createConnection(hostInfo, optCachedHost, optLastSessionId); + } + + private WSClient createConnection( + HostInfo hostInfo, String optCachedHost, String optLastSessionId) { + String host = (optCachedHost != null) ? optCachedHost : hostInfo.getHost(); + URI uri = + HostInfo.getConnectionUrl( + host, hostInfo.isSecure(), hostInfo.getNamespace(), optLastSessionId); + Map extraHeaders = new HashMap<>(); + extraHeaders.put("User-Agent", this.connectionContext.getUserAgent()); + WebSocket ws = new WebSocket(uri, /*protocol=*/ null, extraHeaders); + WSClientTubesock client = new WSClientTubesock(ws); + return client; + } + + public void open() { + conn.connect(); + connectTimeout = + executorService.schedule( + new Runnable() { + @Override + public void run() { + closeIfNeverConnected(); + } + }, + CONNECT_TIMEOUT_MS, + TimeUnit.MILLISECONDS); + } + + public void start() { + // No-op in java + } + + public void close() { + if (logger.logsDebug()) { + logger.debug("websocket is being closed"); + } + isClosed = true; + // Although true is passed for both of these, they each run on the same event loop, so they will + // never be running. + conn.close(); + if (connectTimeout != null) { + connectTimeout.cancel(true); + } + if (keepAlive != null) { + keepAlive.cancel(true); + } + } + + public void send(Map message) { + resetKeepAlive(); + + try { + String toSend = JsonMapper.serializeJson(message); + String[] segs = splitIntoFrames(toSend, MAX_FRAME_SIZE); + if (segs.length > 1) { + conn.send("" + segs.length); + } + + for (int i = 0; i < segs.length; ++i) { + conn.send(segs[i]); + } + } catch (IOException e) { + logger.error("Failed to serialize message: " + message.toString(), e); + shutdown(); + } + } + + private void appendFrame(String message) { + frameReader.addString(message); + totalFrames -= 1; + if (totalFrames == 0) { + // Decode JSON + try { + frameReader.freeze(); + Map decoded = JsonMapper.parseJson(frameReader.toString()); + frameReader = null; + if (logger.logsDebug()) { + logger.debug("handleIncomingFrame complete frame: " + decoded); + } + delegate.onMessage(decoded); + } catch (IOException e) { + logger.error("Error parsing frame: " + frameReader.toString(), e); + close(); + shutdown(); + } catch (ClassCastException e) { + logger.error("Error parsing frame (cast error): " + frameReader.toString(), e); + close(); + shutdown(); + } + } + } + + private void handleNewFrameCount(int numFrames) { + totalFrames = numFrames; + frameReader = new StringListReader(); + if (logger.logsDebug()) { + logger.debug("HandleNewFrameCount: " + totalFrames); + } + } + + private String extractFrameCount(String message) { + // TODO: The server is only supposed to send up to 9999 frames (i.e. length <= 4), but that + // isn't being enforced currently. So allowing larger frame counts (length <= 6). + // See https://app.asana.com/0/search/8688598998380/8237608042508 + if (message.length() <= 6) { + try { + int frameCount = Integer.parseInt(message); + if (frameCount > 0) { + handleNewFrameCount(frameCount); + } + return null; + } catch (NumberFormatException e) { + // not a number, default to framecount 1 + } + } + handleNewFrameCount(1); + return message; + } + + private void handleIncomingFrame(String message) { + if (!isClosed) { + resetKeepAlive(); + if (isBuffering()) { + appendFrame(message); + } else { + String remaining = extractFrameCount(message); + if (remaining != null) { + appendFrame(remaining); + } + } + } + } + + private void resetKeepAlive() { + if (!isClosed) { + if (keepAlive != null) { + keepAlive.cancel(false); + if (logger.logsDebug()) { + logger.debug("Reset keepAlive. Remaining: " + keepAlive.getDelay(TimeUnit.MILLISECONDS)); + } + } else { + if (logger.logsDebug()) { + logger.debug("Reset keepAlive"); + } + } + keepAlive = executorService.schedule(nop(), KEEP_ALIVE_TIMEOUT_MS, TimeUnit.MILLISECONDS); + } + } + + private Runnable nop() { + return new Runnable() { + @Override + public void run() { + if (conn != null) { + conn.send("0"); + resetKeepAlive(); + } + } + }; + } + + private boolean isBuffering() { + return frameReader != null; + } + + // Close methods + + private void onClosed() { + if (!isClosed) { + if (logger.logsDebug()) { + logger.debug("closing itself"); + } + shutdown(); + } + conn = null; + if (keepAlive != null) { + keepAlive.cancel(false); + } + } + + private void shutdown() { + isClosed = true; + delegate.onDisconnect(everConnected); + } + + private void closeIfNeverConnected() { + if (!everConnected && !isClosed) { + if (logger.logsDebug()) { + logger.debug("timed out on connect"); + } + conn.close(); + } + } + + private static String[] splitIntoFrames(String src, int maxFrameSize) { + if (src.length() <= maxFrameSize) { + return new String[]{src}; + } else { + ArrayList segs = new ArrayList<>(); + for (int i = 0; i < src.length(); i += maxFrameSize) { + int end = Math.min(i + maxFrameSize, src.length()); + String seg = src.substring(i, end); + segs.add(seg); + } + return segs.toArray(new String[segs.size()]); + } + } +} diff --git a/src/main/java/com/google/firebase/database/connection/util/RetryHelper.java b/src/main/java/com/google/firebase/database/connection/util/RetryHelper.java new file mode 100644 index 000000000..e40d8d60a --- /dev/null +++ b/src/main/java/com/google/firebase/database/connection/util/RetryHelper.java @@ -0,0 +1,158 @@ +package com.google.firebase.database.connection.util; + +import com.google.firebase.database.logging.LogWrapper; +import com.google.firebase.database.logging.Logger; +import java.util.Random; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; + +public class RetryHelper { + + private final ScheduledExecutorService executorService; + private final LogWrapper logger; + /** + * The minimum delay for a retry in ms + */ + private final long minRetryDelayAfterFailure; + /** + * The maximum retry delay in ms + */ + private final long maxRetryDelay; + /** + * The range of the delay that will be used at random + * 0 => no randomness + * 0.5 => at least half the current delay + * 1 => any delay between [min, max) + */ + private final double jitterFactor; + /** + * The backoff exponent + */ + private final double retryExponent; + + private final Random random = new Random(); + + private ScheduledFuture scheduledRetry; + + private long currentRetryDelay; + private boolean lastWasSuccess = true; + + private RetryHelper( + ScheduledExecutorService executorService, + LogWrapper logger, + long minRetryDelayAfterFailure, + long maxRetryDelay, + double retryExponent, + double jitterFactor) { + this.executorService = executorService; + this.logger = logger; + this.minRetryDelayAfterFailure = minRetryDelayAfterFailure; + this.maxRetryDelay = maxRetryDelay; + this.retryExponent = retryExponent; + this.jitterFactor = jitterFactor; + } + + public void retry(final Runnable runnable) { + Runnable wrapped = + new Runnable() { + @Override + public void run() { + scheduledRetry = null; + runnable.run(); + } + }; + long delay; + if (this.scheduledRetry != null) { + logger.debug("Cancelling previous scheduled retry"); + this.scheduledRetry.cancel(false); + this.scheduledRetry = null; + } + if (this.lastWasSuccess) { + delay = 0; + } else { + if (this.currentRetryDelay == 0) { + this.currentRetryDelay = this.minRetryDelayAfterFailure; + } else { + long newDelay = (long) (this.currentRetryDelay * this.retryExponent); + this.currentRetryDelay = Math.min(newDelay, this.maxRetryDelay); + } + delay = + (long) + (((1 - jitterFactor) * this.currentRetryDelay) + + (jitterFactor * currentRetryDelay * random.nextDouble())); + } + this.lastWasSuccess = false; + logger.debug("Scheduling retry in %dms", delay); + this.scheduledRetry = this.executorService.schedule(wrapped, delay, TimeUnit.MILLISECONDS); + } + + public void signalSuccess() { + this.lastWasSuccess = true; + this.currentRetryDelay = 0; + } + + public void setMaxDelay() { + this.currentRetryDelay = this.maxRetryDelay; + } + + public void cancel() { + if (this.scheduledRetry != null) { + logger.debug("Cancelling existing retry attempt"); + this.scheduledRetry.cancel(false); + this.scheduledRetry = null; + } else { + logger.debug("No existing retry attempt to cancel"); + } + this.currentRetryDelay = 0; + } + + /** */ + public static class Builder { + + private final ScheduledExecutorService service; + private long minRetryDelayAfterFailure = 1000; + private double jitterFactor = 0.5; + private long retryMaxDelay = 30 * 1000; + private double retryExponent = 1.3; + private final LogWrapper logger; + + public Builder(ScheduledExecutorService service, Logger logger, String tag) { + this.service = service; + this.logger = new LogWrapper(logger, tag); + } + + public Builder withMinDelayAfterFailure(long delay) { + this.minRetryDelayAfterFailure = delay; + return this; + } + + public Builder withMaxDelay(long delay) { + this.retryMaxDelay = delay; + return this; + } + + public Builder withRetryExponent(double exponent) { + this.retryExponent = exponent; + return this; + } + + public Builder withJitterFactor(double random) { + if (random < 0 || random > 1) { + throw new IllegalArgumentException("Argument out of range: " + random); + } + this.jitterFactor = random; + return this; + } + + public RetryHelper build() { + return new RetryHelper( + this.service, + this.logger, + this.minRetryDelayAfterFailure, + this.retryMaxDelay, + this.retryExponent, + this.jitterFactor); + } + } +} diff --git a/src/main/java/com/google/firebase/database/connection/util/StringListReader.java b/src/main/java/com/google/firebase/database/connection/util/StringListReader.java new file mode 100644 index 000000000..f4834ac9d --- /dev/null +++ b/src/main/java/com/google/firebase/database/connection/util/StringListReader.java @@ -0,0 +1,174 @@ +package com.google.firebase.database.connection.util; + +import java.io.IOException; +import java.io.Reader; +import java.nio.CharBuffer; +import java.util.ArrayList; +import java.util.List; + +public class StringListReader extends Reader { + + private List strings = null; + private boolean closed = false; + + private int charPos; + private int stringListPos; + + private int markedCharPos = charPos; + private int markedStringListPos = stringListPos; + + private boolean frozen = false; + + public StringListReader() { + strings = new ArrayList<>(); + } + + public void addString(String string) { + if (frozen) { + throw new IllegalStateException("Trying to add string after reading"); + } + if (string.length() > 0) { + strings.add(string); + } + } + + public void freeze() { + if (frozen) { + throw new IllegalStateException("Trying to freeze frozen StringListReader"); + } + frozen = true; + } + + @Override + public String toString() { + StringBuilder builder = new StringBuilder(); + for (String string : this.strings) { + builder.append(string); + } + return builder.toString(); + } + + @Override + public void reset() throws IOException { + charPos = markedCharPos; + stringListPos = markedStringListPos; + } + + private String currentString() { + return (stringListPos < this.strings.size()) ? this.strings.get(stringListPos) : null; + } + + private int currentStringRemainingChars() { + String current = currentString(); + return (current == null) ? 0 : current.length() - charPos; + } + + private void checkState() throws IOException { + if (this.closed) { + throw new IOException("Stream already closed"); + } + if (!frozen) { + throw new IOException("Reader needs to be frozen before read operations can be called"); + } + } + + private long advance(long numChars) { + long advanced = 0; + while (stringListPos < strings.size() && advanced < numChars) { + int remainingStringChars = currentStringRemainingChars(); + long remainingChars = numChars - advanced; + if (remainingChars < remainingStringChars) { + charPos += remainingChars; + advanced += remainingChars; + } else { + advanced += remainingStringChars; + charPos = 0; + stringListPos++; + } + } + return advanced; + } + + @Override + public int read(CharBuffer target) throws IOException { + checkState(); + int remaining = target.remaining(); + int total = 0; + String current = currentString(); + while (remaining > 0 && current != null) { + int strLength = Math.min(current.length() - charPos, remaining); + target.put(this.strings.get(stringListPos), charPos, charPos + strLength); + remaining -= strLength; + total += strLength; + advance(strLength); + current = currentString(); + } + if (total > 0 || current != null) { + return total; + } else { + return -1; + } + } + + @Override + public int read() throws IOException { + checkState(); + String current = currentString(); + if (current == null) { + return -1; + } else { + char c = current.charAt(charPos); + advance(1); + return c; + } + } + + @Override + public long skip(long n) throws IOException { + checkState(); + return advance(n); + } + + @Override + public boolean ready() throws IOException { + checkState(); + return true; + } + + @Override + public boolean markSupported() { + return true; + } + + @Override + public void mark(int readAheadLimit) throws IOException { + checkState(); + markedCharPos = charPos; + markedStringListPos = stringListPos; + } + + @Override + public int read(char[] cbuf, int off, int len) throws IOException { + checkState(); + int charsCopied = 0; + String current = currentString(); + while (current != null && charsCopied < len) { + int copyLength = Math.min(currentStringRemainingChars(), len - charsCopied); + current.getChars(charPos, charPos + copyLength, cbuf, off + charsCopied); + charsCopied += copyLength; + advance(copyLength); + current = currentString(); + } + if (charsCopied > 0 || current != null) { + return charsCopied; + } else { + return -1; + } + } + + @Override + public void close() throws IOException { + checkState(); + this.closed = true; + } +} diff --git a/src/main/java/com/google/firebase/database/core/AuthTokenProvider.java b/src/main/java/com/google/firebase/database/core/AuthTokenProvider.java new file mode 100644 index 000000000..9b9de7b40 --- /dev/null +++ b/src/main/java/com/google/firebase/database/core/AuthTokenProvider.java @@ -0,0 +1,62 @@ +package com.google.firebase.database.core; + +/** */ +public interface AuthTokenProvider { + + /** */ + interface GetTokenCompletionListener { + + /** + * Called if the getToken operation completed successfully. Token may be null if there is no + * auth state currently. + */ + void onSuccess(String token); + + /** + * Called if the getToken operation fails. + * + *

TODO: Figure out sane way to plumb errors through. + */ + void onError(String error); + } + + /** */ + interface TokenChangeListener { + + /** + * Called whenever an event happens that will affect the current auth token (e.g. user logging + * in or out). Use {@link #getToken(boolean, GetTokenCompletionListener)} method to get the + * updated token. + */ + void onTokenChange(String token); + + // TODO(mikelehen): Remove this once AndroidAuthTokenProvider is updated to call + // the other method. + + /** */ + void onTokenChange(); + } + + /** + * Gets the token that should currently be used for authenticated requests. + * + * @param forceRefresh Pass true to get a new, up-to-date token rather than a (potentially + * expired) cached token. + * @param listener Listener to be notified after operation completes. + */ + void getToken(boolean forceRefresh, GetTokenCompletionListener listener); + + /** + * Adds a TokenChangeListener to be notified of token changes. + * + * @param listener Listener to be added. + */ + void addTokenChangeListener(TokenChangeListener listener); + + /** + * Removes a previously-registered TokenChangeListener. + * + * @param listener Listener to be removed. + */ + void removeTokenChangeListener(TokenChangeListener listener); +} diff --git a/src/main/java/com/google/firebase/database/core/ChildEventRegistration.java b/src/main/java/com/google/firebase/database/core/ChildEventRegistration.java new file mode 100644 index 000000000..5a6fb2a5b --- /dev/null +++ b/src/main/java/com/google/firebase/database/core/ChildEventRegistration.java @@ -0,0 +1,112 @@ +package com.google.firebase.database.core; + +import com.google.firebase.database.ChildEventListener; +import com.google.firebase.database.DataSnapshot; +import com.google.firebase.database.DatabaseError; +import com.google.firebase.database.DatabaseReference; +import com.google.firebase.database.InternalHelpers; +import com.google.firebase.database.annotations.NotNull; +import com.google.firebase.database.core.view.Change; +import com.google.firebase.database.core.view.DataEvent; +import com.google.firebase.database.core.view.Event; +import com.google.firebase.database.core.view.QuerySpec; + +public class ChildEventRegistration extends EventRegistration { + + private final Repo repo; + private final ChildEventListener eventListener; + private final QuerySpec spec; + + public ChildEventRegistration( + @NotNull Repo repo, @NotNull ChildEventListener eventListener, @NotNull QuerySpec spec) { + this.repo = repo; + this.eventListener = eventListener; + this.spec = spec; + } + + @Override + public boolean respondsTo(Event.EventType eventType) { + return eventType != Event.EventType.VALUE; + } + + @Override + public boolean equals(Object other) { + return other instanceof ChildEventRegistration + && ((ChildEventRegistration) other).eventListener.equals(eventListener) + && ((ChildEventRegistration) other).repo.equals(repo) + && ((ChildEventRegistration) other).spec.equals(spec); + } + + @Override + public int hashCode() { + int result = this.eventListener.hashCode(); + result = 31 * result + this.repo.hashCode(); + result = 31 * result + this.spec.hashCode(); + return result; + } + + @Override + public DataEvent createEvent(Change change, QuerySpec query) { + DatabaseReference ref = + InternalHelpers.createReference(repo, query.getPath().child(change.getChildKey())); + + DataSnapshot snapshot = InternalHelpers.createDataSnapshot(ref, change.getIndexedNode()); + String prevName = change.getPrevName() != null ? change.getPrevName().asString() : null; + return new DataEvent(change.getEventType(), this, snapshot, prevName); + } + + @Override + public void fireEvent(final DataEvent eventData) { + if (isZombied()) { + return; + } + switch (eventData.getEventType()) { + case CHILD_ADDED: + eventListener.onChildAdded(eventData.getSnapshot(), eventData.getPreviousName()); + break; + case CHILD_CHANGED: + eventListener.onChildChanged(eventData.getSnapshot(), eventData.getPreviousName()); + break; + case CHILD_MOVED: + eventListener.onChildMoved(eventData.getSnapshot(), eventData.getPreviousName()); + break; + case CHILD_REMOVED: + eventListener.onChildRemoved(eventData.getSnapshot()); + break; + default: + // Shouldn't ever happen. No-op + } + } + + @Override + public void fireCancelEvent(final DatabaseError error) { + eventListener.onCancelled(error); + } + + @Override + public EventRegistration clone(QuerySpec newQuery) { + return new ChildEventRegistration(this.repo, this.eventListener, newQuery); + } + + @Override + public boolean isSameListener(EventRegistration other) { + return (other instanceof ChildEventRegistration) + && ((ChildEventRegistration) other).eventListener.equals(eventListener); + } + + @NotNull + @Override + public QuerySpec getQuerySpec() { + return spec; + } + + @Override + public String toString() { + return "ChildEventRegistration"; + } + + @Override + Repo getRepo() { + return repo; + } +} diff --git a/src/main/java/com/google/firebase/database/core/CompoundWrite.java b/src/main/java/com/google/firebase/database/core/CompoundWrite.java new file mode 100644 index 000000000..583c0eaeb --- /dev/null +++ b/src/main/java/com/google/firebase/database/core/CompoundWrite.java @@ -0,0 +1,287 @@ +package com.google.firebase.database.core; + +import com.google.firebase.database.core.utilities.ImmutableTree; +import com.google.firebase.database.snapshot.ChildKey; +import com.google.firebase.database.snapshot.NamedNode; +import com.google.firebase.database.snapshot.Node; +import com.google.firebase.database.snapshot.NodeUtilities; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.Iterator; +import java.util.List; +import java.util.Map; + +/** + * This class holds a collection of writes that can be applied to nodes in unison. It abstracts away + * the logic with dealing with priority writes and multiple nested writes. At any given path there + * is only allowed to be one write modifying that path. Any write to an existing path or shadowing + * an existing path will modify that existing write to reflect the write added. + */ +public class CompoundWrite implements Iterable> { + + private static final CompoundWrite EMPTY = new CompoundWrite(new ImmutableTree(null)); + + private final ImmutableTree writeTree; + + private CompoundWrite(ImmutableTree writeTree) { + this.writeTree = writeTree; + } + + public static CompoundWrite emptyWrite() { + return EMPTY; + } + + public static CompoundWrite fromValue(Map merge) { + ImmutableTree writeTree = ImmutableTree.emptyInstance(); + for (Map.Entry entry : merge.entrySet()) { + ImmutableTree tree = + new ImmutableTree<>(NodeUtilities.NodeFromJSON(entry.getValue())); + writeTree = writeTree.setTree(new Path(entry.getKey()), tree); + } + return new CompoundWrite(writeTree); + } + + public static CompoundWrite fromChildMerge(Map merge) { + ImmutableTree writeTree = ImmutableTree.emptyInstance(); + for (Map.Entry entry : merge.entrySet()) { + ImmutableTree tree = new ImmutableTree<>(entry.getValue()); + writeTree = writeTree.setTree(new Path(entry.getKey()), tree); + } + return new CompoundWrite(writeTree); + } + + public static CompoundWrite fromPathMerge(Map merge) { + ImmutableTree writeTree = ImmutableTree.emptyInstance(); + for (Map.Entry entry : merge.entrySet()) { + ImmutableTree tree = new ImmutableTree<>(entry.getValue()); + writeTree = writeTree.setTree(entry.getKey(), tree); + } + return new CompoundWrite(writeTree); + } + + public CompoundWrite addWrite(Path path, Node node) { + if (path.isEmpty()) { + return new CompoundWrite(new ImmutableTree<>(node)); + } else { + Path rootMostPath = this.writeTree.findRootMostPathWithValue(path); + if (rootMostPath != null) { + Path relativePath = Path.getRelative(rootMostPath, path); + Node value = this.writeTree.get(rootMostPath); + ChildKey back = relativePath.getBack(); + if (back != null + && back.isPriorityChildName() + && value.getChild(relativePath.getParent()).isEmpty()) { + // Ignore priority updates on empty nodes + return this; + } else { + value = value.updateChild(relativePath, node); + return new CompoundWrite(this.writeTree.set(rootMostPath, value)); + } + } else { + ImmutableTree subtree = new ImmutableTree<>(node); + ImmutableTree newWriteTree = this.writeTree.setTree(path, subtree); + return new CompoundWrite(newWriteTree); + } + } + } + + public CompoundWrite addWrite(ChildKey key, Node node) { + return addWrite(new Path(key), node); + } + + public CompoundWrite addWrites(final Path path, CompoundWrite updates) { + return updates.writeTree.fold( + this, + new ImmutableTree.TreeVisitor() { + @Override + public CompoundWrite onNodeValue(Path relativePath, Node value, CompoundWrite accum) { + return accum.addWrite(path.child(relativePath), value); + } + }); + } + + /** + * Will remove a write at the given path and deeper paths. This will not modify a write + * at a higher location, which must be removed by calling this method with that path. + * + * @param path The path at which a write and all deeper writes should be removed + * @return The new WriteCompound with the removed path + */ + public CompoundWrite removeWrite(Path path) { + if (path.isEmpty()) { + return EMPTY; + } else { + ImmutableTree newWriteTree = + writeTree.setTree(path, ImmutableTree.emptyInstance()); + return new CompoundWrite(newWriteTree); + } + } + + /** + * Returns whether this CompoundWrite will fully overwrite a node at a given location and can + * therefore be considered "complete". + * + * @param path The path to check for + * @return Whether there is a complete write at that path + */ + public boolean hasCompleteWrite(Path path) { + return getCompleteNode(path) != null; + } + + public Node rootWrite() { + return this.writeTree.getValue(); + } + + /** + * Returns a node for a path if and only if the node is a "complete" overwrite at that path. This + * will not aggregate writes from deeper paths, but will return child nodes from a more shallow + * path. + * + * @param path The path to get a complete write + * @return The node if complete at that path, or null otherwise. + */ + public Node getCompleteNode(Path path) { + Path rootMost = this.writeTree.findRootMostPathWithValue(path); + if (rootMost != null) { + return this.writeTree.get(rootMost).getChild(Path.getRelative(rootMost, path)); + } else { + return null; + } + } + + /** + * Returns all children that are guaranteed to be a complete overwrite. + * + * @return A list of all complete children. + */ + public List getCompleteChildren() { + List children = new ArrayList<>(); + if (this.writeTree.getValue() != null) { + for (NamedNode entry : this.writeTree.getValue()) { + children.add(new NamedNode(entry.getName(), entry.getNode())); + } + } else { + for (Map.Entry> entry : this.writeTree.getChildren()) { + ImmutableTree childTree = entry.getValue(); + if (childTree.getValue() != null) { + children.add(new NamedNode(entry.getKey(), childTree.getValue())); + } + } + } + return children; + } + + public CompoundWrite childCompoundWrite(Path path) { + if (path.isEmpty()) { + return this; + } else { + Node shadowingNode = this.getCompleteNode(path); + if (shadowingNode != null) { + return new CompoundWrite(new ImmutableTree<>(shadowingNode)); + } else { + // let the constructor extract the priority update + return new CompoundWrite(this.writeTree.subtree(path)); + } + } + } + + public Map childCompoundWrites() { + Map children = new HashMap<>(); + for (Map.Entry> entries : this.writeTree.getChildren()) { + children.put(entries.getKey(), new CompoundWrite(entries.getValue())); + } + return children; + } + + /** + * Returns true if this CompoundWrite is empty and therefore does not modify any nodes. + * + * @return Whether this CompoundWrite is empty + */ + public boolean isEmpty() { + return this.writeTree.isEmpty(); + } + + private Node applySubtreeWrite(Path relativePath, ImmutableTree writeTree, Node node) { + if (writeTree.getValue() != null) { + // Since there a write is always a leaf, we're done here + return node.updateChild(relativePath, writeTree.getValue()); + } else { + Node priorityWrite = null; + for (Map.Entry> childTreeEntry : writeTree.getChildren()) { + ImmutableTree childTree = childTreeEntry.getValue(); + ChildKey childKey = childTreeEntry.getKey(); + if (childKey.isPriorityChildName()) { + // Apply priorities at the end so we don't update priorities for either empty nodes or + // forget to apply priorities to empty nodes that are later filled + assert childTree.getValue() != null : "Priority writes must always be leaf nodes"; + priorityWrite = childTree.getValue(); + } else { + node = applySubtreeWrite(relativePath.child(childKey), childTree, node); + } + } + // If there was a priority write, we only apply it if the node is not empty + if (!node.getChild(relativePath).isEmpty() && priorityWrite != null) { + node = node.updateChild(relativePath.child(ChildKey.getPriorityKey()), priorityWrite); + } + return node; + } + } + + /** + * Applies this CompoundWrite to a node. The node is returned with all writes from this + * CompoundWrite applied to the node + * + * @param node The node to apply this CompoundWrite to + * @return The node with all writes applied + */ + public Node apply(Node node) { + return applySubtreeWrite(Path.getEmptyPath(), this.writeTree, node); + } + + /** + * Returns a serializable version of this CompoundWrite + * + * @param exportFormat Nodes to write are saved in their export format + * @return The map representing this CompoundWrite + */ + public Map getValue(final boolean exportFormat) { + final Map writes = new HashMap<>(); + this.writeTree.foreach( + new ImmutableTree.TreeVisitor() { + @Override + public Void onNodeValue(Path relativePath, Node value, Void accum) { + writes.put(relativePath.wireFormat(), value.getValue(exportFormat)); + return null; + } + }); + return writes; + } + + @Override + public Iterator> iterator() { + return this.writeTree.iterator(); + } + + @Override + public boolean equals(Object o) { + if (o == this) { + return true; + } + if (o == null || o.getClass() != this.getClass()) { + return false; + } + + return ((CompoundWrite) o).getValue(true).equals(this.getValue(true)); + } + + @Override + public int hashCode() { + return this.getValue(true).hashCode(); + } + + @Override + public String toString() { + return "CompoundWrite{" + this.getValue(true).toString() + "}"; + } +} diff --git a/src/main/java/com/google/firebase/database/core/Constants.java b/src/main/java/com/google/firebase/database/core/Constants.java new file mode 100644 index 000000000..789cf2ceb --- /dev/null +++ b/src/main/java/com/google/firebase/database/core/Constants.java @@ -0,0 +1,16 @@ +package com.google.firebase.database.core; + +import com.google.firebase.database.snapshot.ChildKey; + +/** + * User: greg Date: 5/16/13 Time: 3:52 PM + */ +public class Constants { + + public static final ChildKey DOT_INFO = ChildKey.fromString(".info"); + public static final ChildKey DOT_INFO_SERVERTIME_OFFSET = ChildKey.fromString("serverTimeOffset"); + public static final ChildKey DOT_INFO_AUTHENTICATED = ChildKey.fromString("authenticated"); + public static final ChildKey DOT_INFO_CONNECTED = ChildKey.fromString("connected"); + + public static final String WIRE_PROTOCOL_VERSION = "5"; +} diff --git a/src/main/java/com/google/firebase/database/core/Context.java b/src/main/java/com/google/firebase/database/core/Context.java new file mode 100644 index 000000000..913da217a --- /dev/null +++ b/src/main/java/com/google/firebase/database/core/Context.java @@ -0,0 +1,274 @@ +package com.google.firebase.database.core; + +import com.google.firebase.FirebaseApp; +import com.google.firebase.database.DatabaseException; +import com.google.firebase.database.FirebaseDatabase; +import com.google.firebase.database.connection.ConnectionAuthTokenProvider; +import com.google.firebase.database.connection.ConnectionContext; +import com.google.firebase.database.connection.HostInfo; +import com.google.firebase.database.connection.PersistentConnection; +import com.google.firebase.database.core.persistence.NoopPersistenceManager; +import com.google.firebase.database.core.persistence.PersistenceManager; +import com.google.firebase.database.logging.LogWrapper; +import com.google.firebase.database.logging.Logger; +import com.google.firebase.database.utilities.DefaultRunLoop; +import java.util.List; +import java.util.concurrent.ScheduledExecutorService; + +public class Context { + + private static final long DEFAULT_CACHE_SIZE = 10 * 1024 * 1024; + + protected Logger logger; + protected EventTarget eventTarget; + protected AuthTokenProvider authTokenProvider; + protected RunLoop runLoop; + protected String persistenceKey; + protected List loggedComponents; + protected String userAgent; + protected Logger.Level logLevel = Logger.Level.INFO; + protected boolean persistenceEnabled; + protected long cacheSize = DEFAULT_CACHE_SIZE; + protected FirebaseApp firebaseApp; + private PersistenceManager forcedPersistenceManager; + private boolean frozen = false; + private boolean stopped = false; + + private Platform platform; + + private Platform getPlatform() { + if (platform == null) { + if (GaePlatform.isActive()) { + GaePlatform gaePlatform = new GaePlatform(firebaseApp); + gaePlatform.initialize(); + platform = gaePlatform; + } else { + platform = new JvmPlatform(firebaseApp); + } + } + return platform; + } + + public boolean isFrozen() { + return frozen; + } + + public boolean isStopped() { + return stopped; + } + + synchronized void freeze() { + if (!frozen) { + frozen = true; + initServices(); + } + } + + public void requireStarted() { + if (stopped) { + restartServices(); + stopped = false; + } + } + + private void initServices() { + // Do the logger first, so that other components can get a LogWrapper + ensureLogger(); + // Cache platform + getPlatform(); + ensureUserAgent(); + //ensureStorage(); + ensureEventTarget(); + ensureRunLoop(); + ensureSessionIdentifier(); + ensureAuthTokenProvider(); + } + + private void restartServices() { + eventTarget.restart(); + runLoop.restart(); + } + + void stop() { + stopped = true; + eventTarget.shutdown(); + runLoop.shutdown(); + } + + protected void assertUnfrozen() { + if (isFrozen()) { + throw new DatabaseException( + "Modifications to DatabaseConfig objects must occur before they are in use"); + } + } + + public List getOptDebugLogComponents() { + return this.loggedComponents; + } + + public Logger.Level getLogLevel() { + return this.logLevel; + } + + public Logger getLogger() { + return this.logger; + } + + public LogWrapper getLogger(String component) { + return new LogWrapper(logger, component); + } + + public LogWrapper getLogger(String component, String prefix) { + return new LogWrapper(logger, component, prefix); + } + + public ConnectionContext getConnectionContext() { + return new ConnectionContext( + this.getLogger(), + wrapAuthTokenProvider(this.getAuthTokenProvider()), + this.getExecutorService(), + this.isPersistenceEnabled(), + FirebaseDatabase.getSdkVersion(), + this.getUserAgent()); + } + + PersistenceManager getPersistenceManager(String firebaseId) { + // TODO[persistence]: Create this once and store it. + if (forcedPersistenceManager != null) { + return forcedPersistenceManager; + } + if (this.persistenceEnabled) { + PersistenceManager cache = platform.createPersistenceManager(this, firebaseId); + if (cache == null) { + throw new IllegalArgumentException( + "You have enabled persistence, but persistence is not supported on " + + "this platform."); + } + return cache; + } else { + return new NoopPersistenceManager(); + } + } + + public boolean isPersistenceEnabled() { + return this.persistenceEnabled; + } + + public long getPersistenceCacheSizeBytes() { + return this.cacheSize; + } + + // For testing + void forcePersistenceManager(PersistenceManager persistenceManager) { + this.forcedPersistenceManager = persistenceManager; + } + + public EventTarget getEventTarget() { + return eventTarget; + } + + public RunLoop getRunLoop() { + return runLoop; + } + + public String getUserAgent() { + return userAgent; + } + + public String getPlatformVersion() { + return getPlatform().getPlatformVersion(); + } + + public String getSessionPersistenceKey() { + return this.persistenceKey; + } + + public AuthTokenProvider getAuthTokenProvider() { + return this.authTokenProvider; + } + + public PersistentConnection newPersistentConnection( + HostInfo info, PersistentConnection.Delegate delegate) { + return getPlatform().newPersistentConnection(this, this.getConnectionContext(), info, delegate); + } + + private ScheduledExecutorService getExecutorService() { + RunLoop loop = this.getRunLoop(); + if (!(loop instanceof DefaultRunLoop)) { + // TODO(dimond): We really need to remove this option from the public DatabaseConfig + // object + throw new RuntimeException("Custom run loops are not supported!"); + } + return ((DefaultRunLoop) loop).getExecutorService(); + } + + private void ensureLogger() { + if (logger == null) { + logger = getPlatform().newLogger(this, logLevel, loggedComponents); + } + } + + private void ensureRunLoop() { + if (runLoop == null) { + runLoop = platform.newRunLoop(this); + } + } + + private void ensureEventTarget() { + if (eventTarget == null) { + eventTarget = getPlatform().newEventTarget(this); + } + } + + private void ensureUserAgent() { + if (userAgent == null) { + userAgent = buildUserAgent(getPlatform().getUserAgent(this)); + } + } + + private void ensureAuthTokenProvider() { + if (authTokenProvider == null) { + authTokenProvider = getPlatform().newAuthTokenProvider(this.getExecutorService()); + } + } + + private void ensureSessionIdentifier() { + if (persistenceKey == null) { + persistenceKey = "default"; + } + } + + private String buildUserAgent(String platformAgent) { + StringBuilder sb = + new StringBuilder() + .append("Firebase/") + .append(Constants.WIRE_PROTOCOL_VERSION) + .append("/") + .append(FirebaseDatabase.getSdkVersion()) + .append("/") + .append(platformAgent); + return sb.toString(); + } + + private static ConnectionAuthTokenProvider wrapAuthTokenProvider( + final AuthTokenProvider provider) { + return new ConnectionAuthTokenProvider() { + @Override + public void getToken(boolean forceRefresh, final GetTokenCallback callback) { + provider.getToken( + forceRefresh, + new AuthTokenProvider.GetTokenCompletionListener() { + @Override + public void onSuccess(String token) { + callback.onSuccess(token); + } + + @Override + public void onError(String error) { + callback.onError(error); + } + }); + } + }; + } +} diff --git a/src/main/java/com/google/firebase/database/core/DatabaseConfig.java b/src/main/java/com/google/firebase/database/core/DatabaseConfig.java new file mode 100644 index 000000000..3d46b9666 --- /dev/null +++ b/src/main/java/com/google/firebase/database/core/DatabaseConfig.java @@ -0,0 +1,163 @@ +package com.google.firebase.database.core; + +import com.google.firebase.FirebaseApp; +import com.google.firebase.database.DatabaseException; +import com.google.firebase.database.Logger; +import java.util.List; + +/** + * TODO(mikelehen): Since this is no longer public, we should merge it with Context and clean all + * this crap up. Some methods may need to be re-added to FirebaseDatabase if we want to still expose + * them. + */ +public class DatabaseConfig extends Context { + + // TODO(dimond): Remove this from the public API since we currently can't pass logging + // across AIDL interface. + + /** + * If you would like to provide a custom log target, pass an object that implements the {@link + * com.google.firebase.database.Logger Logger} interface. + * + * @hide + * @param logger The custom logger that will be called with all log messages + */ + public synchronized void setLogger(com.google.firebase.database.logging.Logger logger) { + assertUnfrozen(); + this.logger = logger; + } + + /** + * In the default setup, the Firebase Database library will create a thread to handle all + * callbacks. On Android, it will attempt to use the main Looper.
+ *
+ * In the event that you would like more control over how your callbacks are triggered, you can + * provide an object that implements {@link EventTarget EventTarget}. It will be passed a {@link + * java.lang.Runnable Runnable} for each callback. + * + * @param eventTarget The object that will be responsible for triggering callbacks + */ + public synchronized void setEventTarget(EventTarget eventTarget) { + assertUnfrozen(); + this.eventTarget = eventTarget; + } + + /** + * By default, this is set to {@link Logger.Level#INFO INFO}. This includes any internal errors + * ({@link Logger.Level#ERROR ERROR}) and any security debug messages ({@link Logger.Level#INFO + * INFO}) that the client receives. Set to {@link Logger.Level#DEBUG DEBUG} to turn on the + * diagnostic logging, and {@link Logger.Level#NONE NONE} to disable all logging. + * + * @param logLevel The desired minimum log level + */ + public synchronized void setLogLevel(Logger.Level logLevel) { + assertUnfrozen(); + switch (logLevel) { + case DEBUG: + this.logLevel = com.google.firebase.database.logging.Logger.Level.DEBUG; + break; + case INFO: + this.logLevel = com.google.firebase.database.logging.Logger.Level.INFO; + break; + case WARN: + this.logLevel = com.google.firebase.database.logging.Logger.Level.WARN; + break; + case ERROR: + this.logLevel = com.google.firebase.database.logging.Logger.Level.ERROR; + break; + case NONE: + this.logLevel = com.google.firebase.database.logging.Logger.Level.NONE; + break; + default: + throw new IllegalArgumentException("Unknown log level: " + logLevel); + } + } + + /** + * Used primarily for debugging. Limits the debug output to the specified components. By default, + * this is null, which enables logging from all components. Setting this explicitly will also set + * the log level to {@link Logger.Level#DEBUG DEBUG}. + * + * @param debugComponents A list of components for which logs are desired, or null to enable all + * components + */ + public synchronized void setDebugLogComponents(List debugComponents) { + assertUnfrozen(); + setLogLevel(Logger.Level.DEBUG); + loggedComponents = debugComponents; + } + + public void setRunLoop(RunLoop runLoop) { + this.runLoop = runLoop; + } + + public void setAuthTokenProvider(AuthTokenProvider provider) { + this.authTokenProvider = provider; + } + + /** + * Sets the session identifier for this Firebase Database connection. + * + *

Use session identifiers to enable multiple persisted authentication sessions on the same + * device. There is no need to use this method if there will only be one user per device. + * + * @param sessionKey The session key to identify the session with. + * @since 1.1 + */ + public synchronized void setSessionPersistenceKey(String sessionKey) { + assertUnfrozen(); + if (sessionKey == null || sessionKey.isEmpty()) { + throw new IllegalArgumentException("Session identifier is not allowed to be empty or null!"); + } + this.persistenceKey = sessionKey; + } + + /** + * By default the Firebase Database client will keep data in memory while your application is + * running, but not when it is restarted. By setting this value to `true`, the data will be + * persisted to on-device (disk) storage and will thus be available again when the app is + * restarted (even when there is no network connectivity at that time). Note that this method must + * be called before creating your first Database reference and only needs to be called once per + * application. + * + * @since 2.3 + * @param isEnabled Set to true to enable disk persistence, set to false to disable it. + */ + public synchronized void setPersistenceEnabled(boolean isEnabled) { + assertUnfrozen(); + this.persistenceEnabled = isEnabled; + } + + /** + * By default Firebase Database will use up to 10MB of disk space to cache data. If the cache + * grows beyond this size, Firebase Database will start removing data that hasn't been recently + * used. If you find that your application caches too little or too much data, call this method to + * change the cache size. This method must be called before creating your first Database reference + * and only needs to be called once per application. + * + *

Note that the specified cache size is only an approximation and the size on disk may + * temporarily exceed it at times. + * + * @since 2.3 + * @param cacheSizeInBytes The new size of the cache in bytes. + */ + public synchronized void setPersistenceCacheSizeBytes(long cacheSizeInBytes) { + assertUnfrozen(); + + if (cacheSizeInBytes < 1024 * 1024) { + throw new DatabaseException("The minimum cache size must be at least 1MB"); + } + if (cacheSizeInBytes > 100 * 1024 * 1024) { + throw new DatabaseException( + "Firebase Database currently doesn't support a cache size larger than 100MB"); + } + + this.cacheSize = cacheSizeInBytes; + } + + public synchronized void setFirebaseApp(FirebaseApp app) { + this.firebaseApp = app; + } +} diff --git a/src/main/java/com/google/firebase/database/core/EventRegistration.java b/src/main/java/com/google/firebase/database/core/EventRegistration.java new file mode 100644 index 000000000..76aad6f6e --- /dev/null +++ b/src/main/java/com/google/firebase/database/core/EventRegistration.java @@ -0,0 +1,63 @@ +package com.google.firebase.database.core; + +import com.google.firebase.database.DatabaseError; +import com.google.firebase.database.annotations.NotNull; +import com.google.firebase.database.core.view.Change; +import com.google.firebase.database.core.view.DataEvent; +import com.google.firebase.database.core.view.Event; +import com.google.firebase.database.core.view.QuerySpec; +import java.util.concurrent.atomic.AtomicBoolean; + +public abstract class EventRegistration { + + private AtomicBoolean zombied = new AtomicBoolean(false); + private EventRegistrationZombieListener listener; + private boolean isUserInitiated = false; + + public abstract boolean respondsTo(Event.EventType eventType); + + public abstract DataEvent createEvent(Change change, QuerySpec query); + + public abstract void fireEvent(DataEvent dataEvent); + + public abstract void fireCancelEvent(DatabaseError error); + + public abstract EventRegistration clone(QuerySpec newQuery); + + public abstract boolean isSameListener(EventRegistration other); + + @NotNull + public abstract QuerySpec getQuerySpec(); + + public void zombify() { + if (zombied.compareAndSet(false, true)) { + if (listener != null) { + listener.onZombied(this); + listener = null; + } + } + } + + public boolean isZombied() { + return zombied.get(); + } + + public void setOnZombied(EventRegistrationZombieListener listener) { + assert !isZombied(); + assert this.listener == null; + this.listener = listener; + } + + public boolean isUserInitiated() { + return isUserInitiated; + } + + public void setIsUserInitiated(boolean isUserInitiated) { + this.isUserInitiated = isUserInitiated; + } + + // Used for Testing only. + Repo getRepo() { + return null; + } +} diff --git a/src/main/java/com/google/firebase/database/core/EventRegistrationZombieListener.java b/src/main/java/com/google/firebase/database/core/EventRegistrationZombieListener.java new file mode 100644 index 000000000..a2c37c239 --- /dev/null +++ b/src/main/java/com/google/firebase/database/core/EventRegistrationZombieListener.java @@ -0,0 +1,6 @@ +package com.google.firebase.database.core; + +public interface EventRegistrationZombieListener { + + void onZombied(EventRegistration zombiedInstance); +} diff --git a/src/main/java/com/google/firebase/database/core/EventTarget.java b/src/main/java/com/google/firebase/database/core/EventTarget.java new file mode 100644 index 000000000..080dda15c --- /dev/null +++ b/src/main/java/com/google/firebase/database/core/EventTarget.java @@ -0,0 +1,21 @@ +package com.google.firebase.database.core; + +/** + * This interface defines the operations required for the Firebase Database library to fire + * callbacks. Most users should not need this interface, it is only applicable if you are + * customizing the way in which callbacks are triggered. + */ +public interface EventTarget { + + /** + * This method will be called from the library's event loop whenever there is a new callback to be + * triggered. + * + * @param r The callback to be run + */ + void postEvent(Runnable r); + + void shutdown(); + + void restart(); +} diff --git a/src/main/java/com/google/firebase/database/core/GaePlatform.java b/src/main/java/com/google/firebase/database/core/GaePlatform.java new file mode 100644 index 000000000..bd9d29f1b --- /dev/null +++ b/src/main/java/com/google/firebase/database/core/GaePlatform.java @@ -0,0 +1,113 @@ +package com.google.firebase.database.core; + +import com.google.firebase.FirebaseApp; +import com.google.firebase.database.FirebaseDatabase; +import com.google.firebase.database.connection.ConnectionContext; +import com.google.firebase.database.connection.HostInfo; +import com.google.firebase.database.connection.PersistentConnection; +import com.google.firebase.database.connection.PersistentConnectionImpl; +import com.google.firebase.database.core.persistence.PersistenceManager; +import com.google.firebase.database.logging.DefaultLogger; +import com.google.firebase.database.logging.LogWrapper; +import com.google.firebase.database.logging.Logger; +import com.google.firebase.database.tubesock.WebSocket; +import com.google.firebase.database.utilities.DefaultRunLoop; +import com.google.firebase.internal.GaeThreadFactory; +import com.google.firebase.internal.Preconditions; +import com.google.firebase.internal.RevivingScheduledExecutor; +import java.util.List; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ThreadFactory; + +/** + * Represents a Google AppEngine platform. + * + *

This class is not thread-safe. + */ +class GaePlatform implements Platform { + + private static final String TAG = "GaePlatform"; + private static final String PROCESS_PLATFORM = "AppEngine"; + + ThreadFactory threadFactoryInstance; + + private final FirebaseApp firebaseApp; + + public GaePlatform(FirebaseApp firebaseApp) { + this.firebaseApp = firebaseApp; + } + + @Override + public Logger newLogger(Context ctx, Logger.Level level, List components) { + return new DefaultLogger(level, components); + } + + private ThreadFactory getGaeThreadFactory() { + GaeThreadFactory threadFactory = GaeThreadFactory.getInstance(); + Preconditions.checkState(threadFactory.isUsingBackgroundThreads(), + "Failed to initialize a GAE background thread factory"); + return threadFactory; + } + + public static boolean isActive() { + return GaeThreadFactory.isAvailable(); + } + + public void initialize() { + WebSocket.setThreadFactory( + getGaeThreadFactory(), + new com.google.firebase.database.tubesock.ThreadInitializer() { + @Override + public void setName(Thread thread, String s) { + // Unsupported by GAE + } + }); + } + + @Override + public EventTarget newEventTarget(Context ctx) { + RevivingScheduledExecutor eventExecutor = + new RevivingScheduledExecutor(getGaeThreadFactory(), "FirebaseDatabaseEventTarget", true); + return new ThreadPoolEventTarget(eventExecutor); + } + + @Override + public RunLoop newRunLoop(final Context context) { + final LogWrapper logger = context.getLogger("RunLoop"); + return new DefaultRunLoop(getGaeThreadFactory(), /* periodicRestart= */ true, context) { + @Override + public void handleException(Throwable e) { + logger.error(DefaultRunLoop.messageForException(e), e); + } + }; + } + + @Override + public AuthTokenProvider newAuthTokenProvider(ScheduledExecutorService executorService) { + return new JvmAuthTokenProvider(this.firebaseApp, executorService); + } + + @Override + public PersistentConnection newPersistentConnection( + Context context, + ConnectionContext connectionContext, + HostInfo info, + PersistentConnection.Delegate delegate) { + return new PersistentConnectionImpl(context.getConnectionContext(), info, delegate); + } + + @Override + public String getUserAgent(Context ctx) { + return PROCESS_PLATFORM + "/" + DEVICE; + } + + @Override + public String getPlatformVersion() { + return "gae-" + FirebaseDatabase.getSdkVersion(); + } + + @Override + public PersistenceManager createPersistenceManager(Context ctx, String namespace) { + return null; + } +} diff --git a/src/main/java/com/google/firebase/database/core/JvmAuthTokenProvider.java b/src/main/java/com/google/firebase/database/core/JvmAuthTokenProvider.java new file mode 100644 index 000000000..81148dace --- /dev/null +++ b/src/main/java/com/google/firebase/database/core/JvmAuthTokenProvider.java @@ -0,0 +1,115 @@ +package com.google.firebase.database.core; + +import static com.google.firebase.internal.Preconditions.checkNotNull; + +import com.google.firebase.FirebaseApp; +import com.google.firebase.ImplFirebaseTrampolines; +import com.google.firebase.database.util.GAuthToken; +import com.google.firebase.internal.AuthStateListener; +import com.google.firebase.internal.GetTokenResult; +import com.google.firebase.internal.NonNull; +import com.google.firebase.tasks.OnCompleteListener; +import com.google.firebase.tasks.Task; +import java.util.Map; +import java.util.concurrent.ScheduledExecutorService; + +public class JvmAuthTokenProvider implements AuthTokenProvider { + + private final ScheduledExecutorService executorService; + private final FirebaseApp firebaseApp; + + public JvmAuthTokenProvider(FirebaseApp firebaseApp, ScheduledExecutorService executorService) { + this.executorService = executorService; + this.firebaseApp = firebaseApp; + } + + @Override + public void getToken(boolean forceRefresh, final GetTokenCompletionListener listener) { + ImplFirebaseTrampolines.getToken(firebaseApp, forceRefresh) + .addOnCompleteListener( + this.executorService, + new OnCompleteListener() { + @Override + public void onComplete(@NonNull Task task) { + if (task.isSuccessful()) { + listener.onSuccess(wrapOAuthToken(firebaseApp, task.getResult())); + } else { + listener.onError(task.getException().toString()); + } + } + }); + } + + @Override + public void addTokenChangeListener(TokenChangeListener listener) { + ImplFirebaseTrampolines.addAuthStateChangeListener(firebaseApp, wrap(listener)); + } + + @Override + public void removeTokenChangeListener(TokenChangeListener listener) { + ImplFirebaseTrampolines.removeAuthStateChangeListener(firebaseApp, wrap(listener)); + } + + private AuthStateListener wrap(TokenChangeListener listener) { + return new TokenChangeListenerWrapper(listener, firebaseApp, executorService); + } + + /** + * Wraps a TokenChangeListener instance inside a FirebaseApp.AuthStateListener. Equality + * comparisons are delegated to the TokenChangeListener so that listener addition and removal will + * work as expected in FirebaseApp. + */ + private static class TokenChangeListenerWrapper implements AuthStateListener { + + private final TokenChangeListener listener; + private final FirebaseApp firebaseApp; + private final ScheduledExecutorService executorService; + + TokenChangeListenerWrapper( + TokenChangeListener listener, + FirebaseApp firebaseApp, + ScheduledExecutorService executorService) { + this.listener = checkNotNull(listener, "Listener must not be null"); + this.firebaseApp = checkNotNull(firebaseApp, "FirebaseApp must not be null"); + this.executorService = checkNotNull(executorService, "ExecutorService must not be null"); + } + + @Override + public void onAuthStateChanged(final GetTokenResult tokenResult) { + // Notify the TokenChangeListener on database's thread pool to make sure that + // all database work happens on database worker threads. + executorService.execute( + new Runnable() { + @Override + public void run() { + listener.onTokenChange(wrapOAuthToken(firebaseApp, tokenResult)); + } + }); + } + + @Override + public int hashCode() { + return listener.hashCode(); + } + + @Override + public boolean equals(Object obj) { + return obj != null + && obj instanceof TokenChangeListenerWrapper + && ((TokenChangeListenerWrapper) obj).listener.equals(listener); + } + } + + private static String wrapOAuthToken(FirebaseApp firebaseApp, GetTokenResult result) { + String oauthToken = result.getToken(); + if (oauthToken == null) { + // This shouldn't happen in the actual production SDK, but can happen in tests. + return null; + } else { + Map authVariable = firebaseApp.getOptions().getDatabaseAuthVariableOverride(); + GAuthToken gAuthToken = new GAuthToken(oauthToken, authVariable); + return gAuthToken.serializeToString(); + } + } +} + diff --git a/src/main/java/com/google/firebase/database/core/JvmPlatform.java b/src/main/java/com/google/firebase/database/core/JvmPlatform.java new file mode 100644 index 000000000..b40466946 --- /dev/null +++ b/src/main/java/com/google/firebase/database/core/JvmPlatform.java @@ -0,0 +1,78 @@ +package com.google.firebase.database.core; + +import com.google.firebase.FirebaseApp; +import com.google.firebase.database.FirebaseDatabase; +import com.google.firebase.database.connection.ConnectionContext; +import com.google.firebase.database.connection.HostInfo; +import com.google.firebase.database.connection.PersistentConnection; +import com.google.firebase.database.connection.PersistentConnectionImpl; +import com.google.firebase.database.core.persistence.PersistenceManager; +import com.google.firebase.database.logging.DefaultLogger; +import com.google.firebase.database.logging.LogWrapper; +import com.google.firebase.database.logging.Logger; +import com.google.firebase.database.utilities.DefaultRunLoop; +import java.util.List; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; + +class JvmPlatform implements Platform { + + private static final String PROCESS_PLATFORM = System.getProperty("java.version", "Unknown"); + + private final FirebaseApp firebaseApp; + + public JvmPlatform(FirebaseApp firebaseApp) { + this.firebaseApp = firebaseApp; + } + + @Override + public Logger newLogger(Context ctx, Logger.Level level, List components) { + return new DefaultLogger(level, components); + } + + @Override + public EventTarget newEventTarget(Context ctx) { + return new ThreadPoolEventTarget( + Executors.defaultThreadFactory(), ThreadInitializer.defaultInstance); + } + + @Override + public RunLoop newRunLoop(final Context context) { + final LogWrapper logger = context.getLogger("RunLoop"); + return new DefaultRunLoop() { + @Override + public void handleException(Throwable e) { + logger.error(DefaultRunLoop.messageForException(e), e); + } + }; + } + + @Override + public PersistentConnection newPersistentConnection( + Context context, + ConnectionContext connectionContext, + HostInfo info, + PersistentConnection.Delegate delegate) { + return new PersistentConnectionImpl(context.getConnectionContext(), info, delegate); + } + + @Override + public AuthTokenProvider newAuthTokenProvider(ScheduledExecutorService executorService) { + return new JvmAuthTokenProvider(this.firebaseApp, executorService); + } + + @Override + public String getUserAgent(Context ctx) { + return PROCESS_PLATFORM + "/" + DEVICE; + } + + @Override + public String getPlatformVersion() { + return "jvm-" + FirebaseDatabase.getSdkVersion(); + } + + @Override + public PersistenceManager createPersistenceManager(Context ctx, String namespace) { + return null; + } +} diff --git a/src/main/java/com/google/firebase/database/core/Path.java b/src/main/java/com/google/firebase/database/core/Path.java new file mode 100644 index 000000000..f0c89d910 --- /dev/null +++ b/src/main/java/com/google/firebase/database/core/Path.java @@ -0,0 +1,264 @@ +package com.google.firebase.database.core; + +import com.google.firebase.database.DatabaseException; +import com.google.firebase.database.snapshot.ChildKey; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Iterator; +import java.util.List; +import java.util.NoSuchElementException; + +public class Path implements Iterable, Comparable { + + public static Path getRelative(Path from, Path to) { + ChildKey outerFront = from.getFront(); + ChildKey innerFront = to.getFront(); + if (outerFront == null) { + return to; + } else if (outerFront.equals(innerFront)) { + return getRelative(from.popFront(), to.popFront()); + } else { + throw new DatabaseException("INTERNAL ERROR: " + to + " is not contained in " + from); + } + } + + private final ChildKey[] pieces; + private final int start; + private final int end; + + private static final Path EMPTY_PATH = new Path(""); + + public static Path getEmptyPath() { + return EMPTY_PATH; + } + + public Path(ChildKey... segments) { + this.pieces = Arrays.copyOf(segments, segments.length); + this.start = 0; + this.end = segments.length; + for (ChildKey name : segments) { + assert name != null : "Can't construct a path with a null value!"; + } + } + + public Path(List segments) { + this.pieces = new ChildKey[segments.size()]; + int i = 0; + for (String segment : segments) { + this.pieces[i++] = ChildKey.fromString(segment); + } + this.start = 0; + this.end = segments.size(); + } + + public Path(String pathString) { + String[] segments = pathString.split("/"); + int count = 0; + for (String segment : segments) { + if (segment.length() > 0) { + count++; + } + } + pieces = new ChildKey[count]; + int j = 0; + for (String segment : segments) { + if (segment.length() > 0) { + pieces[j++] = ChildKey.fromString(segment); + } + } + this.start = 0; + this.end = pieces.length; + } + + private Path(ChildKey[] pieces, int start, int end) { + this.pieces = pieces; + this.start = start; + this.end = end; + } + + public Path child(Path path) { + int newSize = this.size() + path.size(); + ChildKey[] newPieces = new ChildKey[newSize]; + System.arraycopy(this.pieces, this.start, newPieces, 0, this.size()); + System.arraycopy(path.pieces, path.start, newPieces, this.size(), path.size()); + return new Path(newPieces, 0, newSize); + } + + public Path child(ChildKey child) { + int size = this.size(); + ChildKey[] newPieces = new ChildKey[size + 1]; + System.arraycopy(this.pieces, this.start, newPieces, 0, size); + newPieces[size] = child; + return new Path(newPieces, 0, size + 1); + } + + @Override + public String toString() { + if (this.isEmpty()) { + return "/"; + } else { + StringBuilder builder = new StringBuilder(); + for (int i = this.start; i < this.end; i++) { + builder.append("/"); + builder.append(pieces[i].asString()); + } + return builder.toString(); + } + } + + public String wireFormat() { + if (this.isEmpty()) { + return "/"; + } else { + StringBuilder builder = new StringBuilder(); + for (int i = this.start; i < this.end; i++) { + if (i > this.start) { + builder.append("/"); + } + builder.append(pieces[i].asString()); + } + return builder.toString(); + } + } + + public List asList() { + List result = new ArrayList<>(this.size()); + for (ChildKey key : this) { + result.add(key.asString()); + } + return result; + } + + public ChildKey getFront() { + if (this.isEmpty()) { + return null; + } else { + return pieces[this.start]; + } + } + + public Path popFront() { + int newStart = this.start; + if (!this.isEmpty()) { + newStart++; + } + return new Path(pieces, newStart, this.end); + } + + public Path getParent() { + if (this.isEmpty()) { + return null; + } else { + return new Path(pieces, start, end - 1); + } + } + + public ChildKey getBack() { + if (!this.isEmpty()) { + return pieces[end - 1]; + } else { + return null; + } + } + + public boolean isEmpty() { + return start >= end; + } + + public int size() { + return this.end - this.start; + } + + @Override + public Iterator iterator() { + return new Iterator() { + int offset = start; + + @Override + public boolean hasNext() { + return offset < end; + } + + @Override + public ChildKey next() { + if (!hasNext()) { + throw new NoSuchElementException("No more elements."); + } + ChildKey child = pieces[offset]; + offset++; + return child; + } + + @Override + public void remove() { + throw new UnsupportedOperationException("Can't remove component from immutable Path!"); + } + }; + } + + public boolean contains(Path other) { + if (this.size() > other.size()) { + return false; + } + + int i = this.start; + int j = other.start; + while (i < this.end) { + if (!this.pieces[i].equals(other.pieces[j])) { + return false; + } + i++; + j++; + } + + return true; + } + + @Override + public boolean equals(Object other) { + if (!(other instanceof Path)) { + return false; + } + if (this == other) { + return true; + } + Path otherPath = (Path) other; + if (size() != otherPath.size()) { + return false; + } + for (int i = start, j = otherPath.start; i < end && j < otherPath.end; i++, j++) { + if (!this.pieces[i].equals(otherPath.pieces[j])) { + return false; + } + } + return true; + } + + @Override + public int hashCode() { + int hashCode = 0; + for (int i = start; i < end; i++) { + hashCode = hashCode * 37 + pieces[i].hashCode(); + } + return hashCode; + } + + @Override + public int compareTo(Path other) { + int i; + int j; + for (i = start, j = other.start; i < end && j < other.end; i++, j++) { + int comp = this.pieces[i].compareTo(other.pieces[j]); + if (comp != 0) { + return comp; + } + } + if (i == end && j == other.end) { + return 0; + } else if (i == end) { + return -1; + } else { + return 1; + } + } +} diff --git a/src/main/java/com/google/firebase/database/core/Platform.java b/src/main/java/com/google/firebase/database/core/Platform.java new file mode 100644 index 000000000..095e4a905 --- /dev/null +++ b/src/main/java/com/google/firebase/database/core/Platform.java @@ -0,0 +1,34 @@ +package com.google.firebase.database.core; + +import com.google.firebase.database.connection.ConnectionContext; +import com.google.firebase.database.connection.HostInfo; +import com.google.firebase.database.connection.PersistentConnection; +import com.google.firebase.database.core.persistence.PersistenceManager; +import com.google.firebase.database.logging.Logger; +import java.util.List; +import java.util.concurrent.ScheduledExecutorService; + +public interface Platform { + + String DEVICE = "AdminJava"; + + Logger newLogger(Context ctx, Logger.Level level, List components); + + EventTarget newEventTarget(Context ctx); + + RunLoop newRunLoop(Context ctx); + + AuthTokenProvider newAuthTokenProvider(ScheduledExecutorService executorService); + + PersistentConnection newPersistentConnection( + Context context, + ConnectionContext connectionContext, + HostInfo info, + PersistentConnection.Delegate delegate); + + String getUserAgent(Context ctx); + + String getPlatformVersion(); + + PersistenceManager createPersistenceManager(Context ctx, String firebaseId); +} diff --git a/src/main/java/com/google/firebase/database/core/Repo.java b/src/main/java/com/google/firebase/database/core/Repo.java new file mode 100644 index 000000000..d5f8c1176 --- /dev/null +++ b/src/main/java/com/google/firebase/database/core/Repo.java @@ -0,0 +1,1367 @@ +package com.google.firebase.database.core; + +import static com.google.firebase.database.utilities.Utilities.hardAssert; + +import com.google.firebase.database.DataSnapshot; +import com.google.firebase.database.DatabaseError; +import com.google.firebase.database.DatabaseException; +import com.google.firebase.database.DatabaseReference; +import com.google.firebase.database.FirebaseDatabase; +import com.google.firebase.database.InternalHelpers; +import com.google.firebase.database.MutableData; +import com.google.firebase.database.Transaction; +import com.google.firebase.database.ValueEventListener; +import com.google.firebase.database.annotations.NotNull; +import com.google.firebase.database.connection.HostInfo; +import com.google.firebase.database.connection.ListenHashProvider; +import com.google.firebase.database.connection.PersistentConnection; +import com.google.firebase.database.connection.RequestResultCallback; +import com.google.firebase.database.core.persistence.NoopPersistenceManager; +import com.google.firebase.database.core.persistence.PersistenceManager; +import com.google.firebase.database.core.utilities.Tree; +import com.google.firebase.database.core.view.Event; +import com.google.firebase.database.core.view.EventRaiser; +import com.google.firebase.database.core.view.QuerySpec; +import com.google.firebase.database.logging.LogWrapper; +import com.google.firebase.database.snapshot.ChildKey; +import com.google.firebase.database.snapshot.EmptyNode; +import com.google.firebase.database.snapshot.IndexedNode; +import com.google.firebase.database.snapshot.Node; +import com.google.firebase.database.snapshot.NodeUtilities; +import com.google.firebase.database.snapshot.RangeMerge; +import com.google.firebase.database.utilities.DefaultClock; +import com.google.firebase.database.utilities.OffsetClock; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Map.Entry; + +public class Repo implements PersistentConnection.Delegate { + + private static final String INTERRUPT_REASON = "repo_interrupt"; + + private final RepoInfo repoInfo; + private final OffsetClock serverClock = new OffsetClock(new DefaultClock(), 0); + private final PersistentConnection connection; + private SnapshotHolder infoData; + private SparseSnapshotTree onDisconnect; + private Tree> transactionQueueTree; + private boolean hijackHash = false; + private final EventRaiser eventRaiser; + private final Context ctx; + private final LogWrapper operationLogger; + private final LogWrapper transactionLogger; + private final LogWrapper dataLogger; + public long dataUpdateCount = 0; // for testing. + private long nextWriteId = 1; + private SyncTree infoSyncTree; + private SyncTree serverSyncTree; + private FirebaseDatabase database; + private boolean loggedTransactionPersistenceWarning = false; + + Repo(RepoInfo repoInfo, Context ctx, FirebaseDatabase database) { + this.repoInfo = repoInfo; + this.ctx = ctx; + this.database = database; + + operationLogger = this.ctx.getLogger("RepoOperation"); + transactionLogger = this.ctx.getLogger("Transaction"); + dataLogger = this.ctx.getLogger("DataOperation"); + + this.eventRaiser = new EventRaiser(this.ctx); + + HostInfo hostInfo = new HostInfo(repoInfo.host, repoInfo.namespace, repoInfo.secure); + connection = ctx.newPersistentConnection(hostInfo, this); + + // Kick off any expensive additional initialization + scheduleNow( + new Runnable() { + @Override + public void run() { + deferredInitialization(); + } + }); + } + + /** + * Defers any initialization that is potentially expensive (e.g. disk access) and must be run on + * the run loop + */ + private void deferredInitialization() { + this.ctx + .getAuthTokenProvider() + .addTokenChangeListener( + new AuthTokenProvider.TokenChangeListener() { + // TODO(mikelehen): Remove this once AndroidAuthTokenProvider is updated to call the + // other overload. + @Override + public void onTokenChange() { + operationLogger.debug("Auth token changed, triggering auth token refresh"); + connection.refreshAuthToken(); + } + + @Override + public void onTokenChange(String token) { + operationLogger.debug("Auth token changed, triggering auth token refresh"); + connection.refreshAuthToken(token); + } + }); + + // Open connection now so that by the time we are connected the deferred init has run + // This relies on the fact that all callbacks run on repo's runloop. + connection.initialize(); + + PersistenceManager persistenceManager = ctx.getPersistenceManager(repoInfo.host); + + infoData = new SnapshotHolder(); + onDisconnect = new SparseSnapshotTree(); + + transactionQueueTree = new Tree<>(); + + infoSyncTree = + new SyncTree( + ctx, + new NoopPersistenceManager(), + new SyncTree.ListenProvider() { + @Override + public void startListening( + final QuerySpec query, + Tag tag, + final ListenHashProvider hash, + final SyncTree.CompletionListener onComplete) { + scheduleNow( + new Runnable() { + @Override + public void run() { + // This is possibly a hack, but we have different semantics for .info + // endpoints. We don't raise null events on initial data... + final Node node = infoData.getNode(query.getPath()); + if (!node.isEmpty()) { + List infoEvents = + infoSyncTree.applyServerOverwrite(query.getPath(), node); + postEvents(infoEvents); + onComplete.onListenComplete(null); + } + } + }); + } + + @Override + public void stopListening(QuerySpec query, Tag tag) { + } + }); + + serverSyncTree = + new SyncTree( + ctx, + persistenceManager, + new SyncTree.ListenProvider() { + @Override + public void startListening( + QuerySpec query, + Tag tag, + ListenHashProvider hash, + final SyncTree.CompletionListener onListenComplete) { + connection.listen( + query.getPath().asList(), + query.getParams().getWireProtocolParams(), + hash, + tag != null ? tag.getTagNumber() : null, + new RequestResultCallback() { + @Override + public void onRequestResult(String optErrorCode, String optErrorMessage) { + DatabaseError error = fromErrorCode(optErrorCode, optErrorMessage); + List events = onListenComplete.onListenComplete(error); + postEvents(events); + } + }); + } + + @Override + public void stopListening(QuerySpec query, Tag tag) { + connection.unlisten( + query.getPath().asList(), query.getParams().getWireProtocolParams()); + } + }); + + restoreWrites(persistenceManager); + + updateInfo(Constants.DOT_INFO_AUTHENTICATED, false); + updateInfo(Constants.DOT_INFO_CONNECTED, false); + } + + private void restoreWrites(PersistenceManager persistenceManager) { + List writes = persistenceManager.loadUserWrites(); + + Map serverValues = ServerValues.generateServerValues(serverClock); + long lastWriteId = Long.MIN_VALUE; + for (final UserWriteRecord write : writes) { + RequestResultCallback onComplete = + new RequestResultCallback() { + @Override + public void onRequestResult(String optErrorCode, String optErrorMessage) { + DatabaseError error = fromErrorCode(optErrorCode, optErrorMessage); + warnIfWriteFailed("Persisted write", write.getPath(), error); + ackWriteAndRerunTransactions(write.getWriteId(), write.getPath(), error); + } + }; + if (lastWriteId >= write.getWriteId()) { + throw new IllegalStateException("Write ids were not in order."); + } + lastWriteId = write.getWriteId(); + nextWriteId = write.getWriteId() + 1; + if (write.isOverwrite()) { + if (operationLogger.logsDebug()) { + operationLogger.debug("Restoring overwrite with id " + write.getWriteId()); + } + connection.put(write.getPath().asList(), write.getOverwrite().getValue(true), onComplete); + Node resolved = + ServerValues.resolveDeferredValueSnapshot(write.getOverwrite(), serverValues); + serverSyncTree.applyUserOverwrite( + write.getPath(), + write.getOverwrite(), + resolved, + write.getWriteId(), /*visible=*/ + true, /*persist=*/ + false); + } else { + if (operationLogger.logsDebug()) { + operationLogger.debug("Restoring merge with id " + write.getWriteId()); + } + connection.merge(write.getPath().asList(), write.getMerge().getValue(true), onComplete); + CompoundWrite resolved = + ServerValues.resolveDeferredValueMerge(write.getMerge(), serverValues); + serverSyncTree.applyUserMerge( + write.getPath(), write.getMerge(), resolved, write.getWriteId(), /*persist=*/ false); + } + } + } + + public FirebaseDatabase getDatabase() { + return this.database; + } + + @Override + public String toString() { + return repoInfo.toString(); + } + + public RepoInfo getRepoInfo() { + return this.repoInfo; + } + + // Regarding the next three methods: scheduleNow, schedule, and postEvent: + // Please use these methods rather than accessing the context directly. This ensures that the + // context is correctly re-initialized if it was previously shut down. In practice, this means + // that when a task is submitted, we will guarantee at least one thread in the core pool for the + // run loop. + + public void scheduleNow(Runnable r) { + ctx.requireStarted(); + ctx.getRunLoop().scheduleNow(r); + } + + public void postEvent(Runnable r) { + ctx.requireStarted(); + ctx.getEventTarget().postEvent(r); + } + + private void postEvents(final List events) { + if (!events.isEmpty()) { + this.eventRaiser.raiseEvents(events); + } + } + + public long getServerTime() { + return serverClock.millis(); + } + + boolean hasListeners() { + return !(this.infoSyncTree.isEmpty() && this.serverSyncTree.isEmpty()); + } + + // PersistentConnection.Delegate methods + @SuppressWarnings("unchecked") // For the cast on rawMergedData + @Override + public void onDataUpdate( + List pathSegments, Object message, boolean isMerge, Long optTag) { + Path path = new Path(pathSegments); + if (operationLogger.logsDebug()) { + operationLogger.debug("onDataUpdate: " + path); + } + if (dataLogger.logsDebug()) { + operationLogger.debug("onDataUpdate: " + path + " " + message); + } + dataUpdateCount++; // For testing. + + List events; + try { + if (optTag != null) { + Tag tag = new Tag(optTag); + if (isMerge) { + Map taggedChildren = new HashMap<>(); + Map rawMergeData = (Map) message; + for (Map.Entry entry : rawMergeData.entrySet()) { + Node newChildNode = NodeUtilities.NodeFromJSON(entry.getValue()); + taggedChildren.put(new Path(entry.getKey()), newChildNode); + } + events = this.serverSyncTree.applyTaggedQueryMerge(path, taggedChildren, tag); + } else { + Node taggedSnap = NodeUtilities.NodeFromJSON(message); + events = this.serverSyncTree.applyTaggedQueryOverwrite(path, taggedSnap, tag); + } + } else if (isMerge) { + Map changedChildren = new HashMap<>(); + Map rawMergeData = (Map) message; + for (Map.Entry entry : rawMergeData.entrySet()) { + Node newChildNode = NodeUtilities.NodeFromJSON(entry.getValue()); + changedChildren.put(new Path(entry.getKey()), newChildNode); + } + events = this.serverSyncTree.applyServerMerge(path, changedChildren); + } else { + Node snap = NodeUtilities.NodeFromJSON(message); + events = this.serverSyncTree.applyServerOverwrite(path, snap); + } + if (events.size() > 0) { + // Since we have a listener outstanding for each transaction, receiving any events + // is a proxy for some change having occurred. + this.rerunTransactions(path); + } + + postEvents(events); + } catch (DatabaseException e) { + operationLogger.error("FIREBASE INTERNAL ERROR", e); + } + } + + @Override + public void onRangeMergeUpdate( + List pathSegments, + List merges, + Long tagNumber) { + Path path = new Path(pathSegments); + if (operationLogger.logsDebug()) { + operationLogger.debug("onRangeMergeUpdate: " + path); + } + if (dataLogger.logsDebug()) { + operationLogger.debug("onRangeMergeUpdate: " + path + " " + merges); + } + dataUpdateCount++; // For testing. + + List parsedMerges = new ArrayList<>(merges.size()); + for (com.google.firebase.database.connection.RangeMerge merge : merges) { + parsedMerges.add(new RangeMerge(merge)); + } + + List events; + if (tagNumber != null) { + events = this.serverSyncTree.applyTaggedRangeMerges(path, parsedMerges, new Tag(tagNumber)); + } else { + events = this.serverSyncTree.applyServerRangeMerges(path, parsedMerges); + } + if (events.size() > 0) { + // Since we have a listener outstanding for each transaction, receiving any events + // is a proxy for some change having occurred. + this.rerunTransactions(path); + } + + postEvents(events); + } + + void callOnComplete( + final DatabaseReference.CompletionListener onComplete, + final DatabaseError error, + final Path path) { + if (onComplete != null) { + final DatabaseReference ref; + ChildKey last = path.getBack(); + if (last != null && last.isPriorityChildName()) { + ref = InternalHelpers.createReference(this, path.getParent()); + } else { + ref = InternalHelpers.createReference(this, path); + } + postEvent( + new Runnable() { + @Override + public void run() { + onComplete.onComplete(error, ref); + } + }); + } + } + + private void ackWriteAndRerunTransactions(long writeId, Path path, DatabaseError error) { + if (error != null && error.getCode() == DatabaseError.WRITE_CANCELED) { + // This write was already removed, we just need to ignore it... + } else { + boolean success = error == null; + List clearEvents = + serverSyncTree.ackUserWrite(writeId, !success, /*persist=*/ true, serverClock); + if (clearEvents.size() > 0) { + rerunTransactions(path); + } + postEvents(clearEvents); + } + } + + public void setValue( + final Path path, + Node newValueUnresolved, + final DatabaseReference.CompletionListener onComplete) { + if (operationLogger.logsDebug()) { + operationLogger.debug("set: " + path); + } + if (dataLogger.logsDebug()) { + dataLogger.debug("set: " + path + " " + newValueUnresolved); + } + + Map serverValues = ServerValues.generateServerValues(serverClock); + Node newValue = ServerValues.resolveDeferredValueSnapshot(newValueUnresolved, serverValues); + + final long writeId = this.getNextWriteId(); + List events = + this.serverSyncTree.applyUserOverwrite( + path, newValueUnresolved, newValue, writeId, /*visible=*/ true, /*persist=*/ true); + this.postEvents(events); + + connection.put( + path.asList(), + newValueUnresolved.getValue(true), + new RequestResultCallback() { + @Override + public void onRequestResult(String optErrorCode, String optErrorMessage) { + DatabaseError error = fromErrorCode(optErrorCode, optErrorMessage); + warnIfWriteFailed("setValue", path, error); + ackWriteAndRerunTransactions(writeId, path, error); + callOnComplete(onComplete, error, path); + } + }); + + Path affectedPath = abortTransactions(path, DatabaseError.OVERRIDDEN_BY_SET); + this.rerunTransactions(affectedPath); + } + + public void updateChildren( + final Path path, + CompoundWrite updates, + final DatabaseReference.CompletionListener onComplete, + Map unParsedUpdates) { + if (operationLogger.logsDebug()) { + operationLogger.debug("update: " + path); + } + if (dataLogger.logsDebug()) { + dataLogger.debug("update: " + path + " " + unParsedUpdates); + } + if (updates.isEmpty()) { + if (operationLogger.logsDebug()) { + operationLogger.debug("update called with no changes. No-op"); + } + // dispatch on complete + callOnComplete(onComplete, null, path); + return; + } + + // Start with our existing data and merge each child into it. + Map serverValues = ServerValues.generateServerValues(serverClock); + CompoundWrite resolved = ServerValues.resolveDeferredValueMerge(updates, serverValues); + + final long writeId = this.getNextWriteId(); + List events = + this.serverSyncTree.applyUserMerge(path, updates, resolved, writeId, /*persist=*/ true); + this.postEvents(events); + + // TODO: DatabaseReference.CompleteionListener isn't really appropriate (the DatabaseReference + // param is meaningless). + connection.merge( + path.asList(), + unParsedUpdates, + new RequestResultCallback() { + @Override + public void onRequestResult(String optErrorCode, String optErrorMessage) { + DatabaseError error = fromErrorCode(optErrorCode, optErrorMessage); + warnIfWriteFailed("updateChildren", path, error); + ackWriteAndRerunTransactions(writeId, path, error); + callOnComplete(onComplete, error, path); + } + }); + + for (Entry update : updates) { + Path pathFromRoot = path.child(update.getKey()); + Path affectedPath = abortTransactions(pathFromRoot, DatabaseError.OVERRIDDEN_BY_SET); + rerunTransactions(affectedPath); + } + } + + public void purgeOutstandingWrites() { + if (operationLogger.logsDebug()) { + operationLogger.debug("Purging writes"); + } + List events = serverSyncTree.removeAllWrites(); + postEvents(events); + // Abort any transactions + abortTransactions(Path.getEmptyPath(), DatabaseError.WRITE_CANCELED); + // Remove outstanding writes from connection + connection.purgeOutstandingWrites(); + } + + public void removeEventCallback(@NotNull EventRegistration eventRegistration) { + // These are guaranteed not to raise events, since we're not passing in a cancelError. However, + // we can future-proof a little bit by handling the return values anyways. + List events; + if (Constants.DOT_INFO.equals(eventRegistration.getQuerySpec().getPath().getFront())) { + events = infoSyncTree.removeEventRegistration(eventRegistration); + } else { + events = serverSyncTree.removeEventRegistration(eventRegistration); + } + this.postEvents(events); + } + + public void onDisconnectSetValue( + final Path path, final Node newValue, final DatabaseReference.CompletionListener onComplete) { + connection.onDisconnectPut( + path.asList(), + newValue.getValue(true), + new RequestResultCallback() { + @Override + public void onRequestResult(String optErrorCode, String optErrorMessage) { + DatabaseError error = fromErrorCode(optErrorCode, optErrorMessage); + warnIfWriteFailed("onDisconnect().setValue", path, error); + if (error == null) { + onDisconnect.remember(path, newValue); + } + callOnComplete(onComplete, error, path); + } + }); + } + + public void onDisconnectUpdate( + final Path path, + final Map newChildren, + final DatabaseReference.CompletionListener listener, + Map unParsedUpdates) { + connection.onDisconnectMerge( + path.asList(), + unParsedUpdates, + new RequestResultCallback() { + @Override + public void onRequestResult(String optErrorCode, String optErrorMessage) { + DatabaseError error = fromErrorCode(optErrorCode, optErrorMessage); + warnIfWriteFailed("onDisconnect().updateChildren", path, error); + if (error == null) { + for (Map.Entry entry : newChildren.entrySet()) { + onDisconnect.remember(path.child(entry.getKey()), entry.getValue()); + } + } + callOnComplete(listener, error, path); + } + }); + } + + public void onDisconnectCancel( + final Path path, final DatabaseReference.CompletionListener onComplete) { + connection.onDisconnectCancel( + path.asList(), + new RequestResultCallback() { + @Override + public void onRequestResult(String optErrorCode, String optErrorMessage) { + DatabaseError error = fromErrorCode(optErrorCode, optErrorMessage); + if (error == null) { + onDisconnect.forget(path); + } + callOnComplete(onComplete, error, path); + } + }); + } + + @Override + public void onConnect() { + onServerInfoUpdate(Constants.DOT_INFO_CONNECTED, true); + } + + @Override + public void onDisconnect() { + onServerInfoUpdate(Constants.DOT_INFO_CONNECTED, false); + runOnDisconnectEvents(); + } + + @Override + public void onAuthStatus(boolean authOk) { + onServerInfoUpdate(Constants.DOT_INFO_AUTHENTICATED, authOk); + } + + public void onServerInfoUpdate(ChildKey key, Object value) { + updateInfo(key, value); + } + + @Override + public void onServerInfoUpdate(Map updates) { + for (Map.Entry entry : updates.entrySet()) { + updateInfo(ChildKey.fromString(entry.getKey()), entry.getValue()); + } + } + + void interrupt() { + connection.interrupt(INTERRUPT_REASON); + } + + void resume() { + connection.resume(INTERRUPT_REASON); + } + + public void addEventCallback(@NotNull EventRegistration eventRegistration) { + List events; + ChildKey front = eventRegistration.getQuerySpec().getPath().getFront(); + if (front != null && front.equals(Constants.DOT_INFO)) { + events = this.infoSyncTree.addEventRegistration(eventRegistration); + } else { + events = this.serverSyncTree.addEventRegistration(eventRegistration); + } + this.postEvents(events); + } + + public void keepSynced(QuerySpec query, boolean keep) { + assert query.getPath().isEmpty() || !query.getPath().getFront().equals(Constants.DOT_INFO); + + serverSyncTree.keepSynced(query, keep); + } + + PersistentConnection getConnection() { + return connection; + } + + private void updateInfo(ChildKey childKey, Object value) { + if (childKey.equals(Constants.DOT_INFO_SERVERTIME_OFFSET)) { + serverClock.setOffset((Long) value); + } + + Path path = new Path(Constants.DOT_INFO, childKey); + try { + Node node = NodeUtilities.NodeFromJSON(value); + infoData.update(path, node); + List events = this.infoSyncTree.applyServerOverwrite(path, node); + this.postEvents(events); + } catch (DatabaseException e) { + operationLogger.error("Failed to parse info update", e); + } + } + + private long getNextWriteId() { + return this.nextWriteId++; + } + + private void runOnDisconnectEvents() { + Map serverValues = ServerValues.generateServerValues(serverClock); + SparseSnapshotTree resolvedTree = + ServerValues.resolveDeferredValueTree(this.onDisconnect, serverValues); + final List events = new ArrayList<>(); + + resolvedTree.forEachTree( + Path.getEmptyPath(), + new SparseSnapshotTree.SparseSnapshotTreeVisitor() { + @Override + public void visitTree(Path prefixPath, Node node) { + events.addAll(serverSyncTree.applyServerOverwrite(prefixPath, node)); + Path affectedPath = abortTransactions(prefixPath, DatabaseError.OVERRIDDEN_BY_SET); + rerunTransactions(affectedPath); + } + }); + onDisconnect = new SparseSnapshotTree(); + this.postEvents(events); + } + + private void warnIfWriteFailed(String writeType, Path path, DatabaseError error) { + // DATA_STALE is a normal, expected error during transaction processing. + if (error != null + && !(error.getCode() == DatabaseError.DATA_STALE + || error.getCode() == DatabaseError.WRITE_CANCELED)) { + operationLogger.warn(writeType + " at " + path.toString() + " failed: " + error.toString()); + } + } + + // Transaction code + + /** + * If a transaction does not succeed after 25 retries, we abort it. Among other things this ensure + * that if there's ever a bug causing a mismatch between client / server hashes for some data, we + * won't retry indefinitely. + */ + private static final int TRANSACTION_MAX_RETRIES = 25; + + private static final String TRANSACTION_TOO_MANY_RETRIES = "maxretries"; + private static final String TRANSACTION_OVERRIDE_BY_SET = "overriddenBySet"; + + private enum TransactionStatus { + INITIALIZING, + // We've run the transaction and updated transactionResultData_ with the result, but it isn't + // currently sent to the server. + // A transaction will go from RUN -> SENT -> RUN if it comes back from the server as rejected + // due to mismatched hash. + RUN, + // We've run the transaction and sent it to the server and it's currently outstanding (hasn't + // come back as accepted or rejected yet). + SENT, + // Temporary state used to mark completed transactions (whether successful or aborted). The + // transaction will be removed when we get a chance to prune completed ones. + COMPLETED, + // Used when an already-sent transaction needs to be aborted (e.g. due to a conflicting set() + // call that was made). If it comes back as unsuccessful, we'll abort it. + SENT_NEEDS_ABORT, + // Temporary state used to mark transactions that need to be aborted. + NEEDS_ABORT + } + + private long transactionOrder = 0; + + private static class TransactionData implements Comparable { + + private Path path; + private Transaction.Handler handler; + private ValueEventListener outstandingListener; + private TransactionStatus status; + private long order; + private boolean applyLocally; + private int retryCount; + private DatabaseError abortReason; + private long currentWriteId; + private Node currentInputSnapshot; + private Node currentOutputSnapshotRaw; + private Node currentOutputSnapshotResolved; + + private TransactionData( + Path path, + Transaction.Handler handler, + ValueEventListener outstandingListener, + TransactionStatus status, + boolean applyLocally, + long order) { + this.path = path; + this.handler = handler; + this.outstandingListener = outstandingListener; + this.status = status; + this.retryCount = 0; + this.applyLocally = applyLocally; + this.order = order; + this.abortReason = null; + this.currentInputSnapshot = null; + this.currentOutputSnapshotRaw = null; + this.currentOutputSnapshotResolved = null; + } + + @Override + public int compareTo(TransactionData o) { + if (order < o.order) { + return -1; + } else if (order == o.order) { + return 0; + } else { + return 1; + } + } + } + + public void startTransaction(Path path, final Transaction.Handler handler, boolean applyLocally) { + if (operationLogger.logsDebug()) { + operationLogger.debug("transaction: " + path); + } + if (dataLogger.logsDebug()) { + operationLogger.debug("transaction: " + path); + } + + if (this.ctx.isPersistenceEnabled() && !loggedTransactionPersistenceWarning) { + loggedTransactionPersistenceWarning = true; + transactionLogger.info( + "runTransaction() usage detected while persistence is enabled. Please be aware that " + + "transactions *will not* be persisted across database restarts. See " + + "https://www.firebase.com/docs/android/guide/offline-capabilities.html" + + "#section-handling-transactions-offline for more details."); + } + + // make sure we're listening on this node + // Note: we can't do this asynchronously. To preserve event ordering, + // it has to be done in this block. This is ok, this block is + // guaranteed to be our own event loop + DatabaseReference watchRef = InternalHelpers.createReference(this, path); + ValueEventListener listener = + new ValueEventListener() { + @Override + public void onDataChange(DataSnapshot snapshot) { + // No-op. We don't care, this is just to make sure we have a listener outstanding + } + + @Override + public void onCancelled(DatabaseError error) { + // Also a no-op? We'll cancel the transaction in this case + } + }; + addEventCallback(new ValueEventRegistration(this, listener, watchRef.getSpec())); + + TransactionData transaction = + new TransactionData( + path, + handler, + listener, + TransactionStatus.INITIALIZING, + applyLocally, + nextTransactionOrder()); + + // Run transaction initially. + Node currentState = this.getLatestState(path); + transaction.currentInputSnapshot = currentState; + MutableData mutableCurrent = InternalHelpers.createMutableData(currentState); + + DatabaseError error = null; + Transaction.Result result; + try { + result = handler.doTransaction(mutableCurrent); + if (result == null) { + throw new NullPointerException("Transaction returned null as result"); + } + } catch (Throwable e) { + error = DatabaseError.fromException(e); + result = Transaction.abort(); + } + if (!result.isSuccess()) { + // Abort the transaction + transaction.currentOutputSnapshotRaw = null; + transaction.currentOutputSnapshotResolved = null; + final DatabaseError innerClassError = error; + final DataSnapshot snap = + InternalHelpers.createDataSnapshot( + watchRef, IndexedNode.from(transaction.currentInputSnapshot)); + postEvent( + new Runnable() { + @Override + public void run() { + handler.onComplete(innerClassError, false, snap); + } + }); + } else { + // Mark as run and add to our queue. + transaction.status = TransactionStatus.RUN; + + Tree> queueNode = transactionQueueTree.subTree(path); + List nodeQueue = queueNode.getValue(); + if (nodeQueue == null) { + nodeQueue = new ArrayList<>(); + } + nodeQueue.add(transaction); + queueNode.setValue(nodeQueue); + + Map serverValues = ServerValues.generateServerValues(serverClock); + Node newNodeUnresolved = result.getNode(); + Node newNode = ServerValues.resolveDeferredValueSnapshot(newNodeUnresolved, serverValues); + + transaction.currentOutputSnapshotRaw = newNodeUnresolved; + transaction.currentOutputSnapshotResolved = newNode; + transaction.currentWriteId = this.getNextWriteId(); + + List events = + this.serverSyncTree.applyUserOverwrite( + path, + newNodeUnresolved, + newNode, + transaction.currentWriteId, /*visible=*/ + applyLocally, /*persist=*/ + false); + this.postEvents(events); + sendAllReadyTransactions(); + } + } + + private Node getLatestState(Path path) { + return this.getLatestState(path, new ArrayList()); + } + + private Node getLatestState(Path path, List excudeSets) { + Node state = this.serverSyncTree.calcCompleteEventCache(path, excudeSets); + if (state == null) { + state = EmptyNode.Empty(); + } + return state; + } + + public void setHijackHash(boolean hijackHash) { + this.hijackHash = hijackHash; + } + + private void sendAllReadyTransactions() { + Tree> node = transactionQueueTree; + + pruneCompletedTransactions(node); + sendReadyTransactions(node); + } + + private void sendReadyTransactions(Tree> node) { + List queue = node.getValue(); + if (queue != null) { + queue = buildTransactionQueue(node); + assert queue.size() > 0; // Sending zero length transaction queue + + Boolean allRun = true; + for (TransactionData transaction : queue) { + if (transaction.status != TransactionStatus.RUN) { + allRun = false; + break; + } + } + // If they're all run (and not sent), we can send them. Else, we must wait. + if (allRun) { + sendTransactionQueue(queue, node.getPath()); + } + } else if (node.hasChildren()) { + node.forEachChild( + new Tree.TreeVisitor>() { + @Override + public void visitTree(Tree> tree) { + sendReadyTransactions(tree); + } + }); + } + } + + private void sendTransactionQueue(final List queue, final Path path) { + // Mark transactions as sent and increment retry count! + List setsToIgnore = new ArrayList<>(); + for (TransactionData txn : queue) { + setsToIgnore.add(txn.currentWriteId); + } + + Node latestState = this.getLatestState(path, setsToIgnore); + Node snapToSend = latestState; + String latestHash = "badhash"; + if (!hijackHash) { + latestHash = latestState.getHash(); + } + + for (TransactionData txn : queue) { + assert txn.status + == TransactionStatus.RUN; // sendTransactionQueue: items in queue should all be run.' + txn.status = TransactionStatus.SENT; + txn.retryCount++; + Path relativePath = Path.getRelative(path, txn.path); + // If we've gotten to this point, the output snapshot must be defined. + snapToSend = snapToSend.updateChild(relativePath, txn.currentOutputSnapshotRaw); + } + + Object dataToSend = snapToSend.getValue(true); + + final Repo repo = this; + + // Send the put. + connection.compareAndPut( + path.asList(), + dataToSend, + latestHash, + new RequestResultCallback() { + @Override + public void onRequestResult(String optErrorCode, String optErrorMessage) { + DatabaseError error = fromErrorCode(optErrorCode, optErrorMessage); + warnIfWriteFailed("Transaction", path, error); + List events = new ArrayList<>(); + + if (error == null) { + List callbacks = new ArrayList<>(); + for (final TransactionData txn : queue) { + txn.status = TransactionStatus.COMPLETED; + events.addAll( + serverSyncTree.ackUserWrite( + txn.currentWriteId, /*revert=*/ false, /*persist=*/ false, serverClock)); + + // We never unset the output snapshot, and given that this + // transaction is complete, it should be set + Node node = txn.currentOutputSnapshotResolved; + final DataSnapshot snap = + InternalHelpers.createDataSnapshot( + InternalHelpers.createReference(repo, txn.path), IndexedNode.from(node)); + + callbacks.add( + new Runnable() { + @Override + public void run() { + txn.handler.onComplete(null, true, snap); + } + }); + // Remove the outstanding value listener that we added + removeEventCallback( + new ValueEventRegistration( + Repo.this, + txn.outstandingListener, + QuerySpec.defaultQueryAtPath(txn.path))); + } + + // Now remove the completed transactions + pruneCompletedTransactions(transactionQueueTree.subTree(path)); + + // There may be pending transactions that we can now send + sendAllReadyTransactions(); + + repo.postEvents(events); + + // Finally, run the callbacks + for (int i = 0; i < callbacks.size(); ++i) { + postEvent(callbacks.get(i)); + } + } else { + // transactions are no longer sent. Update their status appropriately + if (error.getCode() == DatabaseError.DATA_STALE) { + for (TransactionData transaction : queue) { + if (transaction.status == TransactionStatus.SENT_NEEDS_ABORT) { + transaction.status = TransactionStatus.NEEDS_ABORT; + } else { + transaction.status = TransactionStatus.RUN; + } + } + } else { + for (TransactionData transaction : queue) { + transaction.status = TransactionStatus.NEEDS_ABORT; + transaction.abortReason = error; + } + } + + // since we reverted mergedData, we should re-run any remaining + // transactions and raise events + rerunTransactions(path); + } + } + }); + } + + private void pruneCompletedTransactions(Tree> node) { + List queue = node.getValue(); + if (queue != null) { + int i = 0; + while (i < queue.size()) { + TransactionData transaction = queue.get(i); + if (transaction.status == TransactionStatus.COMPLETED) { + queue.remove(i); + } else { + i++; + } + } + if (queue.size() > 0) { + node.setValue(queue); + } else { + node.setValue(null); + } + } + + node.forEachChild( + new Tree.TreeVisitor>() { + @Override + public void visitTree(Tree> tree) { + pruneCompletedTransactions(tree); + } + }); + } + + private long nextTransactionOrder() { + return transactionOrder++; + } + + private Path rerunTransactions(Path changedPath) { + Tree> rootMostTransactionNode = getAncestorTransactionNode(changedPath); + Path path = rootMostTransactionNode.getPath(); + + List queue = buildTransactionQueue(rootMostTransactionNode); + rerunTransactionQueue(queue, path); + + return path; + } + + private void rerunTransactionQueue(List queue, Path path) { + if (queue.isEmpty()) { + return; // Nothing to do! + } + + // Queue up the callbacks and fire them after cleaning up all of our transaction state, since + // the callback could trigger more transactions or sets + List callbacks = new ArrayList<>(); + + // Ignore, by default, all of the sets in this queue, since we're re-running all of them. + // However, we want to include the results of new sets triggered as part of this re-run, so we + // don't want to ignore a range, just these specific sets. + List setsToIgnore = new ArrayList<>(); + for (TransactionData transaction : queue) { + setsToIgnore.add(transaction.currentWriteId); + } + + for (final TransactionData transaction : queue) { + Path relativePath = Path.getRelative(path, transaction.path); + boolean abortTransaction = false; + DatabaseError abortReason = null; + List events = new ArrayList<>(); + + assert relativePath != null; // rerunTransactionQueue: relativePath should not be null. + + if (transaction.status == TransactionStatus.NEEDS_ABORT) { + abortTransaction = true; + abortReason = transaction.abortReason; + if (abortReason.getCode() != DatabaseError.WRITE_CANCELED) { + events.addAll( + serverSyncTree.ackUserWrite( + transaction.currentWriteId, /*revert=*/ true, /*persist=*/ false, serverClock)); + } + } else if (transaction.status == TransactionStatus.RUN) { + if (transaction.retryCount >= TRANSACTION_MAX_RETRIES) { + abortTransaction = true; + abortReason = DatabaseError.fromStatus(TRANSACTION_TOO_MANY_RETRIES); + events.addAll( + serverSyncTree.ackUserWrite( + transaction.currentWriteId, /*revert=*/ true, /*persist=*/ false, serverClock)); + } else { + // This code reruns a transaction + Node currentNode = this.getLatestState(transaction.path, setsToIgnore); + transaction.currentInputSnapshot = currentNode; + MutableData mutableCurrent = InternalHelpers.createMutableData(currentNode); + DatabaseError error = null; + Transaction.Result result; + try { + result = transaction.handler.doTransaction(mutableCurrent); + } catch (Throwable e) { + error = DatabaseError.fromException(e); + result = Transaction.abort(); + } + if (result.isSuccess()) { + Long oldWriteId = transaction.currentWriteId; + Map serverValues = ServerValues.generateServerValues(serverClock); + + Node newDataNode = result.getNode(); + Node newNodeResolved = + ServerValues.resolveDeferredValueSnapshot(newDataNode, serverValues); + + transaction.currentOutputSnapshotRaw = newDataNode; + transaction.currentOutputSnapshotResolved = newNodeResolved; + transaction.currentWriteId = this.getNextWriteId(); + + // Mutates setsToIgnore in place + setsToIgnore.remove(oldWriteId); + events.addAll( + serverSyncTree.applyUserOverwrite( + transaction.path, + newDataNode, + newNodeResolved, + transaction.currentWriteId, + transaction.applyLocally, /*persist=*/ + false)); + events.addAll( + serverSyncTree.ackUserWrite( + oldWriteId, /*revert=*/ true, /*persist=*/ false, serverClock)); + } else { + // The user aborted the transaction. It's not an error, so we don't need to send them + // one + abortTransaction = true; + abortReason = error; + events.addAll( + serverSyncTree.ackUserWrite( + transaction.currentWriteId, /*revert=*/ true, /*persist=*/ false, serverClock)); + } + } + } + + this.postEvents(events); + + if (abortTransaction) { + // Abort + transaction.status = TransactionStatus.COMPLETED; + final DatabaseReference ref = InternalHelpers.createReference(this, transaction.path); + + // We set this field immediately, so it's safe to cast to an actual snapshot + Node lastInput = transaction.currentInputSnapshot; + // TODO: In the future, perhaps this should just be KeyIndex? + final DataSnapshot snapshot = + InternalHelpers.createDataSnapshot(ref, IndexedNode.from(lastInput)); + + // Removing a callback can trigger pruning which can muck with mergedData/visibleData (as it + // prunes data). So defer removing the callback until later. + this.scheduleNow( + new Runnable() { + @Override + public void run() { + removeEventCallback( + new ValueEventRegistration( + Repo.this, + transaction.outstandingListener, + QuerySpec.defaultQueryAtPath(transaction.path))); + } + }); + + final DatabaseError callbackError = abortReason; + callbacks.add( + new Runnable() { + @Override + public void run() { + transaction.handler.onComplete(callbackError, false, snapshot); + } + }); + } + } + + // Clean up completed transactions. + pruneCompletedTransactions(transactionQueueTree); + + // Now fire callbacks, now that we're in a good, known state. + for (int i = 0; i < callbacks.size(); ++i) { + postEvent(callbacks.get(i)); + } + + // Try to send the transaction result to the server. + sendAllReadyTransactions(); + } + + private Tree> getAncestorTransactionNode(Path path) { + Tree> transactionNode = transactionQueueTree; + while (!path.isEmpty() && transactionNode.getValue() == null) { + transactionNode = transactionNode.subTree(new Path(path.getFront())); + path = path.popFront(); + } + + return transactionNode; + } + + private List buildTransactionQueue(Tree> transactionNode) { + List queue = new ArrayList<>(); + aggregateTransactionQueues(queue, transactionNode); + + Collections.sort(queue); + + return queue; + } + + private void aggregateTransactionQueues( + final List queue, Tree> node) { + List childQueue = node.getValue(); + if (childQueue != null) { + queue.addAll(childQueue); + } + + node.forEachChild( + new Tree.TreeVisitor>() { + @Override + public void visitTree(Tree> tree) { + aggregateTransactionQueues(queue, tree); + } + }); + } + + private Path abortTransactions(Path path, final int reason) { + Path affectedPath = getAncestorTransactionNode(path).getPath(); + + if (transactionLogger.logsDebug()) { + operationLogger.debug( + "Aborting transactions for path: " + path + ". Affected: " + affectedPath); + } + + Tree> transactionNode = transactionQueueTree.subTree(path); + transactionNode.forEachAncestor( + new Tree.TreeFilter>() { + @Override + public boolean filterTreeNode(Tree> tree) { + abortTransactionsAtNode(tree, reason); + return false; + } + }); + + abortTransactionsAtNode(transactionNode, reason); + + transactionNode.forEachDescendant( + new Tree.TreeVisitor>() { + @Override + public void visitTree(Tree> tree) { + abortTransactionsAtNode(tree, reason); + } + }); + + return affectedPath; + } + + private void abortTransactionsAtNode(Tree> node, int reason) { + List queue = node.getValue(); + List events = new ArrayList<>(); + + if (queue != null) { + List callbacks = new ArrayList<>(); + final DatabaseError abortError; + if (reason == DatabaseError.OVERRIDDEN_BY_SET) { + abortError = DatabaseError.fromStatus(TRANSACTION_OVERRIDE_BY_SET); + } else { + hardAssert( + reason == DatabaseError.WRITE_CANCELED, "Unknown transaction abort reason: " + reason); + abortError = DatabaseError.fromCode(DatabaseError.WRITE_CANCELED); + } + + int lastSent = -1; + for (int i = 0; i < queue.size(); ++i) { + final TransactionData transaction = queue.get(i); + if (transaction.status == TransactionStatus.SENT_NEEDS_ABORT) { + // No-op. Already marked + } else if (transaction.status == TransactionStatus.SENT) { + assert lastSent == i - 1; // All SENT items should be at beginning of queue. + lastSent = i; + // Mark transaction for abort when it comes back. + transaction.status = TransactionStatus.SENT_NEEDS_ABORT; + transaction.abortReason = abortError; + } else { + assert transaction.status + == TransactionStatus.RUN; // Unexpected transaction status in abort + // We can abort this immediately. + removeEventCallback( + new ValueEventRegistration( + Repo.this, + transaction.outstandingListener, + QuerySpec.defaultQueryAtPath(transaction.path))); + if (reason == DatabaseError.OVERRIDDEN_BY_SET) { + events.addAll( + serverSyncTree.ackUserWrite( + transaction.currentWriteId, /*revert=*/ true, /*persist=*/ false, serverClock)); + } else { + hardAssert( + reason == DatabaseError.WRITE_CANCELED, + "Unknown transaction abort reason: " + reason); + // If it was cancelled, it was already removed from the sync tree + } + callbacks.add( + new Runnable() { + @Override + public void run() { + transaction.handler.onComplete(abortError, false, null); + } + }); + } + } + + if (lastSent == -1) { + // We're not waiting for any sent transactions. We can clear the queue + node.setValue(null); + } else { + // Remove the transactions we aborted + node.setValue(queue.subList(0, lastSent + 1)); + } + + // Now fire the callbacks. + this.postEvents(events); + for (Runnable r : callbacks) { + postEvent(r); + } + } + } + + // Package private for testing purposes only + SyncTree getServerSyncTree() { + return serverSyncTree; + } + + // Package private for testing purposes only + SyncTree getInfoSyncTree() { + return infoSyncTree; + } + + private static DatabaseError fromErrorCode(String optErrorCode, String optErrorReason) { + if (optErrorCode != null) { + return DatabaseError.fromStatus(optErrorCode, optErrorReason); + } else { + return null; + } + } +} diff --git a/src/main/java/com/google/firebase/database/core/RepoInfo.java b/src/main/java/com/google/firebase/database/core/RepoInfo.java new file mode 100644 index 000000000..76a4bd4ae --- /dev/null +++ b/src/main/java/com/google/firebase/database/core/RepoInfo.java @@ -0,0 +1,96 @@ +package com.google.firebase.database.core; + +import java.net.URI; + +/** + * User: greg Date: 5/15/13 Time: 3:55 PM + */ +public class RepoInfo { + + private static final String VERSION_PARAM = "v"; + private static final String LAST_SESSION_ID_PARAM = "ls"; + + public String host; + public boolean secure; + public String namespace; + public String internalHost; + + @Override + public String toString() { + return "http" + (secure ? "s" : "") + "://" + host; + } + + public String toDebugString() { + return "(host=" + + host + + ", secure=" + + secure + + ", ns=" + + namespace + + " internal=" + + internalHost + + ")"; + } + + public URI getConnectionURL(String optLastSessionId) { + String scheme = secure ? "wss" : "ws"; + String url = + scheme + + "://" + + internalHost + + "/.ws?ns=" + + namespace + + "&" + + VERSION_PARAM + + "=" + + Constants.WIRE_PROTOCOL_VERSION; + if (optLastSessionId != null) { + url += "&" + LAST_SESSION_ID_PARAM + "=" + optLastSessionId; + } + return URI.create(url); + } + + public boolean isCacheableHost() { + return internalHost.startsWith("s-"); + } + + public boolean isSecure() { + return secure; + } + + public boolean isDemoHost() { + return host.contains(".firebaseio-demo.com"); + } + + public boolean isCustomHost() { + return !host.contains(".firebaseio.com") && !host.contains(".firebaseio-demo.com"); + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + + RepoInfo repoInfo = (RepoInfo) o; + + if (secure != repoInfo.secure) { + return false; + } + if (!host.equals(repoInfo.host)) { + return false; + } + return namespace.equals(repoInfo.namespace); + } + + @Override + public int hashCode() { + int result = host.hashCode(); + result = 31 * result + (secure ? 1 : 0); + result = 31 * result + namespace.hashCode(); + return result; + } +} diff --git a/src/main/java/com/google/firebase/database/core/RepoManager.java b/src/main/java/com/google/firebase/database/core/RepoManager.java new file mode 100644 index 000000000..4cf3833c9 --- /dev/null +++ b/src/main/java/com/google/firebase/database/core/RepoManager.java @@ -0,0 +1,139 @@ +package com.google.firebase.database.core; + +import com.google.firebase.FirebaseApp; +import com.google.firebase.database.DatabaseException; +import com.google.firebase.database.FirebaseDatabase; +import com.google.firebase.database.InternalHelpers; +import java.util.HashMap; +import java.util.Map; + +public class RepoManager { + + private static final RepoManager instance; + + static { + instance = new RepoManager(); + } + + /** + * Used for legacy unit tests. The public API should go through FirebaseDatabase which calls + * createRepo. + */ + public static Repo getRepo(Context ctx, RepoInfo info) throws DatabaseException { + return instance.getLocalRepo(ctx, info); + } + + public static Repo createRepo(Context ctx, RepoInfo info, FirebaseDatabase database) + throws DatabaseException { + return instance.createLocalRepo(ctx, info, database); + } + + public static void interrupt(Context ctx) { + instance.interruptInternal(ctx); + } + + public static void interrupt(final Repo repo) { + repo.scheduleNow( + new Runnable() { + @Override + public void run() { + repo.interrupt(); + } + }); + } + + public static void resume(final Repo repo) { + repo.scheduleNow( + new Runnable() { + @Override + public void run() { + repo.resume(); + } + }); + } + + public static void resume(Context ctx) { + instance.resumeInternal(ctx); + } + + private final Map> repos = new HashMap<>(); + + public RepoManager() { + } + + private Repo getLocalRepo(Context ctx, RepoInfo info) throws DatabaseException { + ctx.freeze(); // No-op if it's already frozen + String repoHash = "https://" + info.host + "/" + info.namespace; + synchronized (repos) { + if (!repos.containsKey(ctx) || !repos.get(ctx).containsKey(repoHash)) { + // Calling this should create the repo. + InternalHelpers.createDatabaseForTests( + FirebaseApp.getInstance(), info, (DatabaseConfig) ctx); + } + return repos.get(ctx).get(repoHash); + } + } + + private Repo createLocalRepo(Context ctx, RepoInfo info, FirebaseDatabase database) + throws DatabaseException { + ctx.freeze(); // No-op if it's already frozen + String repoHash = "https://" + info.host + "/" + info.namespace; + synchronized (repos) { + if (!repos.containsKey(ctx)) { + Map innerMap = new HashMap<>(); + repos.put(ctx, innerMap); + } + Map innerMap = repos.get(ctx); + if (!innerMap.containsKey(repoHash)) { + Repo repo = new Repo(info, ctx, database); + innerMap.put(repoHash, repo); + return repo; + } else { + throw new IllegalStateException("createLocalRepo() called for existing repo."); + } + } + } + + private void interruptInternal(final Context ctx) { + RunLoop runLoop = ctx.getRunLoop(); + if (runLoop != null) { + runLoop.scheduleNow( + new Runnable() { + @Override + public void run() { + synchronized (repos) { + boolean allEmpty = true; + if (repos.containsKey(ctx)) { + for (Repo repo : repos.get(ctx).values()) { + repo.interrupt(); + allEmpty = allEmpty && !repo.hasListeners(); + } + if (allEmpty) { + ctx.stop(); + } + } + } + } + }); + } + } + + private void resumeInternal(final Context ctx) { + RunLoop runLoop = ctx.getRunLoop(); + if (runLoop != null) { + runLoop.scheduleNow( + new Runnable() { + @Override + public void run() { + synchronized (repos) { + if (repos.containsKey(ctx)) { + for (Repo repo : repos.get(ctx).values()) { + repo.resume(); + } + } + } + } + }); + } + } +} diff --git a/src/main/java/com/google/firebase/database/core/RunLoop.java b/src/main/java/com/google/firebase/database/core/RunLoop.java new file mode 100644 index 000000000..c7322d382 --- /dev/null +++ b/src/main/java/com/google/firebase/database/core/RunLoop.java @@ -0,0 +1,32 @@ +package com.google.firebase.database.core; + +import java.util.concurrent.ScheduledFuture; + +/** + * This interface defines the required functionality for the Firebase Database library's run loop. + * Most users will not need this interface. However, if you are customizing how the Firebase + * Database + */ +@SuppressWarnings("rawtypes") +public interface RunLoop { + + /** + * Append this operation to the queue + * + * @param r The operation to run + */ + void scheduleNow(Runnable r); + + /** + * Schedule this operation to run after the specified delay + * + * @param r The operation to run + * @param milliseconds The delay, in milliseconds + * @return A Future that can be used to cancel the operation if it has not yet started executing + */ + ScheduledFuture schedule(Runnable r, long milliseconds); + + void shutdown(); + + void restart(); +} diff --git a/src/main/java/com/google/firebase/database/core/ServerValues.java b/src/main/java/com/google/firebase/database/core/ServerValues.java new file mode 100644 index 000000000..72da630be --- /dev/null +++ b/src/main/java/com/google/firebase/database/core/ServerValues.java @@ -0,0 +1,104 @@ +package com.google.firebase.database.core; + +import com.google.firebase.database.snapshot.ChildKey; +import com.google.firebase.database.snapshot.ChildrenNode; +import com.google.firebase.database.snapshot.Node; +import com.google.firebase.database.snapshot.NodeUtilities; +import com.google.firebase.database.snapshot.PriorityUtilities; +import com.google.firebase.database.utilities.Clock; +import java.util.HashMap; +import java.util.Map; + +/** + * User: robertdimarco Date: 6/5/13 Time: 11:15 AM + */ +@SuppressWarnings("rawtypes") +public class ServerValues { + + public static final String NAME_SUBKEY_SERVERVALUE = ".sv"; + + public static Map generateServerValues(Clock clock) { + Map values = new HashMap<>(); + values.put("timestamp", clock.millis()); + return values; + } + + public static Object resolveDeferredValue(Object value, Map serverValues) { + if (value instanceof Map) { + Map mapValue = (Map) value; + if (mapValue.containsKey(NAME_SUBKEY_SERVERVALUE)) { + String serverValueKey = (String) mapValue.get(NAME_SUBKEY_SERVERVALUE); + if (serverValues.containsKey(serverValueKey)) { + return serverValues.get(serverValueKey); + } + } + } + return value; + } + + public static SparseSnapshotTree resolveDeferredValueTree( + SparseSnapshotTree tree, final Map serverValues) { + final SparseSnapshotTree resolvedTree = new SparseSnapshotTree(); + tree.forEachTree( + new Path(""), + new SparseSnapshotTree.SparseSnapshotTreeVisitor() { + @Override + public void visitTree(Path prefixPath, Node tree) { + resolvedTree.remember(prefixPath, resolveDeferredValueSnapshot(tree, serverValues)); + } + }); + return resolvedTree; + } + + public static Node resolveDeferredValueSnapshot( + Node data, final Map serverValues) { + Object priorityVal = data.getPriority().getValue(); + if (priorityVal instanceof Map) { + Map priorityMapValue = (Map) priorityVal; + if (priorityMapValue.containsKey(NAME_SUBKEY_SERVERVALUE)) { + String serverValueKey = (String) priorityMapValue.get(NAME_SUBKEY_SERVERVALUE); + priorityVal = serverValues.get(serverValueKey); + } + } + Node priority = PriorityUtilities.parsePriority(priorityVal); + + if (data.isLeafNode()) { + Object value = resolveDeferredValue(data.getValue(), serverValues); + if (!value.equals(data.getValue()) || !priority.equals(data.getPriority())) { + return NodeUtilities.NodeFromJSON(value, priority); + } + return data; + } else if (data.isEmpty()) { + return data; + } else { + ChildrenNode childNode = (ChildrenNode) data; + final SnapshotHolder holder = new SnapshotHolder(childNode); + childNode.forEachChild( + new ChildrenNode.ChildVisitor() { + @Override + public void visitChild(ChildKey name, Node child) { + Node newChildNode = resolveDeferredValueSnapshot(child, serverValues); + if (newChildNode != child) { + holder.update(new Path(name.asString()), newChildNode); + } + } + }); + if (!holder.getRootNode().getPriority().equals(priority)) { + return holder.getRootNode().updatePriority(priority); + } else { + return holder.getRootNode(); + } + } + } + + public static CompoundWrite resolveDeferredValueMerge( + CompoundWrite merge, final Map serverValues) { + CompoundWrite write = CompoundWrite.emptyWrite(); + for (Map.Entry entry : merge) { + write = + write.addWrite( + entry.getKey(), resolveDeferredValueSnapshot(entry.getValue(), serverValues)); + } + return write; + } +} diff --git a/src/main/java/com/google/firebase/database/core/SnapshotHolder.java b/src/main/java/com/google/firebase/database/core/SnapshotHolder.java new file mode 100644 index 000000000..c43647659 --- /dev/null +++ b/src/main/java/com/google/firebase/database/core/SnapshotHolder.java @@ -0,0 +1,32 @@ +package com.google.firebase.database.core; + +import com.google.firebase.database.snapshot.EmptyNode; +import com.google.firebase.database.snapshot.Node; + +/** + * User: greg Date: 5/16/13 Time: 4:11 PM + */ +public class SnapshotHolder { + + private Node rootNode; + + SnapshotHolder() { + rootNode = EmptyNode.Empty(); + } + + public SnapshotHolder(Node node) { + rootNode = node; + } + + public Node getRootNode() { + return rootNode; + } + + public Node getNode(Path path) { + return rootNode.getChild(path); + } + + public void update(Path path, Node node) { + rootNode = rootNode.updateChild(path, node); + } +} diff --git a/src/main/java/com/google/firebase/database/core/SparseSnapshotTree.java b/src/main/java/com/google/firebase/database/core/SparseSnapshotTree.java new file mode 100644 index 000000000..950b6ab6d --- /dev/null +++ b/src/main/java/com/google/firebase/database/core/SparseSnapshotTree.java @@ -0,0 +1,124 @@ +package com.google.firebase.database.core; + +import com.google.firebase.database.snapshot.ChildKey; +import com.google.firebase.database.snapshot.ChildrenNode; +import com.google.firebase.database.snapshot.Node; +import java.util.HashMap; +import java.util.Map; + +/** + * User: greg Date: 5/20/13 Time: 11:54 AM + */ +class SparseSnapshotTree { + + private Node value; + private Map children; + + public SparseSnapshotTree() { + this.value = null; + this.children = null; + } + + public interface SparseSnapshotTreeVisitor { + + void visitTree(Path prefixPath, Node tree); + } + + public interface SparseSnapshotChildVisitor { + + void visitChild(ChildKey key, SparseSnapshotTree tree); + } + + public void remember(Path path, Node data) { + if (path.isEmpty()) { + value = data; + children = null; + } else if (value != null) { + value = value.updateChild(path, data); + } else { + if (children == null) { + children = new HashMap<>(); + } + + ChildKey childKey = path.getFront(); + if (!children.containsKey(childKey)) { + children.put(childKey, new SparseSnapshotTree()); + } + + SparseSnapshotTree child = children.get(childKey); + child.remember(path.popFront(), data); + } + } + + public boolean forget(final Path path) { + if (path.isEmpty()) { + value = null; + children = null; + return true; + } else { + if (value != null) { + if (value.isLeafNode()) { + // non-empty path at leaf. The path leads to nowhere + return false; + } else { + ChildrenNode childrenNode = (ChildrenNode) value; + value = null; + + childrenNode.forEachChild( + new ChildrenNode.ChildVisitor() { + @Override + public void visitChild(ChildKey name, Node child) { + remember(path.child(name), child); + } + }); + + // We've cleared out the value and set the children. Call this method again to hit the + // next case + return forget(path); + } + } else if (children != null) { + ChildKey childKey = path.getFront(); + Path childPath = path.popFront(); + + if (children.containsKey(childKey)) { + SparseSnapshotTree child = children.get(childKey); + boolean safeToRemove = child.forget(childPath); + if (safeToRemove) { + children.remove(childKey); + } + } + + if (children.isEmpty()) { + children = null; + return true; + } else { + return false; + } + } else { + return true; + } + } + } + + public void forEachTree(final Path prefixPath, final SparseSnapshotTreeVisitor visitor) { + if (value != null) { + visitor.visitTree(prefixPath, value); + } else { + this.forEachChild( + new SparseSnapshotChildVisitor() { + @Override + public void visitChild(ChildKey key, SparseSnapshotTree tree) { + tree.forEachTree(prefixPath.child(key), visitor); + } + }); + } + } + + public void forEachChild(SparseSnapshotChildVisitor visitor) { + if (children != null) { + for (Map.Entry entry : children.entrySet()) { + visitor.visitChild(entry.getKey(), entry.getValue()); + } + } + } +} diff --git a/src/main/java/com/google/firebase/database/core/SyncPoint.java b/src/main/java/com/google/firebase/database/core/SyncPoint.java new file mode 100644 index 000000000..fff3c0927 --- /dev/null +++ b/src/main/java/com/google/firebase/database/core/SyncPoint.java @@ -0,0 +1,251 @@ +package com.google.firebase.database.core; + +import com.google.firebase.database.DatabaseError; +import com.google.firebase.database.annotations.NotNull; +import com.google.firebase.database.annotations.Nullable; +import com.google.firebase.database.core.operation.Operation; +import com.google.firebase.database.core.persistence.PersistenceManager; +import com.google.firebase.database.core.view.CacheNode; +import com.google.firebase.database.core.view.Change; +import com.google.firebase.database.core.view.DataEvent; +import com.google.firebase.database.core.view.Event; +import com.google.firebase.database.core.view.QueryParams; +import com.google.firebase.database.core.view.QuerySpec; +import com.google.firebase.database.core.view.View; +import com.google.firebase.database.core.view.ViewCache; +import com.google.firebase.database.snapshot.ChildKey; +import com.google.firebase.database.snapshot.IndexedNode; +import com.google.firebase.database.snapshot.NamedNode; +import com.google.firebase.database.snapshot.Node; +import com.google.firebase.database.utilities.Pair; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * SyncPoint represents a single location in a SyncTree with 1 or more event registrations, meaning + * we need to maintain 1 or more Views at this location to cache server data and raise appropriate + * events for server changes and user writes (set, transaction, update). + * + *

It's responsible for: - Maintaining the set of 1 or more views necessary at this location (a + * SyncPoint with 0 views should be removed). - Proxying user / server operations to the views as + * appropriate (i.e. applyServerOverwrite, applyUserOverwrite, etc.) + */ +public class SyncPoint { + + /** + * The Views being tracked at this location in the tree, stored as a map where the key is a + * QueryParams and the value is the View for that query. + * + *

NOTE: This list will be quite small (usually 1, but perhaps 2 or 3; any more is an odd use + * case). + */ + private final Map views; + + private final PersistenceManager persistenceManager; + + public SyncPoint(PersistenceManager persistenceManager) { + this.views = new HashMap<>(); + this.persistenceManager = persistenceManager; + } + + public boolean isEmpty() { + return this.views.isEmpty(); + } + + private List applyOperationToView( + View view, Operation operation, WriteTreeRef writes, Node optCompleteServerCache) { + View.OperationResult result = view.applyOperation(operation, writes, optCompleteServerCache); + // Not a default query, track active children + if (!view.getQuery().loadsAllData()) { + Set removed = new HashSet<>(); + Set added = new HashSet<>(); + for (Change change : result.changes) { + Event.EventType type = change.getEventType(); + if (type == Event.EventType.CHILD_ADDED) { + added.add(change.getChildKey()); + } else if (type == Event.EventType.CHILD_REMOVED) { + removed.add(change.getChildKey()); + } + } + if (!added.isEmpty() || !removed.isEmpty()) { + this.persistenceManager.updateTrackedQueryKeys(view.getQuery(), added, removed); + } + } + return result.events; + } + + public List applyOperation( + Operation operation, WriteTreeRef writesCache, Node optCompleteServerCache) { + QueryParams queryParams = operation.getSource().getQueryParams(); + if (queryParams != null) { + View view = this.views.get(queryParams); + assert view != null; + return applyOperationToView(view, operation, writesCache, optCompleteServerCache); + } else { + List events = new ArrayList<>(); + for (Map.Entry entry : this.views.entrySet()) { + View view = entry.getValue(); + events.addAll(applyOperationToView(view, operation, writesCache, optCompleteServerCache)); + } + return events; + } + } + + /** + * Add an event callback for the specified query. + */ + public List addEventRegistration( + @NotNull EventRegistration eventRegistration, + WriteTreeRef writesCache, + CacheNode serverCache) { + QuerySpec query = eventRegistration.getQuerySpec(); + View view = this.views.get(query.getParams()); + if (view == null) { + // TODO: make writesCache take flag for complete server node + Node eventCache = + writesCache.calcCompleteEventCache( + serverCache.isFullyInitialized() ? serverCache.getNode() : null); + boolean eventCacheComplete; + if (eventCache != null) { + eventCacheComplete = true; + } else { + eventCache = writesCache.calcCompleteEventChildren(serverCache.getNode()); + eventCacheComplete = false; + } + IndexedNode indexed = IndexedNode.from(eventCache, query.getIndex()); + ViewCache viewCache = + new ViewCache(new CacheNode(indexed, eventCacheComplete, false), serverCache); + view = new View(query, viewCache); + // If this is a non-default query we need to tell persistence our current view of the data + if (!query.loadsAllData()) { + Set allChildren = new HashSet<>(); + for (NamedNode node : view.getEventCache()) { + allChildren.add(node.getName()); + } + this.persistenceManager.setTrackedQueryKeys(query, allChildren); + } + this.views.put(query.getParams(), view); + } + + // This is guaranteed to exist now, we just created anything that was missing + view.addEventRegistration(eventRegistration); + return view.getInitialEvents(eventRegistration); + } + + /** + * Remove event callback(s). Return cancelEvents if a cancelError is specified. + * + *

If query is the default query, we'll check all views for the specified eventRegistration. If + * eventRegistration is null, we'll remove all callbacks for the specified view(s). + * + * @param {!fb.api.Query} query + * @param {?fb.core.view.EventRegistration} eventRegistration If null, remove all callbacks. + * @param {Error=} cancelError If a cancelError is provided, appropriate cancel events will be + * returned. + * @return {{removed:!Array., events:!Array.}} removed queries + * and any cancel events + */ + public Pair, List> removeEventRegistration( + @NotNull QuerySpec query, + @Nullable EventRegistration eventRegistration, + @Nullable DatabaseError cancelError) { + List removed = new ArrayList<>(); + List cancelEvents = new ArrayList<>(); + boolean hadCompleteView = this.hasCompleteView(); + if (query.isDefault()) { + // When you do ref.off(...), we search all views for the registration to remove. + Iterator> iterator = this.views.entrySet().iterator(); + while (iterator.hasNext()) { + Map.Entry entry = iterator.next(); + View view = entry.getValue(); + cancelEvents.addAll(view.removeEventRegistration(eventRegistration, cancelError)); + if (view.isEmpty()) { + iterator.remove(); + + // We'll deal with complete views later. + if (!view.getQuery().loadsAllData()) { + removed.add(view.getQuery()); + } + } + } + } else { + // remove the callback from the specific view. + View view = this.views.get(query.getParams()); + if (view != null) { + cancelEvents.addAll(view.removeEventRegistration(eventRegistration, cancelError)); + if (view.isEmpty()) { + this.views.remove(query.getParams()); + + // We'll deal with complete views later. + if (!view.getQuery().loadsAllData()) { + removed.add(view.getQuery()); + } + } + } + } + + if (hadCompleteView && !this.hasCompleteView()) { + // We removed our last complete view. + removed.add(QuerySpec.defaultQueryAtPath(query.getPath())); + } + return new Pair<>(removed, cancelEvents); + } + + public List getQueryViews() { + List views = new ArrayList<>(); + for (Map.Entry entry : this.views.entrySet()) { + View view = entry.getValue(); + if (!view.getQuery().loadsAllData()) { + views.add(view); + } + } + return views; + } + + public Node getCompleteServerCache(Path path) { + for (View view : this.views.values()) { + if (view.getCompleteServerCache(path) != null) { + return view.getCompleteServerCache(path); + } + } + return null; + } + + public View viewForQuery(QuerySpec query) { + // TODO: iOS doesn't have this loadsAllData() case and I'm not sure it makes sense... but + // leaving for now. + if (query.loadsAllData()) { + return this.getCompleteView(); + } else { + return this.views.get(query.getParams()); + } + } + + public boolean viewExistsForQuery(QuerySpec query) { + return this.viewForQuery(query) != null; + } + + public boolean hasCompleteView() { + return this.getCompleteView() != null; + } + + public View getCompleteView() { + for (Map.Entry entry : this.views.entrySet()) { + View view = entry.getValue(); + if (view.getQuery().loadsAllData()) { + return view; + } + } + return null; + } + + // Package private for testing purposes only + Map getViews() { + return views; + } +} diff --git a/src/main/java/com/google/firebase/database/core/SyncTree.java b/src/main/java/com/google/firebase/database/core/SyncTree.java new file mode 100644 index 000000000..0e77a9d2b --- /dev/null +++ b/src/main/java/com/google/firebase/database/core/SyncTree.java @@ -0,0 +1,1006 @@ +package com.google.firebase.database.core; + +import static com.google.firebase.database.utilities.Utilities.hardAssert; + +import com.google.firebase.database.DatabaseError; +import com.google.firebase.database.annotations.NotNull; +import com.google.firebase.database.annotations.Nullable; +import com.google.firebase.database.collection.LLRBNode; +import com.google.firebase.database.connection.ListenHashProvider; +import com.google.firebase.database.core.operation.AckUserWrite; +import com.google.firebase.database.core.operation.ListenComplete; +import com.google.firebase.database.core.operation.Merge; +import com.google.firebase.database.core.operation.Operation; +import com.google.firebase.database.core.operation.OperationSource; +import com.google.firebase.database.core.operation.Overwrite; +import com.google.firebase.database.core.persistence.PersistenceManager; +import com.google.firebase.database.core.utilities.ImmutableTree; +import com.google.firebase.database.core.view.CacheNode; +import com.google.firebase.database.core.view.Change; +import com.google.firebase.database.core.view.DataEvent; +import com.google.firebase.database.core.view.Event; +import com.google.firebase.database.core.view.QuerySpec; +import com.google.firebase.database.core.view.View; +import com.google.firebase.database.logging.LogWrapper; +import com.google.firebase.database.snapshot.ChildKey; +import com.google.firebase.database.snapshot.CompoundHash; +import com.google.firebase.database.snapshot.EmptyNode; +import com.google.firebase.database.snapshot.IndexedNode; +import com.google.firebase.database.snapshot.NamedNode; +import com.google.firebase.database.snapshot.Node; +import com.google.firebase.database.snapshot.RangeMerge; +import com.google.firebase.database.utilities.Clock; +import com.google.firebase.database.utilities.NodeSizeEstimator; +import com.google.firebase.database.utilities.Pair; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.Callable; + +/** + * SyncTree is the central class for managing event callback registration, data caching, views + * (query processing), and event generation. There are typically two SyncTree instances for each + * Repo, one for the normal Firebase data, and one for the .info data. + * + *

It has a number of responsibilities, including: - Tracking all user event callbacks + * (registered via addEventRegistration() and removeEventRegistration()). - Applying and caching + * data changes for user set(), transaction(), and update() calls (applyUserOverwrite(), + * applyUserMerge()). - Applying and caching data changes for server data changes + * (applyServerOverwrite(), applyServerMerge()). - Generating user-facing events for server and user + * changes (all of the apply* methods return the set of events that need to be raised as a result). + * - Maintaining the appropriate set of server listens to ensure we are always subscribed to the + * correct set of paths and queries to satisfy the current set of user event callbacks (listens are + * started/stopped using the provided listenProvider). + * + *

NOTE: Although SyncTree tracks event callbacks and calculates events to raise, the actual + * events are returned to the caller rather than raised synchronously. + */ +public class SyncTree { + + // Size after which we start including the compound hash + private static final long SIZE_THRESHOLD_FOR_COMPOUND_HASH = 1024; + + /** */ + public interface CompletionListener { + + List onListenComplete(DatabaseError error); + } + + /** */ + public interface ListenProvider { + + void startListening( + QuerySpec query, + Tag tag, + final ListenHashProvider hash, + final CompletionListener onListenComplete); + + void stopListening(QuerySpec query, Tag tag); + } + + private class ListenContainer implements ListenHashProvider, CompletionListener { + + private final View view; + private final Tag tag; + + public ListenContainer(View view) { + this.view = view; + this.tag = SyncTree.this.tagForQuery(view.getQuery()); + } + + @Override + public com.google.firebase.database.connection.CompoundHash getCompoundHash() { + CompoundHash hash = CompoundHash.fromNode(view.getServerCache()); + List pathPosts = hash.getPosts(); + List> posts = new ArrayList<>(pathPosts.size()); + for (Path path : pathPosts) { + posts.add(path.asList()); + } + return new com.google.firebase.database.connection.CompoundHash(posts, hash.getHashes()); + } + + @Override + public String getSimpleHash() { + return view.getServerCache().getHash(); + } + + @Override + public boolean shouldIncludeCompoundHash() { + return NodeSizeEstimator.estimateSerializedNodeSize(view.getServerCache()) + > SIZE_THRESHOLD_FOR_COMPOUND_HASH; + } + + @Override + public List onListenComplete(DatabaseError error) { + if (error == null) { + QuerySpec query = this.view.getQuery(); + if (tag != null) { + return SyncTree.this.applyTaggedListenComplete(tag); + } else { + return SyncTree.this.applyListenComplete(query.getPath()); + } + } else { + logger.warn("Listen at " + view.getQuery().getPath() + " failed: " + error.toString()); + + // If a listen failed, kill all of the listeners here, not just the one that triggered the + // error. Note that this may need to be scoped to just this listener if we change + // permissions on filtered children + return SyncTree.this.removeAllEventRegistrations(view.getQuery(), error); + } + } + } + + /** + * Tree of SyncPoints. There's a SyncPoint at any location that has 1 or more views. + */ + private ImmutableTree syncPointTree; + + /** + * A tree of all pending user writes (user-initiated set()'s, transaction()'s, update()'s, etc.). + */ + private final WriteTree pendingWriteTree; + + private final Map tagToQueryMap; + private final Map queryToTagMap; + private final Set keepSyncedQueries; + private final ListenProvider listenProvider; + private final PersistenceManager persistenceManager; + private final LogWrapper logger; + + public SyncTree( + Context context, PersistenceManager persistenceManager, ListenProvider listenProvider) { + this.syncPointTree = ImmutableTree.emptyInstance(); + this.pendingWriteTree = new WriteTree(); + this.tagToQueryMap = new HashMap<>(); + this.queryToTagMap = new HashMap<>(); + this.keepSyncedQueries = new HashSet<>(); + this.listenProvider = listenProvider; + this.persistenceManager = persistenceManager; + this.logger = context.getLogger("SyncTree"); + } + + public boolean isEmpty() { + return this.syncPointTree.isEmpty(); + } + + /** + * Apply the data changes for a user-generated set() or transaction() call. + */ + public List applyUserOverwrite( + final Path path, + final Node newDataUnresolved, + final Node newData, + final long writeId, + final boolean visible, + final boolean persist) { + hardAssert(visible || !persist, "We shouldn't be persisting non-visible writes."); + return persistenceManager.runInTransaction( + new Callable>() { + @Override + public List call() { + if (persist) { + persistenceManager.saveUserOverwrite(path, newDataUnresolved, writeId); + } + + pendingWriteTree.addOverwrite(path, newData, writeId, visible); + if (!visible) { + return Collections.emptyList(); + } else { + return applyOperationToSyncPoints(new Overwrite(OperationSource.USER, path, newData)); + } + } + }); + } + + /** + * Apply the data from a user-generated update() call. + */ + public List applyUserMerge( + final Path path, + final CompoundWrite unresolvedChildren, + final CompoundWrite children, + final long writeId, + final boolean persist) { + return this.persistenceManager.runInTransaction( + new Callable>() { + @Override + public List call() throws Exception { + if (persist) { + persistenceManager.saveUserMerge(path, unresolvedChildren, writeId); + } + pendingWriteTree.addMerge(path, children, writeId); + + return applyOperationToSyncPoints(new Merge(OperationSource.USER, path, children)); + } + }); + } + + /** + * Acknowledge a pending user write that was previously registered with applyUserOverwrite() or + * applyUserMerge(). + */ + // TODO[persistence]: Taking a serverClock here is awkward, but server values are awkward. :-( + public List ackUserWrite( + final long writeId, final boolean revert, final boolean persist, final Clock serverClock) { + return this.persistenceManager.runInTransaction( + new Callable>() { + @Override + public List call() { + if (persist) { + persistenceManager.removeUserWrite(writeId); + } + UserWriteRecord write = pendingWriteTree.getWrite(writeId); + boolean needToReevaluate = pendingWriteTree.removeWrite(writeId); + if (write.isVisible()) { + if (!revert) { + Map serverValues = ServerValues.generateServerValues(serverClock); + if (write.isOverwrite()) { + Node resolvedNode = + ServerValues.resolveDeferredValueSnapshot(write.getOverwrite(), serverValues); + persistenceManager.applyUserWriteToServerCache(write.getPath(), resolvedNode); + } else { + CompoundWrite resolvedMerge = + ServerValues.resolveDeferredValueMerge(write.getMerge(), serverValues); + persistenceManager.applyUserWriteToServerCache(write.getPath(), resolvedMerge); + } + } + } + if (!needToReevaluate) { + return Collections.emptyList(); + } else { + ImmutableTree affectedTree = ImmutableTree.emptyInstance(); + if (write.isOverwrite()) { + affectedTree = affectedTree.set(Path.getEmptyPath(), true); + } else { + for (Map.Entry entry : write.getMerge()) { + affectedTree = affectedTree.set(entry.getKey(), true); + } + } + return applyOperationToSyncPoints( + new AckUserWrite(write.getPath(), affectedTree, revert)); + } + } + }); + } + + /** + * Removes all local writes + */ + public List removeAllWrites() { + return this.persistenceManager.runInTransaction( + new Callable>() { + @Override + public List call() throws Exception { + persistenceManager.removeAllUserWrites(); + List purgedWrites = pendingWriteTree.purgeAllWrites(); + if (purgedWrites.isEmpty()) { + return Collections.emptyList(); + } else { + ImmutableTree affectedTree = new ImmutableTree<>(true); + return applyOperationToSyncPoints( + new AckUserWrite(Path.getEmptyPath(), affectedTree, /*revert=*/ true)); + } + } + }); + } + + /** + * Apply new server data for the specified path. + */ + public List applyServerOverwrite(final Path path, final Node newData) { + return persistenceManager.runInTransaction( + new Callable>() { + @Override + public List call() { + persistenceManager.updateServerCache(QuerySpec.defaultQueryAtPath(path), newData); + return applyOperationToSyncPoints(new Overwrite(OperationSource.SERVER, path, newData)); + } + }); + } + + /** + * Apply new server data to be merged in at the specified path. + */ + public List applyServerMerge( + final Path path, final Map changedChildren) { + return persistenceManager.runInTransaction( + new Callable>() { + @Override + public List call() { + CompoundWrite merge = CompoundWrite.fromPathMerge(changedChildren); + persistenceManager.updateServerCache(path, merge); + return applyOperationToSyncPoints(new Merge(OperationSource.SERVER, path, merge)); + } + }); + } + + /** + * Apply a range merge + */ + public List applyServerRangeMerges( + final Path path, List rangeMerges) { + SyncPoint syncPoint = syncPointTree.get(path); + if (syncPoint == null) { + // Removed view, so it's safe to just ignore this update + return Collections.emptyList(); + } else { + // This could be for any "complete" (unfiltered) view, and if there is more than one complete + // view, they should each have the same cache so it doesn't matter which one we use. + View view = syncPoint.getCompleteView(); + if (view != null) { + Node serverNode = view.getServerCache(); + for (RangeMerge merge : rangeMerges) { + serverNode = merge.applyTo(serverNode); + } + return applyServerOverwrite(path, serverNode); + } else { + // There doesn't exist a view for this update, so it was removed and it's safe to just + // ignore this range merge + return Collections.emptyList(); + } + } + } + + public List applyTaggedRangeMerges( + Path path, List rangeMerges, Tag tag) { + QuerySpec query = queryForTag(tag); + if (query != null) { + assert path.equals(query.getPath()); + SyncPoint syncPoint = syncPointTree.get(query.getPath()); + assert syncPoint != null : "Missing sync point for query tag that we're tracking"; + View view = syncPoint.viewForQuery(query); + assert view != null : "Missing view for query tag that we're tracking"; + Node serverNode = view.getServerCache(); + for (RangeMerge merge : rangeMerges) { + serverNode = merge.applyTo(serverNode); + } + return this.applyTaggedQueryOverwrite(path, serverNode, tag); + } else { + // We've already removed the query. No big deal, ignore the update + return Collections.emptyList(); + } + } + + /** + * Apply a listen complete to a path + */ + public List applyListenComplete(final Path path) { + return persistenceManager.runInTransaction( + new Callable>() { + @Override + public List call() { + persistenceManager.setQueryComplete(QuerySpec.defaultQueryAtPath(path)); + return applyOperationToSyncPoints(new ListenComplete(OperationSource.SERVER, path)); + } + }); + } + + /** + * Apply a listen complete to a path + */ + public List applyTaggedListenComplete(final Tag tag) { + return persistenceManager.runInTransaction( + new Callable>() { + @Override + public List call() { + QuerySpec query = queryForTag(tag); + if (query != null) { + persistenceManager.setQueryComplete(query); + Operation op = + new ListenComplete( + OperationSource.forServerTaggedQuery(query.getParams()), Path.getEmptyPath()); + return applyTaggedOperation(query, op); + } else { + // We've already removed the query. No big deal, ignore the update + return Collections.emptyList(); + } + } + }); + } + + private List applyTaggedOperation(QuerySpec query, Operation operation) { + Path queryPath = query.getPath(); + SyncPoint syncPoint = syncPointTree.get(queryPath); + assert syncPoint != null : "Missing sync point for query tag that we're tracking"; + WriteTreeRef writesCache = pendingWriteTree.childWrites(queryPath); + return syncPoint.applyOperation(operation, writesCache, /*serverCache*/ null); + } + + /** + * Apply new server data for the specified tagged query. + */ + public List applyTaggedQueryOverwrite( + final Path path, final Node snap, final Tag tag) { + return persistenceManager.runInTransaction( + new Callable>() { + @Override + public List call() { + QuerySpec query = queryForTag(tag); + if (query != null) { + Path relativePath = Path.getRelative(query.getPath(), path); + QuerySpec queryToOverwrite = + relativePath.isEmpty() ? query : QuerySpec.defaultQueryAtPath(path); + persistenceManager.updateServerCache(queryToOverwrite, snap); + Operation op = + new Overwrite( + OperationSource.forServerTaggedQuery(query.getParams()), relativePath, snap); + return applyTaggedOperation(query, op); + } else { + // Query must have been removed already + return Collections.emptyList(); + } + } + }); + } + + /** + * Apply server data to be merged in for the specified tagged query. + */ + public List applyTaggedQueryMerge( + final Path path, final Map changedChildren, final Tag tag) { + return persistenceManager.runInTransaction( + new Callable>() { + @Override + public List call() { + QuerySpec query = queryForTag(tag); + if (query != null) { + Path relativePath = Path.getRelative(query.getPath(), path); + CompoundWrite merge = CompoundWrite.fromPathMerge(changedChildren); + persistenceManager.updateServerCache(path, merge); + Operation op = + new Merge( + OperationSource.forServerTaggedQuery(query.getParams()), relativePath, merge); + return applyTaggedOperation(query, op); + } else { + // We've already removed the query. No big deal, ignore the update + return Collections.emptyList(); + } + } + }); + } + + /** + * Add an event callback for the specified query. + */ + public List addEventRegistration( + @NotNull final EventRegistration eventRegistration) { + return persistenceManager.runInTransaction( + new Callable>() { + @Override + public List call() { + final QuerySpec query = eventRegistration.getQuerySpec(); + Path path = query.getPath(); + + Node serverCacheNode = null; + boolean foundAncestorDefaultView = false; + // Any covering writes will necessarily be at the root, so really all we need to find is + // the server cache. Consider optimizing this once there's a better understanding of + // what actual behavior will be. + // for (Map.Entry entry: views.entrySet()) { + { + ImmutableTree tree = syncPointTree; + Path currentPath = path; + while (!tree.isEmpty()) { + SyncPoint currentSyncPoint = tree.getValue(); + if (currentSyncPoint != null) { + serverCacheNode = + serverCacheNode != null + ? serverCacheNode + : currentSyncPoint.getCompleteServerCache(currentPath); + foundAncestorDefaultView = + foundAncestorDefaultView || currentSyncPoint.hasCompleteView(); + } + ChildKey front = + currentPath.isEmpty() ? ChildKey.fromString("") : currentPath.getFront(); + tree = tree.getChild(front); + currentPath = currentPath.popFront(); + } + } + + SyncPoint syncPoint = syncPointTree.get(path); + if (syncPoint == null) { + syncPoint = new SyncPoint(persistenceManager); + syncPointTree = syncPointTree.set(path, syncPoint); + } else { + foundAncestorDefaultView = foundAncestorDefaultView || syncPoint.hasCompleteView(); + serverCacheNode = + serverCacheNode != null + ? serverCacheNode + : syncPoint.getCompleteServerCache(Path.getEmptyPath()); + } + + persistenceManager.setQueryActive(query); + + CacheNode serverCache; + if (serverCacheNode != null) { + serverCache = + new CacheNode(IndexedNode.from(serverCacheNode, query.getIndex()), true, false); + } else { + // Hit persistence + CacheNode persistentServerCache = persistenceManager.serverCache(query); + if (persistentServerCache.isFullyInitialized()) { + serverCache = persistentServerCache; + } else { + serverCacheNode = EmptyNode.Empty(); + ImmutableTree subtree = syncPointTree.subtree(path); + for (Map.Entry> child : subtree.getChildren()) { + SyncPoint childSyncPoint = child.getValue().getValue(); + if (childSyncPoint != null) { + Node completeCache = childSyncPoint.getCompleteServerCache(Path.getEmptyPath()); + if (completeCache != null) { + serverCacheNode = + serverCacheNode.updateImmediateChild(child.getKey(), completeCache); + } + } + } + // Fill the node with any available children we have + for (NamedNode child : persistentServerCache.getNode()) { + if (!serverCacheNode.hasChild(child.getName())) { + serverCacheNode = + serverCacheNode.updateImmediateChild(child.getName(), child.getNode()); + } + } + serverCache = + new CacheNode( + IndexedNode.from(serverCacheNode, query.getIndex()), false, false); + } + } + + boolean viewAlreadyExists = syncPoint.viewExistsForQuery(query); + if (!viewAlreadyExists && !query.loadsAllData()) { + // We need to track a tag for this query + assert !queryToTagMap.containsKey(query) : "View does not exist but we have a tag"; + Tag tag = getNextQueryTag(); + queryToTagMap.put(query, tag); + tagToQueryMap.put(tag, query); + } + WriteTreeRef writesCache = pendingWriteTree.childWrites(path); + List events = + syncPoint.addEventRegistration(eventRegistration, writesCache, serverCache); + if (!viewAlreadyExists && !foundAncestorDefaultView) { + View view = syncPoint.viewForQuery(query); + setupListener(query, view); + } + return events; + } + }); + } + + /** + * Remove event callback(s). + * + *

If query is the default query, we'll check all queries for the specified eventRegistration. + */ + public List removeEventRegistration(@NotNull EventRegistration eventRegistration) { + return this.removeEventRegistration(eventRegistration.getQuerySpec(), eventRegistration, null); + } + + /** + * Remove all event callback(s). + * + *

If query is the default query, we'll check all queries for the specified eventRegistration. + */ + public List removeAllEventRegistrations( + @NotNull QuerySpec query, @NotNull DatabaseError error) { + return this.removeEventRegistration(query, null, error); + } + + private List removeEventRegistration( + final @NotNull QuerySpec query, + final @Nullable EventRegistration eventRegistration, + final @Nullable DatabaseError cancelError) { + return persistenceManager.runInTransaction( + new Callable>() { + @Override + public List call() { + // Find the syncPoint first. Then deal with whether or not it has matching listeners + Path path = query.getPath(); + SyncPoint maybeSyncPoint = syncPointTree.get(path); + List cancelEvents = new ArrayList<>(); + // A removal on a default query affects all queries at that location. A removal on an + // indexed query, even one without other query constraints, does *not* affect all + // queries at that location. So this check must be for 'default', and not + // loadsAllData(). + if (maybeSyncPoint != null + && (query.isDefault() || maybeSyncPoint.viewExistsForQuery(query))) { + // @type {{removed: !Array., events: !Array.}} + + Pair, List> removedAndEvents = + maybeSyncPoint.removeEventRegistration(query, eventRegistration, cancelError); + if (maybeSyncPoint.isEmpty()) { + syncPointTree = syncPointTree.remove(path); + } + List removed = removedAndEvents.getFirst(); + cancelEvents = removedAndEvents.getSecond(); + // We may have just removed one of many listeners and can short-circuit this whole + // process. We may also not have removed a default listener, in which case all of the + // descendant listeners should already be properly set up. + // + // Since indexed queries can shadow if they don't have other query constraints, check + // for loadsAllData(), instead of isDefault(). + boolean removingDefault = false; + for (QuerySpec queryRemoved : removed) { + persistenceManager.setQueryInactive(query); + removingDefault = removingDefault || queryRemoved.loadsAllData(); + } + ImmutableTree currentTree = syncPointTree; + boolean covered = + currentTree.getValue() != null && currentTree.getValue().hasCompleteView(); + for (ChildKey component : path) { + currentTree = currentTree.getChild(component); + covered = + covered + || (currentTree.getValue() != null + && currentTree.getValue().hasCompleteView()); + if (covered || currentTree.isEmpty()) { + break; + } + } + + if (removingDefault && !covered) { + ImmutableTree subtree = syncPointTree.subtree(path); + // There are potentially child listeners. Determine what if any listens we need to + // send before executing the removal. + if (!subtree.isEmpty()) { + // We need to fold over our subtree and collect the listeners to send + List newViews = collectDistinctViewsForSubTree(subtree); + + // Ok, we've collected all the listens we need. Set them up. + for (View view : newViews) { + ListenContainer container = new ListenContainer(view); + QuerySpec newQuery = view.getQuery(); + listenProvider.startListening( + queryForListening(newQuery), container.tag, container, container); + } + } else { + // There's nothing below us, so nothing we need to start listening on + } + } + // If we removed anything and we're not covered by a higher up listen, we need to stop + // listening on this query. The above block has us covered in terms of making sure + // we're set up on listens lower in the tree. + // Also, note that if we have a cancelError, it's already been removed at the provider + // level. + if (!covered && !removed.isEmpty() && cancelError == null) { + // If we removed a default, then we weren't listening on any of the other queries + // here. Just cancel the one default. Otherwise, we need to iterate through and + // cancel each individual query + if (removingDefault) { + listenProvider.stopListening(queryForListening(query), null); + } else { + for (QuerySpec queryToRemove : removed) { + Tag tag = tagForQuery(queryToRemove); + assert tag != null; + listenProvider.stopListening(queryForListening(queryToRemove), tag); + } + } + } + // Now, clear all of the tags we're tracking for the removed listens + removeTags(removed); + } else { + // No-op, this listener must've been already removed + } + return cancelEvents; + } + }); + } + + private static class KeepSyncedEventRegistration extends EventRegistration { + + private QuerySpec spec; + + public KeepSyncedEventRegistration(@NotNull QuerySpec spec) { + this.spec = spec; + } + + @Override + public boolean respondsTo(Event.EventType eventType) { + return false; + } + + @Override + public DataEvent createEvent(Change change, QuerySpec query) { + return null; + } + + @Override + public void fireEvent(DataEvent dataEvent) { + } + + @Override + public void fireCancelEvent(DatabaseError error) { + } + + @Override + public EventRegistration clone(QuerySpec newQuery) { + return new KeepSyncedEventRegistration(newQuery); + } + + @Override + public boolean isSameListener(EventRegistration other) { + return other instanceof KeepSyncedEventRegistration; + } + + @NotNull + @Override + public QuerySpec getQuerySpec() { + return spec; + } + + @Override + public boolean equals(Object other) { + return (other instanceof KeepSyncedEventRegistration + && ((KeepSyncedEventRegistration) other).spec.equals(spec)); + } + + @Override + public int hashCode() { + return spec.hashCode(); + } + } + + public void keepSynced(final QuerySpec query, final boolean keep) { + if (keep && !keepSyncedQueries.contains(query)) { + // TODO[persistence]: Find better / more efficient way to do keep-synced listeners. + addEventRegistration(new KeepSyncedEventRegistration(query)); + keepSyncedQueries.add(query); + } else if (!keep && keepSyncedQueries.contains(query)) { + removeEventRegistration(new KeepSyncedEventRegistration(query)); + keepSyncedQueries.remove(query); + } + } + + /** + * This collapses multiple unfiltered views into a single view, since we only need a single + * listener for them. + */ + private List collectDistinctViewsForSubTree(ImmutableTree subtree) { + ArrayList accumulator = new ArrayList<>(); + collectDistinctViewsForSubTree(subtree, accumulator); + return accumulator; + } + + private void collectDistinctViewsForSubTree( + ImmutableTree subtree, List accumulator) { + SyncPoint maybeSyncPoint = subtree.getValue(); + if (maybeSyncPoint != null && maybeSyncPoint.hasCompleteView()) { + accumulator.add(maybeSyncPoint.getCompleteView()); + } else { + if (maybeSyncPoint != null) { + accumulator.addAll(maybeSyncPoint.getQueryViews()); + } + for (Map.Entry> entry : subtree.getChildren()) { + collectDistinctViewsForSubTree(entry.getValue(), accumulator); + } + } + } + + private void removeTags(List queries) { + for (QuerySpec removedQuery : queries) { + if (!removedQuery.loadsAllData()) { + // We should have a tag for this + Tag tag = this.tagForQuery(removedQuery); + assert tag != null; + this.queryToTagMap.remove(removedQuery); + this.tagToQueryMap.remove(tag); + } + } + } + + private QuerySpec queryForListening(QuerySpec query) { + if (query.loadsAllData() && !query.isDefault()) { + // We treat queries that load all data as default queries + return QuerySpec.defaultQueryAtPath(query.getPath()); + } else { + return query; + } + } + + /** + * For a given new listen, manage the de-duplication of outstanding subscriptions. + */ + private void setupListener(QuerySpec query, View view) { + Path path = query.getPath(); + Tag tag = this.tagForQuery(query); + ListenContainer container = new ListenContainer(view); + + this.listenProvider.startListening(queryForListening(query), tag, container, container); + + ImmutableTree subtree = this.syncPointTree.subtree(path); + // The root of this subtree has our query. We're here because we definitely need to send a + // listen for that, but we may need to shadow other listens as well. + if (tag != null) { + assert !subtree.getValue().hasCompleteView() + : "If we're adding a query, it shouldn't be shadowed"; + } else { + // Shadow everything at or below this location, this is a default listener. + subtree.foreach( + new ImmutableTree.TreeVisitor() { + @Override + public Void onNodeValue(Path relativePath, SyncPoint maybeChildSyncPoint, Void accum) { + if (!relativePath.isEmpty() && maybeChildSyncPoint.hasCompleteView()) { + QuerySpec query = maybeChildSyncPoint.getCompleteView().getQuery(); + listenProvider.stopListening(queryForListening(query), tagForQuery(query)); + } else { + // No default listener here + for (View syncPointView : maybeChildSyncPoint.getQueryViews()) { + QuerySpec childQuery = syncPointView.getQuery(); + listenProvider.stopListening( + queryForListening(childQuery), tagForQuery(childQuery)); + } + } + return null; + } + }); + } + } + + /** + * Return the query associated with the given tag, if we have one + */ + private QuerySpec queryForTag(Tag tag) { + return this.tagToQueryMap.get(tag); + } + + /** + * Return the tag associated with the given query. + */ + private Tag tagForQuery(QuerySpec query) { + return this.queryToTagMap.get(query); + } + + /** + * Returns a complete cache, if we have one, of the data at a particular path. The location must + * have a listener above it, but as this is only used by transaction code, that should always be + * the case anyways. + * + *

Note: this method will *include* hidden writes from transaction with applyLocally set to + * false. + */ + public Node calcCompleteEventCache(Path path, List writeIdsToExclude) { + ImmutableTree tree = this.syncPointTree; + SyncPoint currentSyncPoint = tree.getValue(); + Node serverCache = null; + Path pathToFollow = path; + Path pathSoFar = Path.getEmptyPath(); + do { + ChildKey front = pathToFollow.getFront(); + pathToFollow = pathToFollow.popFront(); + pathSoFar = pathSoFar.child(front); + Path relativePath = Path.getRelative(pathSoFar, path); + tree = front != null ? tree.getChild(front) : ImmutableTree.emptyInstance(); + currentSyncPoint = tree.getValue(); + if (currentSyncPoint != null) { + serverCache = currentSyncPoint.getCompleteServerCache(relativePath); + } + } while (!pathToFollow.isEmpty() && serverCache == null); + return this.pendingWriteTree.calcCompleteEventCache(path, serverCache, writeIdsToExclude, true); + } + + /** + * Static tracker for next query tag. + */ + private long nextQueryTag = 1L; + + /** + * Static accessor for query tags. + */ + private Tag getNextQueryTag() { + return new Tag(nextQueryTag++); + } + + /** + * A helper method that visits all descendant and ancestor SyncPoints, applying the operation. + * + *

NOTES: - Descendant SyncPoints will be visited first (since we raise events depth-first). + * + *

- We call applyOperation() on each SyncPoint passing three things: 1. A version of the + * Operation that has been made relative to the SyncPoint location. 2. A WriteTreeRef of any + * writes we have cached at the SyncPoint location. 3. A snapshot Node with cached server data, if + * we have it. + * + *

- We concatenate all of the events returned by each SyncPoint and return the result. + */ + private List applyOperationToSyncPoints(Operation operation) { + return this.applyOperationHelper( + operation, + this.syncPointTree, /* serverCache */ + null, + this.pendingWriteTree.childWrites(Path.getEmptyPath())); + } + + /** + * Recursive helper for applyOperationToSyncPoints + */ + private List applyOperationHelper( + Operation operation, + ImmutableTree syncPointTree, + Node serverCache, + WriteTreeRef writesCache) { + if (operation.getPath().isEmpty()) { + return this.applyOperationDescendantsHelper( + operation, syncPointTree, serverCache, writesCache); + } else { + SyncPoint syncPoint = syncPointTree.getValue(); + + // If we don't have cached server data, see if we can get it from this SyncPoint. + if (serverCache == null && syncPoint != null) { + serverCache = syncPoint.getCompleteServerCache(Path.getEmptyPath()); + } + + List events = new ArrayList<>(); + ChildKey childKey = operation.getPath().getFront(); + Operation childOperation = operation.operationForChild(childKey); + ImmutableTree childTree = syncPointTree.getChildren().get(childKey); + if (childTree != null && childOperation != null) { + Node childServerCache = + (serverCache != null) ? serverCache.getImmediateChild(childKey) : null; + WriteTreeRef childWritesCache = writesCache.child(childKey); + events.addAll( + this.applyOperationHelper( + childOperation, childTree, childServerCache, childWritesCache)); + } + + if (syncPoint != null) { + events.addAll(syncPoint.applyOperation(operation, writesCache, serverCache)); + } + + return events; + } + } + + /** + * Recursive helper for applyOperationToSyncPoints + */ + private List applyOperationDescendantsHelper( + final Operation operation, + ImmutableTree syncPointTree, + Node serverCache, + final WriteTreeRef writesCache) { + SyncPoint syncPoint = syncPointTree.getValue(); + + // If we don't have cached server data, see if we can get it from this SyncPoint. + final Node resolvedServerCache; + if (serverCache == null && syncPoint != null) { + resolvedServerCache = syncPoint.getCompleteServerCache(Path.getEmptyPath()); + } else { + resolvedServerCache = serverCache; + } + + final List events = new ArrayList<>(); + syncPointTree + .getChildren() + .inOrderTraversal( + new LLRBNode.NodeVisitor>() { + @Override + public void visitEntry(ChildKey key, ImmutableTree childTree) { + Node childServerCache = null; + if (resolvedServerCache != null) { + childServerCache = resolvedServerCache.getImmediateChild(key); + } + WriteTreeRef childWritesCache = writesCache.child(key); + Operation childOperation = operation.operationForChild(key); + if (childOperation != null) { + events.addAll( + applyOperationDescendantsHelper( + childOperation, childTree, childServerCache, childWritesCache)); + } + } + }); + + if (syncPoint != null) { + events.addAll(syncPoint.applyOperation(operation, writesCache, resolvedServerCache)); + } + + return events; + } + + // Package private for testing purposes only + ImmutableTree getSyncPointTree() { + return syncPointTree; + } +} diff --git a/src/main/java/com/google/firebase/database/core/Tag.java b/src/main/java/com/google/firebase/database/core/Tag.java new file mode 100644 index 000000000..3f6645450 --- /dev/null +++ b/src/main/java/com/google/firebase/database/core/Tag.java @@ -0,0 +1,42 @@ +package com.google.firebase.database.core; + +public class Tag { + + private final long tagNumber; + + public Tag(long tagNumber) { + this.tagNumber = tagNumber; + } + + public long getTagNumber() { + return this.tagNumber; + } + + @Override + public String toString() { + return "Tag{" + "tagNumber=" + tagNumber + '}'; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + + Tag tag = (Tag) o; + + if (tagNumber != tag.tagNumber) { + return false; + } + + return true; + } + + @Override + public int hashCode() { + return (int) (tagNumber ^ (tagNumber >>> 32)); + } +} diff --git a/src/main/java/com/google/firebase/database/core/ThreadBackgroundExecutor.java b/src/main/java/com/google/firebase/database/core/ThreadBackgroundExecutor.java new file mode 100644 index 000000000..ad530141b --- /dev/null +++ b/src/main/java/com/google/firebase/database/core/ThreadBackgroundExecutor.java @@ -0,0 +1,6 @@ +package com.google.firebase.database.core; + +/** */ +public class ThreadBackgroundExecutor { + +} diff --git a/src/main/java/com/google/firebase/database/core/ThreadInitializer.java b/src/main/java/com/google/firebase/database/core/ThreadInitializer.java new file mode 100644 index 000000000..9ff528cc9 --- /dev/null +++ b/src/main/java/com/google/firebase/database/core/ThreadInitializer.java @@ -0,0 +1,30 @@ +package com.google.firebase.database.core; + +import java.lang.Thread.UncaughtExceptionHandler; + +public interface ThreadInitializer { + + ThreadInitializer defaultInstance = + new ThreadInitializer() { + @Override + public void setName(Thread t, String name) { + t.setName(name); + } + + @Override + public void setDaemon(Thread t, boolean isDaemon) { + t.setDaemon(isDaemon); + } + + @Override + public void setUncaughtExceptionHandler(Thread t, UncaughtExceptionHandler handler) { + t.setUncaughtExceptionHandler(handler); + } + }; + + void setName(Thread t, String name); + + void setDaemon(Thread t, boolean isDaemon); + + void setUncaughtExceptionHandler(Thread t, Thread.UncaughtExceptionHandler handler); +} diff --git a/src/main/java/com/google/firebase/database/core/ThreadPoolEventTarget.java b/src/main/java/com/google/firebase/database/core/ThreadPoolEventTarget.java new file mode 100644 index 000000000..0cd2abd3a --- /dev/null +++ b/src/main/java/com/google/firebase/database/core/ThreadPoolEventTarget.java @@ -0,0 +1,71 @@ +package com.google.firebase.database.core; + +import com.google.firebase.internal.Preconditions; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.ThreadFactory; +import java.util.concurrent.ThreadPoolExecutor; +import java.util.concurrent.TimeUnit; + +/** + * ThreadPoolEventTarget is an event target using a configurable threadpool. + */ +class ThreadPoolEventTarget implements EventTarget { + + private final ThreadPoolExecutor executor; + + public ThreadPoolEventTarget( + final ThreadFactory wrappedFactory, final ThreadInitializer threadInitializer) { + int poolSize = 1; + BlockingQueue queue = new LinkedBlockingQueue<>(); + + executor = + new ThreadPoolExecutor( + poolSize, + poolSize, + 3, + TimeUnit.SECONDS, + queue, + new ThreadFactory() { + + @Override + public Thread newThread(Runnable r) { + Thread thread = wrappedFactory.newThread(r); + threadInitializer.setName(thread, "FirebaseDatabaseEventTarget"); + threadInitializer.setDaemon(thread, true); + // TODO: should we set an uncaught exception handler here? Probably want to let exceptions happen... + return thread; + } + }); + } + + public ThreadPoolEventTarget(final ThreadPoolExecutor executor) { + Preconditions.checkNotNull(executor); + this.executor = executor; + } + + @Override + public void postEvent(Runnable r) { + executor.execute(r); + } + + /** + * Our implementation of shutdown is not immediate, it merely lowers the required number of + * threads to 0. Depending on what we set as our timeout on the executor, this will reap the event + * target thread after some amount of time if there's no activity + */ + @Override + public void shutdown() { + executor.setCorePoolSize(0); + } + + /** + * Rather than launching anything, this method will ensure that our executor has at least one + * thread available. This will keep the process alive and launch the thread if it has been reaped. + * If the thread already exists, this is a no-op + */ + @Override + public void restart() { + executor.setCorePoolSize(1); + } +} diff --git a/src/main/java/com/google/firebase/database/core/UserWriteRecord.java b/src/main/java/com/google/firebase/database/core/UserWriteRecord.java new file mode 100644 index 000000000..664202c08 --- /dev/null +++ b/src/main/java/com/google/firebase/database/core/UserWriteRecord.java @@ -0,0 +1,120 @@ +package com.google.firebase.database.core; + +import com.google.firebase.database.snapshot.Node; + +public class UserWriteRecord { + + private final long writeId; + private final Path path; + private final Node overwrite; + private final CompoundWrite merge; + private final boolean visible; + + public UserWriteRecord(long writeId, Path path, Node overwrite, boolean visible) { + this.writeId = writeId; + this.path = path; + this.overwrite = overwrite; + this.merge = null; + this.visible = visible; + } + + public UserWriteRecord(long writeId, Path path, CompoundWrite merge) { + this.writeId = writeId; + this.path = path; + this.overwrite = null; + this.merge = merge; + this.visible = true; + } + + public long getWriteId() { + return writeId; + } + + public Path getPath() { + return path; + } + + public Node getOverwrite() { + if (overwrite == null) { + throw new IllegalArgumentException("Can't access overwrite when write is a merge!"); + } + return overwrite; + } + + public CompoundWrite getMerge() { + if (merge == null) { + throw new IllegalArgumentException("Can't access merge when write is an overwrite!"); + } + return merge; + } + + public boolean isMerge() { + return merge != null; + } + + public boolean isOverwrite() { + return overwrite != null; + } + + public boolean isVisible() { + return visible; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + + UserWriteRecord record = (UserWriteRecord) o; + + if (!(this.writeId == record.writeId)) { + return false; + } + if (!(this.path.equals(record.path))) { + return false; + } + if (!(this.visible == record.visible)) { + return false; + } + if (!(this.overwrite != null + ? this.overwrite.equals(record.overwrite) + : record.overwrite == null)) { + return false; + } + if (!(this.merge != null ? this.merge.equals(record.merge) : record.merge == null)) { + return false; + } + + return true; + } + + @Override + public int hashCode() { + int result = Long.valueOf(this.writeId).hashCode(); + result = 31 * result + Boolean.valueOf(this.visible).hashCode(); + result = 31 * result + this.path.hashCode(); + result = 31 * result + (this.overwrite != null ? this.overwrite.hashCode() : 0); + result = 31 * result + (this.merge != null ? this.merge.hashCode() : 0); + + return result; + } + + @Override + public String toString() { + return "UserWriteRecord{id=" + + writeId + + " path=" + + path + + " visible=" + + visible + + " overwrite=" + + overwrite + + " merge=" + + merge + + "}"; + } +} diff --git a/src/main/java/com/google/firebase/database/core/ValidationPath.java b/src/main/java/com/google/firebase/database/core/ValidationPath.java new file mode 100644 index 000000000..63d14532e --- /dev/null +++ b/src/main/java/com/google/firebase/database/core/ValidationPath.java @@ -0,0 +1,145 @@ +package com.google.firebase.database.core; + +import com.google.firebase.database.DatabaseException; +import com.google.firebase.database.snapshot.ChildKey; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +/** + * Dynamic (mutable) path used to count path lengths. + * + *

This class is used to efficiently check paths for valid length (in UTF8 bytes) and depth (used + * in path validation). + * + *

The definition of a path always begins with '/'. + */ +public class ValidationPath { + + private final List parts = new ArrayList<>(); + private int byteLength = 0; + + public static final int MAX_PATH_LENGTH_BYTES = 768; + public static final int MAX_PATH_DEPTH = 32; + + private ValidationPath(Path path) throws DatabaseException { + for (ChildKey key : path) { + parts.add(key.asString()); + } + + // Initialize to number of '/' chars needed in path. + byteLength = Math.max(1, parts.size()); + for (int i = 0; i < parts.size(); i++) { + byteLength += utf8Bytes(parts.get(i)); + } + checkValid(); + } + + public static void validateWithObject(Path path, Object value) throws DatabaseException { + new ValidationPath(path).withObject(value); + } + + @SuppressWarnings({"unchecked", "rawtypes"}) + private void withObject(Object value) throws DatabaseException { + if (value instanceof Map) { + Map mapValue = (Map) value; + for (String key : mapValue.keySet()) { + if (key.startsWith(".")) { + continue; + } + push(key); + withObject(mapValue.get(key)); + pop(); + } + return; + } + + if (value instanceof List) { + List listValue = (List) value; + for (int i = 0; i < listValue.size(); ++i) { + String key = Integer.toString(i); + push(key); + withObject(listValue.get(i)); + pop(); + } + } + } + + private void push(String child) throws DatabaseException { + // Count the '/' + if (parts.size() > 0) { + byteLength += 1; + } + parts.add(child); + byteLength += utf8Bytes(child); + checkValid(); + } + + private String pop() { + String last = parts.remove(parts.size() - 1); + byteLength -= utf8Bytes(last); + // Un-count the previous '/' + if (parts.size() > 0) { + byteLength -= 1; + } + return last; + } + + private void checkValid() throws DatabaseException { + if (byteLength > MAX_PATH_LENGTH_BYTES) { + throw new DatabaseException( + "Data has a key path longer than " + + MAX_PATH_LENGTH_BYTES + + " bytes (" + + byteLength + + ")."); + } + if (parts.size() > MAX_PATH_DEPTH) { + throw new DatabaseException( + "Path specified exceeds the maximum depth that can be written (" + + MAX_PATH_DEPTH + + ") or object contains a cycle " + + toErrorString()); + } + } + + private String toErrorString() { + if (parts.size() == 0) { + return ""; + } + return "in path \'" + joinStringList("/", parts) + "\'"; + } + + private static String joinStringList(String delimeter, List parts) { + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < parts.size(); i++) { + if (i > 0) { + sb.append(delimeter); + } + sb.append(parts.get(i)); + } + return sb.toString(); + } + + /* + * Compute UTF-8 encoding size in bytes w/o realizing the string in + * memory (which is what String.getBytes('UTF-8').length would do). + */ + private static int utf8Bytes(CharSequence sequence) { + int count = 0; + for (int i = 0, len = sequence.length(); i < len; i++) { + char ch = sequence.charAt(i); + if (ch <= 0x7F) { + count++; + } else if (ch <= 0x7FF) { + count += 2; + } else if (Character.isHighSurrogate(ch)) { + count += 4; + ++i; + } else { + count += 3; + } + } + return count; + } +} diff --git a/src/main/java/com/google/firebase/database/core/ValueEventRegistration.java b/src/main/java/com/google/firebase/database/core/ValueEventRegistration.java new file mode 100644 index 000000000..70565c839 --- /dev/null +++ b/src/main/java/com/google/firebase/database/core/ValueEventRegistration.java @@ -0,0 +1,96 @@ +package com.google.firebase.database.core; + +import com.google.firebase.database.DataSnapshot; +import com.google.firebase.database.DatabaseError; +import com.google.firebase.database.DatabaseReference; +import com.google.firebase.database.InternalHelpers; +import com.google.firebase.database.ValueEventListener; +import com.google.firebase.database.annotations.NotNull; +import com.google.firebase.database.core.view.Change; +import com.google.firebase.database.core.view.DataEvent; +import com.google.firebase.database.core.view.Event; +import com.google.firebase.database.core.view.QuerySpec; + +public class ValueEventRegistration extends EventRegistration { + + private final Repo repo; + private final ValueEventListener eventListener; + private final QuerySpec spec; + + public ValueEventRegistration( + Repo repo, ValueEventListener eventListener, @NotNull QuerySpec spec) { + this.repo = repo; + this.eventListener = eventListener; + this.spec = spec; + } + + @Override + public boolean respondsTo(Event.EventType eventType) { + return eventType == Event.EventType.VALUE; + } + + @Override + public boolean equals(Object other) { + return other instanceof ValueEventRegistration + && ((ValueEventRegistration) other).eventListener.equals(eventListener) + && ((ValueEventRegistration) other).repo.equals(repo) + && ((ValueEventRegistration) other).spec.equals(spec); + } + + @Override + public int hashCode() { + int result = this.eventListener.hashCode(); + result = 31 * result + this.repo.hashCode(); + result = 31 * result + this.spec.hashCode(); + return result; + } + + @Override + public DataEvent createEvent(Change change, QuerySpec query) { + DatabaseReference ref = InternalHelpers.createReference(repo, query.getPath()); + + DataSnapshot dataSnapshot = InternalHelpers.createDataSnapshot(ref, change.getIndexedNode()); + return new DataEvent(Event.EventType.VALUE, this, dataSnapshot, null); + } + + @Override + public void fireEvent(final DataEvent eventData) { + if (isZombied()) { + return; + } + eventListener.onDataChange(eventData.getSnapshot()); + } + + @Override + public void fireCancelEvent(final DatabaseError error) { + eventListener.onCancelled(error); + } + + @Override + public EventRegistration clone(QuerySpec newQuery) { + return new ValueEventRegistration(this.repo, this.eventListener, newQuery); + } + + @Override + public boolean isSameListener(EventRegistration other) { + return (other instanceof ValueEventRegistration) + && ((ValueEventRegistration) other).eventListener.equals(eventListener); + } + + @NotNull + @Override + public QuerySpec getQuerySpec() { + return spec; + } + + @Override + public String toString() { + return "ValueEventRegistration"; + } + + // Package private for testing purposes only + @Override + Repo getRepo() { + return repo; + } +} diff --git a/src/main/java/com/google/firebase/database/core/WriteTree.java b/src/main/java/com/google/firebase/database/core/WriteTree.java new file mode 100644 index 000000000..da7281a43 --- /dev/null +++ b/src/main/java/com/google/firebase/database/core/WriteTree.java @@ -0,0 +1,460 @@ +package com.google.firebase.database.core; + +import com.google.firebase.database.core.utilities.Predicate; +import com.google.firebase.database.core.view.CacheNode; +import com.google.firebase.database.snapshot.ChildKey; +import com.google.firebase.database.snapshot.EmptyNode; +import com.google.firebase.database.snapshot.Index; +import com.google.firebase.database.snapshot.NamedNode; +import com.google.firebase.database.snapshot.Node; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +/** + * Defines a single user-initiated write operation. May be the result of a set(), transaction(), or + * update() call. In the case of a set() or transaction, snap wil be non-null. In the case of an + * update(), children will be non-null. + */ +public class WriteTree { + + /** + * A tree tracking the result of applying all visible writes. This does not include transactions + * with applyLocally=false or writes that are completely shadowed by other writes. + */ + private CompoundWrite visibleWrites; + + /** + * A list of all pending writes, regardless of visibility and shadowed-ness. Used to calculate + * arbitrary sets of the changed data, such as hidden writes (from transactions) or changes with + * certain writes excluded (also used by transactions). + */ + private List allWrites; + + private Long lastWriteId; + + /** + * WriteTree tracks all pending user-initiated writes and has methods to calculate the result of + * merging them with underlying server data (to create "event cache" data). Pending writes are + * added with addOverwrite() and addMerge(), and removed with removeWrite(). + */ + public WriteTree() { + this.visibleWrites = CompoundWrite.emptyWrite(); + this.allWrites = new ArrayList<>(); + this.lastWriteId = -1L; + } + + /** + * Create a new WriteTreeRef for the given path. For use with a new sync point at the given path. + */ + public WriteTreeRef childWrites(Path path) { + return new WriteTreeRef(path, this); + } + + /** + * Record a new overwrite from user code. + */ + public void addOverwrite(Path path, Node snap, Long writeId, boolean visible) { + assert writeId > this.lastWriteId; // Stacking an older write on top of newer ones + this.allWrites.add(new UserWriteRecord(writeId, path, snap, visible)); + if (visible) { + this.visibleWrites = this.visibleWrites.addWrite(path, snap); + } + this.lastWriteId = writeId; + } + + /** + * Record a new merge from user code. + */ + public void addMerge(Path path, CompoundWrite changedChildren, Long writeId) { + assert writeId > this.lastWriteId; // Stacking an older write on top of newer ones + this.allWrites.add(new UserWriteRecord(writeId, path, changedChildren)); + this.visibleWrites = this.visibleWrites.addWrites(path, changedChildren); + this.lastWriteId = writeId; + } + + public UserWriteRecord getWrite(long writeId) { + for (UserWriteRecord record : this.allWrites) { + if (record.getWriteId() == writeId) { + return record; + } + } + return null; + } + + public List purgeAllWrites() { + List purgedWrites = new ArrayList<>(this.allWrites); + // Reset everything + this.visibleWrites = CompoundWrite.emptyWrite(); + this.allWrites = new ArrayList<>(); + return purgedWrites; + } + + /** + * Remove a write (either an overwrite or merge) that has been successfully acknowledge by the + * server. Recalculates the tree if necessary. We return whether the write may have been visible, + * meaning views need to reevaluate. + * + * @return true if the write may have been visible (meaning we'll need to reevaluate / raise + * events as a result). + */ + public boolean removeWrite(long writeId) { + // Note: disabling this check. It could be a transaction that preempted another transaction, and + // thus was applied out of order. + // var validClear = revert || this.allWrites_.length === 0 || + // writeId <= this.allWrites_[0].writeId; + // fb.core.util.assert(validClear, "Either we don't have this write, or it's the first one in + // the queue"); + + // TODO: maybe use hashmap + UserWriteRecord writeToRemove = null; + int idx = 0; + for (UserWriteRecord record : this.allWrites) { + if (record.getWriteId() == writeId) { + writeToRemove = record; + break; + } + idx++; + } + assert writeToRemove != null : "removeWrite called with nonexistent writeId"; + + this.allWrites.remove(writeToRemove); + + boolean removedWriteWasVisible = writeToRemove.isVisible(); + boolean removedWriteOverlapsWithOtherWrites = false; + int i = this.allWrites.size() - 1; + + while (removedWriteWasVisible && i >= 0) { + UserWriteRecord currentWrite = this.allWrites.get(i); + if (currentWrite.isVisible()) { + if (i >= idx && this.recordContainsPath(currentWrite, writeToRemove.getPath())) { + // The removed write was completely shadowed by a subsequent write. + removedWriteWasVisible = false; + } else if (writeToRemove.getPath().contains(currentWrite.getPath())) { + // Either we're covering some writes or they're covering part of us (depending on which + // came first). + removedWriteOverlapsWithOtherWrites = true; + } + } + i--; + } + + if (!removedWriteWasVisible) { + return false; + } else if (removedWriteOverlapsWithOtherWrites) { + // There's some shadowing going on. Just rebuild the visible writes from scratch. + this.resetTree(); + return true; + } else { + // There's no shadowing. We can safely just remove the write(s) from visibleWrites. + if (writeToRemove.isOverwrite()) { + this.visibleWrites = this.visibleWrites.removeWrite(writeToRemove.getPath()); + } else { + for (Map.Entry entry : writeToRemove.getMerge()) { + Path path = entry.getKey(); + this.visibleWrites = this.visibleWrites.removeWrite(writeToRemove.getPath().child(path)); + } + } + return true; + } + } + + /** + * Return a complete snapshot for the given path if there's visible write data at that path, else + * null. No server data is considered. + */ + public Node getCompleteWriteData(Path path) { + return this.visibleWrites.getCompleteNode(path); + } + + /** + * Given optional, underlying server data, and an optional set of constraints (exclude some sets, + * include hidden writes), attempt to calculate a complete snapshot for the given path + */ + public Node calcCompleteEventCache(Path treePath, Node completeServerCache) { + return this.calcCompleteEventCache(treePath, completeServerCache, new ArrayList()); + } + + public Node calcCompleteEventCache( + Path treePath, Node completeServerCache, List writeIdsToExclude) { + return this.calcCompleteEventCache(treePath, completeServerCache, writeIdsToExclude, false); + } + + public Node calcCompleteEventCache( + final Path treePath, + Node completeServerCache, + final List writeIdsToExclude, + final boolean includeHiddenWrites) { + if (writeIdsToExclude.isEmpty() && !includeHiddenWrites) { + Node shadowingNode = this.visibleWrites.getCompleteNode(treePath); + if (shadowingNode != null) { + return shadowingNode; + } else { + CompoundWrite subMerge = this.visibleWrites.childCompoundWrite(treePath); + if (subMerge.isEmpty()) { + return completeServerCache; + } else if (completeServerCache == null && !subMerge.hasCompleteWrite(Path.getEmptyPath())) { + // We wouldn't have a complete snapshot, since there's no underlying data and no complete + // shadow + return null; + } else { + Node layeredCache; + if (completeServerCache != null) { + layeredCache = completeServerCache; + } else { + layeredCache = EmptyNode.Empty(); + } + return subMerge.apply(layeredCache); + } + } + } else { + CompoundWrite merge = this.visibleWrites.childCompoundWrite(treePath); + if (!includeHiddenWrites && merge.isEmpty()) { + return completeServerCache; + } else { + // If the server cache is null, and we don't have a complete cache, we need to return null + if (!includeHiddenWrites + && completeServerCache == null + && !merge.hasCompleteWrite(Path.getEmptyPath())) { + return null; + } else { + Predicate filter = + new Predicate() { + @Override + public boolean evaluate(UserWriteRecord write) { + return (write.isVisible() || includeHiddenWrites) + && (!writeIdsToExclude.contains(write.getWriteId())) + && (write.getPath().contains(treePath) || treePath.contains(write.getPath())); + } + }; + Node layeredCache; + CompoundWrite mergeAtPath = WriteTree.layerTree(this.allWrites, filter, treePath); + layeredCache = completeServerCache != null ? completeServerCache : EmptyNode.Empty(); + return mergeAtPath.apply(layeredCache); + } + } + } + } + + /** + * With underlying server data, attempt to return a children node of children that we have + * complete data for. Used when creating new views, to pre-fill their complete event children + * snapshot. + */ + public Node calcCompleteEventChildren(Path treePath, Node completeServerChildren) { + Node completeChildren = EmptyNode.Empty(); + Node topLevelSet = this.visibleWrites.getCompleteNode(treePath); + if (topLevelSet != null) { + if (!topLevelSet.isLeafNode()) { + // we're shadowing everything. Return the children. + for (NamedNode childEntry : topLevelSet) { + completeChildren = + completeChildren.updateImmediateChild(childEntry.getName(), childEntry.getNode()); + } + } + return completeChildren; + } else { + // Layer any children we have on top of this + // We know we don't have a top-level set, so just enumerate existing children, and apply any + // updates + CompoundWrite merge = this.visibleWrites.childCompoundWrite(treePath); + for (NamedNode entry : completeServerChildren) { + Node node = merge.childCompoundWrite(new Path(entry.getName())).apply(entry.getNode()); + completeChildren = completeChildren.updateImmediateChild(entry.getName(), node); + } + // Add any complete children we have from the set + for (NamedNode node : merge.getCompleteChildren()) { + completeChildren = completeChildren.updateImmediateChild(node.getName(), node.getNode()); + } + return completeChildren; + } + } + + /** + * Given that the underlying server data has updated, determine what, if anything, needs to be + * applied to the event cache. + * + *

Possibilities: + * + *

1. No writes are shadowing. Events should be raised, the snap to be applied comes from the + * server data + * + *

2. Some write is completely shadowing. No events to be raised + * + *

3. Is partially shadowed. Events + * + *

Either existingEventSnap or existingServerSnap must exist + */ + public Node calcEventCacheAfterServerOverwrite( + Path treePath, + final Path childPath, + final Node existingEventSnap, + final Node existingServerSnap) { + assert existingEventSnap != null || existingServerSnap != null + : "Either existingEventSnap or existingServerSnap must exist"; + Path path = treePath.child(childPath); + if (this.visibleWrites.hasCompleteWrite(path)) { + // At this point we can probably guarantee that we're in case 2, meaning no events + // May need to check visibility while doing the findRootMostValueAndPath call + return null; + } else { + // No complete shadowing. We're either partially shadowing or not shadowing at all. + CompoundWrite childMerge = this.visibleWrites.childCompoundWrite(path); + if (childMerge.isEmpty()) { + // We're not shadowing at all. Case 1 + return existingServerSnap.getChild(childPath); + } else { + // This could be more efficient if the serverNode + updates doesn't change the eventSnap + // However this is tricky to find out, since user updates don't necessary change the server + // snap, e.g. priority updates on empty nodes, or deep deletes. Another special case is if + // the server adds nodes, but doesn't change any existing writes. It is therefore not enough + // to only check if the updates change the serverNode. + // Maybe check if the merge tree contains these special cases and only do a full overwrite + // in that case? + return childMerge.apply(existingServerSnap.getChild(childPath)); + } + } + } + + /** + * Returns a complete child for a given server snap after applying all user writes or null if + * there is no complete child for this ChildKey. + */ + public Node calcCompleteChild(Path treePath, ChildKey childKey, CacheNode existingServerSnap) { + Path path = treePath.child(childKey); + Node shadowingNode = this.visibleWrites.getCompleteNode(path); + if (shadowingNode != null) { + return shadowingNode; + } else { + if (existingServerSnap.isCompleteForChild(childKey)) { + CompoundWrite childMerge = this.visibleWrites.childCompoundWrite(path); + return childMerge.apply(existingServerSnap.getNode().getImmediateChild(childKey)); + } else { + return null; + } + } + } + + /** + * Returns a node if there is a complete overwrite for this path. More specifically, if there is a + * write at a higher path, this will return the child of that write relative to the write and this + * path. Returns null if there is no write at this path. + */ + public Node shadowingWrite(Path path) { + return this.visibleWrites.getCompleteNode(path); + } + + /** + * This method is used when processing child remove events on a query. If we can, we pull in + * children that were outside the window, but may now be in the window. + */ + public NamedNode calcNextNodeAfterPost( + Path treePath, Node completeServerData, NamedNode post, boolean reverse, Index index) { + Node toIterate; + CompoundWrite merge = this.visibleWrites.childCompoundWrite(treePath); + Node shadowingNode = merge.getCompleteNode(Path.getEmptyPath()); + if (shadowingNode != null) { + toIterate = shadowingNode; + } else if (completeServerData != null) { + toIterate = merge.apply(completeServerData); + } else { + // no children to iterate on + return null; + } + NamedNode currentNext = null; + for (NamedNode node : toIterate) { + if (index.compare(node, post, reverse) > 0 + && (currentNext == null || index.compare(node, currentNext, reverse) < 0)) { + currentNext = node; + } + } + return currentNext; + } + + private boolean recordContainsPath(UserWriteRecord writeRecord, Path path) { + if (writeRecord.isOverwrite()) { + return writeRecord.getPath().contains(path); + } else { + for (Map.Entry entry : writeRecord.getMerge()) { + if (writeRecord.getPath().child(entry.getKey()).contains(path)) { + return true; + } + } + return false; + } + } + + /** + * Re-layer the writes and merges into a tree so we can efficiently calculate event snapshots + */ + private void resetTree() { + this.visibleWrites = + WriteTree.layerTree(this.allWrites, WriteTree.DEFAULT_FILTER, Path.getEmptyPath()); + if (this.allWrites.size() > 0) { + this.lastWriteId = this.allWrites.get(this.allWrites.size() - 1).getWriteId(); + } else { + this.lastWriteId = -1L; + } + } + + /** + * The default filter used when constructing the tree. Keep everything that's visible. + */ + private static final Predicate DEFAULT_FILTER = + new Predicate() { + @Override + public boolean evaluate(UserWriteRecord write) { + return write.isVisible(); + } + }; + + /** + * Static method. Given an array of WriteRecords, a filter for which ones to include, and a path, + * construct a merge at that path. + */ + private static CompoundWrite layerTree( + List writes, Predicate filter, Path treeRoot) { + CompoundWrite compoundWrite = CompoundWrite.emptyWrite(); + for (UserWriteRecord write : writes) { + // Theory, a later set will either: + // a) abort a relevant transaction, so no need to worry about excluding it from calculating + // that transaction + // b) not be relevant to a transaction (separate branch), so again will not affect the data + // for that transaction + if (filter.evaluate(write)) { + Path writePath = write.getPath(); + if (write.isOverwrite()) { + if (treeRoot.contains(writePath)) { + Path relativePath = Path.getRelative(treeRoot, writePath); + compoundWrite = compoundWrite.addWrite(relativePath, write.getOverwrite()); + } else if (writePath.contains(treeRoot)) { + compoundWrite = + compoundWrite.addWrite( + Path.getEmptyPath(), + write.getOverwrite().getChild(Path.getRelative(writePath, treeRoot))); + } else { + // There is no overlap between root path and write path, ignore write + } + } else { + if (treeRoot.contains(writePath)) { + Path relativePath = Path.getRelative(treeRoot, writePath); + compoundWrite = compoundWrite.addWrites(relativePath, write.getMerge()); + } else if (writePath.contains(treeRoot)) { + Path relativePath = Path.getRelative(writePath, treeRoot); + if (relativePath.isEmpty()) { + compoundWrite = compoundWrite.addWrites(Path.getEmptyPath(), write.getMerge()); + } else { + Node deepNode = write.getMerge().getCompleteNode(relativePath); + if (deepNode != null) { + compoundWrite = compoundWrite.addWrite(Path.getEmptyPath(), deepNode); + } + } + } else { + // There is no overlap between root path and write path, ignore write + } + } + } + } + return compoundWrite; + } +} diff --git a/src/main/java/com/google/firebase/database/core/WriteTreeRef.java b/src/main/java/com/google/firebase/database/core/WriteTreeRef.java new file mode 100644 index 000000000..e3c4d44b6 --- /dev/null +++ b/src/main/java/com/google/firebase/database/core/WriteTreeRef.java @@ -0,0 +1,118 @@ +package com.google.firebase.database.core; + +import com.google.firebase.database.core.view.CacheNode; +import com.google.firebase.database.snapshot.ChildKey; +import com.google.firebase.database.snapshot.Index; +import com.google.firebase.database.snapshot.NamedNode; +import com.google.firebase.database.snapshot.Node; +import java.util.Collections; +import java.util.List; + +/** + * A WriteTreeRef wraps a WriteTree and a path, for convenient access to a particular subtree. All + * of the methods just proxy to the underlying WriteTree. + */ +public class WriteTreeRef { + + /** + * The path to this particular write tree ref. Used for calling methods on writeTree_ while + * exposing a simpler interface to callers. + */ + private final Path treePath; + + /** + * A reference to the actual tree of write data. All methods are pass-through to the tree, but + * with the appropriate path prefixed. + * + *

This lets us make cheap references to points in the tree for sync points without having to + * copy and maintain all of the data. + */ + private final WriteTree writeTree; + + public WriteTreeRef(Path path, WriteTree writeTree) { + this.treePath = path; + this.writeTree = writeTree; + } + + /** + * If possible, returns a complete event cache, using the underlying server data if possible. In + * addition, can be used to get a cache that includes hidden writes, and excludes arbitrary + * writes. Note that customizing the returned node can lead to a more expensive calculation. + */ + public Node calcCompleteEventCache(Node completeServerCache) { + return this.calcCompleteEventCache(completeServerCache, Collections.emptyList()); + } + + public Node calcCompleteEventCache(Node completeServerCache, List writeIdsToExclude) { + return this.calcCompleteEventCache(completeServerCache, writeIdsToExclude, false); + } + + public Node calcCompleteEventCache( + Node completeServerCache, List writeIdsToExclude, boolean includeHiddenWrites) { + return this.writeTree.calcCompleteEventCache( + this.treePath, completeServerCache, writeIdsToExclude, includeHiddenWrites); + } + + /** + * If possible, returns a children node containing all of the complete children we have data for. + * The returned data is a mix of the given server data and write data. + */ + public Node calcCompleteEventChildren(Node completeServerChildren) { + return this.writeTree.calcCompleteEventChildren(this.treePath, completeServerChildren); + } + + /** + * Given that either the underlying server data has updated or the outstanding writes have + * updated, determine what, if anything, needs to be applied to the event cache. + * + *

Possibilities: + * + *

1. No writes are shadowing. Events should be raised, the snap to be applied comes from the + * server data + * + *

2. Some write is completely shadowing. No events to be raised + * + *

3. Is partially shadowed. Events should be raised + * + *

Either existingEventSnap or existingServerSnap must exist, this is validated via an assert + */ + public Node calcEventCacheAfterServerOverwrite( + Path path, Node existingEventSnap, Node existingServerSnap) { + return this.writeTree.calcEventCacheAfterServerOverwrite( + this.treePath, path, existingEventSnap, existingServerSnap); + } + + /** + * Returns a node if there is a complete overwrite for this path. More specifically, if there is a + * write at a higher path, this will return the child of that write relative to the write and this + * path. Returns null if there is no write at this path. + */ + public Node shadowingWrite(Path path) { + return this.writeTree.shadowingWrite(this.treePath.child(path)); + } + + /** + * This method is used when processing child remove events on a query. If we can, we pull in + * children that were outside the window, but may now be in the window + */ + public NamedNode calcNextNodeAfterPost( + Node completeServerData, NamedNode startPost, boolean reverse, Index index) { + return this.writeTree.calcNextNodeAfterPost( + this.treePath, completeServerData, startPost, reverse, index); + } + + /** + * Returns a complete child for a given server snap after applying all user writes or null if + * there is no complete child for this ChildKey. + */ + public Node calcCompleteChild(ChildKey childKey, CacheNode existingServerCache) { + return this.writeTree.calcCompleteChild(this.treePath, childKey, existingServerCache); + } + + /** + * Return a WriteTreeRef for a child. + */ + public WriteTreeRef child(ChildKey childKey) { + return new WriteTreeRef(this.treePath.child(childKey), this.writeTree); + } +} diff --git a/src/main/java/com/google/firebase/database/core/ZombieEventManager.java b/src/main/java/com/google/firebase/database/core/ZombieEventManager.java new file mode 100644 index 000000000..be1f83c6c --- /dev/null +++ b/src/main/java/com/google/firebase/database/core/ZombieEventManager.java @@ -0,0 +1,143 @@ +package com.google.firebase.database.core; + +import com.google.firebase.database.annotations.NotNull; +import com.google.firebase.database.core.view.QuerySpec; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; + +/** + * {@link ZombieEventManager} records event registrations made from Query so that when they are + * unregistered, we immediately "zombie" them so that all further events are suppressed. This stops + * events that would normally be fired if events were already queued in the {@link Repo} or even in + * the Android message queue. + */ +public class ZombieEventManager implements EventRegistrationZombieListener { + + // This hashmap stores the original eventregistrations sent to the repo. + // These are the registration instances that will get called with update events. + // Since EventRegistration overrides equals and hashcode, we create temporary instances + // to use as lookup keys. + // Package private for testing purposes only + final HashMap> globalEventRegistrations = + new HashMap<>(); + + private static ZombieEventManager defaultInstance = new ZombieEventManager(); + + private ZombieEventManager() { + } + + @NotNull + public static ZombieEventManager getInstance() { + return defaultInstance; + } + + public void recordEventRegistration(EventRegistration registration) { + synchronized (globalEventRegistrations) { + List registrationList = globalEventRegistrations.get(registration); + if (registrationList == null) { + registrationList = new ArrayList<>(); + globalEventRegistrations.put(registration, registrationList); + } + registrationList.add(registration); + // We record non default listeners twice, because when the default listener is zombied + // (removed) the repo will remove that listener on all specific queries as well. + // We need to match that behavior here and zombie all the relevant registrations. + if (!registration.getQuerySpec().isDefault()) { + EventRegistration defaultRegistration = + registration.clone(QuerySpec.defaultQueryAtPath(registration.getQuerySpec().getPath())); + registrationList = globalEventRegistrations.get(defaultRegistration); + if (registrationList == null) { + registrationList = new ArrayList<>(); + globalEventRegistrations.put(defaultRegistration, registrationList); + } + registrationList.add(registration); + } + + registration.setIsUserInitiated(true); + registration.setOnZombied(this); + } + } + + private void unRecordEventRegistration(EventRegistration zombiedRegistration) { + synchronized (globalEventRegistrations) { + boolean found = false; + + List registrationList = globalEventRegistrations.get(zombiedRegistration); + if (registrationList != null) { + for (int i = 0; i < registrationList.size(); i++) { + if (registrationList.get(i) == zombiedRegistration) { + found = true; + registrationList.remove(i); + break; + } + } + if (registrationList.isEmpty()) { + globalEventRegistrations.remove(zombiedRegistration); + } + } + assert (found || !zombiedRegistration.isUserInitiated()); + + // If the registration was recorded twice, we need to remove its second + // record. + if (!zombiedRegistration.getQuerySpec().isDefault()) { + EventRegistration defaultRegistration = + zombiedRegistration.clone( + QuerySpec.defaultQueryAtPath(zombiedRegistration.getQuerySpec().getPath())); + + registrationList = globalEventRegistrations.get(defaultRegistration); + if (registrationList != null) { + for (int i = 0; i < registrationList.size(); i++) { + if (registrationList.get(i) == zombiedRegistration) { + registrationList.remove(i); + break; + } + } + if (registrationList.isEmpty()) { + globalEventRegistrations.remove(defaultRegistration); + } + } + } + } + } + + public void zombifyForRemove(EventRegistration registration) { + synchronized (globalEventRegistrations) { + List registrationList = globalEventRegistrations.get(registration); + if (registrationList != null && !registrationList.isEmpty()) { + if (registration.getQuerySpec().isDefault()) { + // The behavior here has to match the behavior of SyncPoint.removeEventRegistration. + // If the query is default, it remove a single instance of the registration + // from each unique query. So for example, if you had 3 copies registered under default, + // you would end up with 2 still registered. + // If you had 1 registration in default and 2 in query a', you'd end up with just + // a single registration in a'. + // To implement this, we just store in a hashset queries that we remove so we can still + // keep a fairly simple structure. + // Note that we *could* use the same logic for non-default as the list there only has a + // a single query, but its somewhat wasteful to enumerate the list when we know we will + // only grab 1. + HashSet zombiedQueries = new HashSet<>(); + // Walk down the list so that removes do not mess up the enumeration. + for (int i = registrationList.size() - 1; i >= 0; i--) { + EventRegistration currentRegistration = registrationList.get(i); + if (!zombiedQueries.contains(currentRegistration.getQuerySpec())) { + zombiedQueries.add(currentRegistration.getQuerySpec()); + currentRegistration.zombify(); + } + } + } else { + // Note that this entry cannot already be zombied because we are inside synchronization + // and any previous calls to zombify would have removed the entry. + registrationList.get(0).zombify(); + } + } + } + } + + @Override + public void onZombied(EventRegistration zombiedInstance) { + unRecordEventRegistration(zombiedInstance); + } +} diff --git a/src/main/java/com/google/firebase/database/core/operation/AckUserWrite.java b/src/main/java/com/google/firebase/database/core/operation/AckUserWrite.java new file mode 100644 index 000000000..46e2f7a89 --- /dev/null +++ b/src/main/java/com/google/firebase/database/core/operation/AckUserWrite.java @@ -0,0 +1,53 @@ +package com.google.firebase.database.core.operation; + +import static com.google.firebase.database.utilities.Utilities.hardAssert; + +import com.google.firebase.database.core.Path; +import com.google.firebase.database.core.utilities.ImmutableTree; +import com.google.firebase.database.snapshot.ChildKey; + +public class AckUserWrite extends Operation { + + private final boolean revert; + // A tree containing true for each affected path. Affected paths can't overlap. + private final ImmutableTree affectedTree; + + public AckUserWrite(Path path, ImmutableTree affectedTree, boolean revert) { + super(OperationType.AckUserWrite, OperationSource.USER, path); + this.affectedTree = affectedTree; + this.revert = revert; + } + + public ImmutableTree getAffectedTree() { + return this.affectedTree; + } + + public boolean isRevert() { + return this.revert; + } + + @Override + public Operation operationForChild(ChildKey childKey) { + if (!this.path.isEmpty()) { + hardAssert( + this.path.getFront().equals(childKey), "operationForChild called for unrelated child."); + return new AckUserWrite(this.path.popFront(), this.affectedTree, this.revert); + } else if (this.affectedTree.getValue() != null) { + hardAssert( + this.affectedTree.getChildren().isEmpty(), + "affectedTree should not have overlapping affected paths."); + // All child locations are affected as well; just return same operation. + return this; + } else { + ImmutableTree childTree = this.affectedTree.subtree(new Path(childKey)); + return new AckUserWrite(Path.getEmptyPath(), childTree, this.revert); + } + } + + @Override + public String toString() { + return String.format( + "AckUserWrite { path=%s, revert=%s, affectedTree=%s }", + getPath(), this.revert, this.affectedTree); + } +} diff --git a/src/main/java/com/google/firebase/database/core/operation/ListenComplete.java b/src/main/java/com/google/firebase/database/core/operation/ListenComplete.java new file mode 100644 index 000000000..aaec8ca78 --- /dev/null +++ b/src/main/java/com/google/firebase/database/core/operation/ListenComplete.java @@ -0,0 +1,26 @@ +package com.google.firebase.database.core.operation; + +import com.google.firebase.database.core.Path; +import com.google.firebase.database.snapshot.ChildKey; + +public class ListenComplete extends Operation { + + public ListenComplete(OperationSource source, Path path) { + super(OperationType.ListenComplete, source, path); + assert !source.isFromUser() : "Can't have a listen complete from a user source"; + } + + @Override + public Operation operationForChild(ChildKey childKey) { + if (this.path.isEmpty()) { + return new ListenComplete(this.source, Path.getEmptyPath()); + } else { + return new ListenComplete(this.source, this.path.popFront()); + } + } + + @Override + public String toString() { + return String.format("ListenComplete { path=%s, source=%s }", getPath(), getSource()); + } +} diff --git a/src/main/java/com/google/firebase/database/core/operation/Merge.java b/src/main/java/com/google/firebase/database/core/operation/Merge.java new file mode 100644 index 000000000..86e0d1254 --- /dev/null +++ b/src/main/java/com/google/firebase/database/core/operation/Merge.java @@ -0,0 +1,46 @@ +package com.google.firebase.database.core.operation; + +import com.google.firebase.database.core.CompoundWrite; +import com.google.firebase.database.core.Path; +import com.google.firebase.database.snapshot.ChildKey; + +public class Merge extends Operation { + + private final CompoundWrite children; + + public Merge(OperationSource source, Path path, CompoundWrite children) { + super(OperationType.Merge, source, path); + this.children = children; + } + + public CompoundWrite getChildren() { + return this.children; + } + + @Override + public Operation operationForChild(ChildKey childKey) { + if (this.path.isEmpty()) { + CompoundWrite childTree = children.childCompoundWrite(new Path(childKey)); + if (childTree.isEmpty()) { + // This child is unaffected. + return null; + } else if (childTree.rootWrite() != null) { + // we have a set + return new Overwrite(this.source, Path.getEmptyPath(), childTree.rootWrite()); + } else { + return new Merge(this.source, Path.getEmptyPath(), childTree); + } + } else if (this.path.getFront().equals(childKey)) { + return new Merge(this.source, this.path.popFront(), this.children); + } else { + // merge doesn't affect this path + return null; + } + } + + @Override + public String toString() { + return String.format( + "Merge { path=%s, source=%s, children=%s }", getPath(), getSource(), this.children); + } +} diff --git a/src/main/java/com/google/firebase/database/core/operation/Operation.java b/src/main/java/com/google/firebase/database/core/operation/Operation.java new file mode 100644 index 000000000..887decf82 --- /dev/null +++ b/src/main/java/com/google/firebase/database/core/operation/Operation.java @@ -0,0 +1,39 @@ +package com.google.firebase.database.core.operation; + +import com.google.firebase.database.core.Path; +import com.google.firebase.database.snapshot.ChildKey; + +public abstract class Operation { + + /** */ + public enum OperationType { + Overwrite, + Merge, + AckUserWrite, + ListenComplete + } + + protected final OperationType type; + protected final OperationSource source; + protected final Path path; + + protected Operation(OperationType type, OperationSource source, Path path) { + this.type = type; + this.source = source; + this.path = path; + } + + public Path getPath() { + return this.path; + } + + public OperationSource getSource() { + return this.source; + } + + public OperationType getType() { + return this.type; + } + + public abstract Operation operationForChild(ChildKey childKey); +} diff --git a/src/main/java/com/google/firebase/database/core/operation/OperationSource.java b/src/main/java/com/google/firebase/database/core/operation/OperationSource.java new file mode 100644 index 000000000..4375607d7 --- /dev/null +++ b/src/main/java/com/google/firebase/database/core/operation/OperationSource.java @@ -0,0 +1,57 @@ +package com.google.firebase.database.core.operation; + +import com.google.firebase.database.core.view.QueryParams; + +public class OperationSource { + + private enum Source { + User, + Server + } + + public static final OperationSource USER = new OperationSource(Source.User, null, false); + public static final OperationSource SERVER = new OperationSource(Source.Server, null, false); + + public static OperationSource forServerTaggedQuery(QueryParams queryParams) { + return new OperationSource(Source.Server, queryParams, true); + } + + private final Source source; + private final QueryParams queryParams; + private final boolean tagged; + + public OperationSource(Source source, QueryParams queryParams, boolean tagged) { + this.source = source; + this.queryParams = queryParams; + this.tagged = tagged; + assert !tagged || isFromServer(); + } + + public boolean isFromUser() { + return this.source == Source.User; + } + + public boolean isFromServer() { + return this.source == Source.Server; + } + + public boolean isTagged() { + return tagged; + } + + @Override + public String toString() { + return "OperationSource{" + + "source=" + + source + + ", queryParams=" + + queryParams + + ", tagged=" + + tagged + + '}'; + } + + public QueryParams getQueryParams() { + return this.queryParams; + } +} diff --git a/src/main/java/com/google/firebase/database/core/operation/Overwrite.java b/src/main/java/com/google/firebase/database/core/operation/Overwrite.java new file mode 100644 index 000000000..3fa54bd65 --- /dev/null +++ b/src/main/java/com/google/firebase/database/core/operation/Overwrite.java @@ -0,0 +1,35 @@ +package com.google.firebase.database.core.operation; + +import com.google.firebase.database.core.Path; +import com.google.firebase.database.snapshot.ChildKey; +import com.google.firebase.database.snapshot.Node; + +public class Overwrite extends Operation { + + private final Node snapshot; + + public Overwrite(OperationSource source, Path path, Node snapshot) { + super(OperationType.Overwrite, source, path); + this.snapshot = snapshot; + } + + public Node getSnapshot() { + return this.snapshot; + } + + @Override + public Operation operationForChild(ChildKey childKey) { + if (this.path.isEmpty()) { + return new Overwrite( + this.source, Path.getEmptyPath(), this.snapshot.getImmediateChild(childKey)); + } else { + return new Overwrite(this.source, this.path.popFront(), this.snapshot); + } + } + + @Override + public String toString() { + return String.format( + "Overwrite { path=%s, source=%s, snapshot=%s }", getPath(), getSource(), this.snapshot); + } +} diff --git a/src/main/java/com/google/firebase/database/core/persistence/CachePolicy.java b/src/main/java/com/google/firebase/database/core/persistence/CachePolicy.java new file mode 100644 index 000000000..83c6a7637 --- /dev/null +++ b/src/main/java/com/google/firebase/database/core/persistence/CachePolicy.java @@ -0,0 +1,35 @@ +package com.google.firebase.database.core.persistence; + +public interface CachePolicy { + + boolean shouldPrune(long currentSizeBytes, long countOfPrunableQueries); + + boolean shouldCheckCacheSize(long serverUpdatesSinceLastCheck); + + float getPercentOfQueriesToPruneAtOnce(); + + long getMaxNumberOfQueriesToKeep(); + + CachePolicy NONE = + new CachePolicy() { + @Override + public boolean shouldPrune(long currentSizeBytes, long countOfPrunableQueries) { + return false; + } + + @Override + public boolean shouldCheckCacheSize(long serverUpdatesSinceLastCheck) { + return false; + } + + @Override + public float getPercentOfQueriesToPruneAtOnce() { + return 0; + } + + @Override + public long getMaxNumberOfQueriesToKeep() { + return Long.MAX_VALUE; + } + }; +} diff --git a/src/main/java/com/google/firebase/database/core/persistence/DefaultPersistenceManager.java b/src/main/java/com/google/firebase/database/core/persistence/DefaultPersistenceManager.java new file mode 100644 index 000000000..31dc42afd --- /dev/null +++ b/src/main/java/com/google/firebase/database/core/persistence/DefaultPersistenceManager.java @@ -0,0 +1,258 @@ +package com.google.firebase.database.core.persistence; + +import com.google.firebase.database.core.CompoundWrite; +import com.google.firebase.database.core.Context; +import com.google.firebase.database.core.Path; +import com.google.firebase.database.core.UserWriteRecord; +import com.google.firebase.database.core.view.CacheNode; +import com.google.firebase.database.core.view.QuerySpec; +import com.google.firebase.database.logging.LogWrapper; +import com.google.firebase.database.snapshot.ChildKey; +import com.google.firebase.database.snapshot.EmptyNode; +import com.google.firebase.database.snapshot.IndexedNode; +import com.google.firebase.database.snapshot.Node; +import com.google.firebase.database.utilities.Clock; +import com.google.firebase.database.utilities.DefaultClock; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.Callable; + +public class DefaultPersistenceManager implements PersistenceManager { + + private final PersistenceStorageEngine storageLayer; + private final TrackedQueryManager trackedQueryManager; + private final LogWrapper logger; + private final CachePolicy cachePolicy; + private long serverCacheUpdatesSinceLastPruneCheck = 0; + + public DefaultPersistenceManager( + Context ctx, PersistenceStorageEngine engine, CachePolicy cachePolicy) { + this(ctx, engine, cachePolicy, new DefaultClock()); + } + + public DefaultPersistenceManager( + Context ctx, PersistenceStorageEngine engine, CachePolicy cachePolicy, Clock clock) { + this.storageLayer = engine; + this.logger = ctx.getLogger("Persistence"); + this.trackedQueryManager = new TrackedQueryManager(storageLayer, logger, clock); + this.cachePolicy = cachePolicy; + } + + /** + * Save a user overwrite + * + * @param path The path for this write + * @param node The node for this write + * @param writeId The write id that was used for this write + */ + @Override + public void saveUserOverwrite(Path path, Node node, long writeId) { + this.storageLayer.saveUserOverwrite(path, node, writeId); + } + + /** + * Save a user merge + * + * @param path The path for this merge + * @param children The children for this merge + * @param writeId The write id that was used for this merge + */ + @Override + public void saveUserMerge(Path path, CompoundWrite children, long writeId) { + this.storageLayer.saveUserMerge(path, children, writeId); + } + + /** + * Remove a write with the given write id. + * + * @param writeId The write id to remove + */ + @Override + public void removeUserWrite(long writeId) { + this.storageLayer.removeUserWrite(writeId); + } + + @Override + public void removeAllUserWrites() { + this.storageLayer.removeAllUserWrites(); + } + + @Override + public void applyUserWriteToServerCache(Path path, Node node) { + // This is a hack to guess whether we already cached this because we got a server data update + // for this write via an existing active default query. If we didn't, then we'll manually cache + // this and add a tracked query to mark it complete and keep it cached. + // Unfortunately this is just a guess and it's possible that we *did* get an update (e.g. via a + // filtered query) and by overwriting the cache here, we'll actually store an incorrect value + // (e.g. in the case that we wrote a ServerValue.TIMESTAMP and the server resolved it to a + // different value). + // TODO[persistence]: Consider reworking. + if (!this.trackedQueryManager.hasActiveDefaultQuery(path)) { + this.storageLayer.overwriteServerCache(path, node); + this.trackedQueryManager.ensureCompleteTrackedQuery(path); + } + } + + @Override + public void applyUserWriteToServerCache(Path path, CompoundWrite merge) { + // TODO: This could probably be optimized. + for (Map.Entry write : merge) { + Path writePath = path.child(write.getKey()); + Node writeNode = write.getValue(); + this.applyUserWriteToServerCache(writePath, writeNode); + } + } + + /** + * Return a list of all writes that were persisted + * + * @return The list of writes + */ + @Override + public List loadUserWrites() { + return this.storageLayer.loadUserWrites(); + } + + /** + * Returns any cached node or children as a CacheNode. The query is *not* used to filter the node + * but rather to determine if it can be considered complete. + * + * @param query The query at the path + * @return The cached node or an empty CacheNode if no cache is available + */ + @Override + public CacheNode serverCache(QuerySpec query) { + Set trackedKeys; + boolean complete; + // TODO[persistence]: Should we use trackedKeys to find out if this location is a child of a + // complete query? + if (this.trackedQueryManager.isQueryComplete(query)) { + complete = true; + TrackedQuery trackedQuery = this.trackedQueryManager.findTrackedQuery(query); + if (!query.loadsAllData() && trackedQuery != null && trackedQuery.complete) { + trackedKeys = this.storageLayer.loadTrackedQueryKeys(trackedQuery.id); + } else { + trackedKeys = null; + } + } else { + complete = false; + trackedKeys = trackedQueryManager.getKnownCompleteChildren(query.getPath()); + } + + // TODO[persistence]: Only load the tracked key data rather than load everything and then filter + Node serverCacheNode = storageLayer.serverCache(query.getPath()); + if (trackedKeys != null) { + Node filteredNode = EmptyNode.Empty(); + for (ChildKey key : trackedKeys) { + filteredNode = + filteredNode.updateImmediateChild(key, serverCacheNode.getImmediateChild(key)); + } + return new CacheNode( + IndexedNode.from(filteredNode, query.getIndex()), complete, /*filtered=*/ true); + } else { + return new CacheNode( + IndexedNode.from(serverCacheNode, query.getIndex()), complete, /*filtered=*/ false); + } + } + + @Override + public void updateServerCache(QuerySpec query, Node node) { + if (query.loadsAllData()) { + this.storageLayer.overwriteServerCache(query.getPath(), node); + } else { + this.storageLayer.mergeIntoServerCache(query.getPath(), node); + } + setQueryComplete(query); + doPruneCheckAfterServerUpdate(); + } + + @Override + public void updateServerCache(Path path, CompoundWrite children) { + this.storageLayer.mergeIntoServerCache(path, children); + doPruneCheckAfterServerUpdate(); + } + + @Override + public void setQueryActive(QuerySpec query) { + this.trackedQueryManager.setQueryActive(query); + } + + @Override + public void setQueryInactive(QuerySpec query) { + this.trackedQueryManager.setQueryInactive(query); + } + + @Override + public void setQueryComplete(QuerySpec query) { + if (query.loadsAllData()) { + this.trackedQueryManager.setQueriesComplete(query.getPath()); + } else { + this.trackedQueryManager.setQueryCompleteIfExists(query); + } + } + + @Override + public void setTrackedQueryKeys(QuerySpec query, Set keys) { + assert !query.loadsAllData() : "We should only track keys for filtered queries."; + TrackedQuery trackedQuery = this.trackedQueryManager.findTrackedQuery(query); + assert trackedQuery != null && trackedQuery.active + : "We only expect tracked keys for currently-active queries."; + + this.storageLayer.saveTrackedQueryKeys(trackedQuery.id, keys); + // TODO: In the future we may want to try to prune the no-longer-tracked keys. + } + + @Override + public void updateTrackedQueryKeys(QuerySpec query, Set added, Set removed) { + assert !query.loadsAllData() : "We should only track keys for filtered queries."; + TrackedQuery trackedQuery = this.trackedQueryManager.findTrackedQuery(query); + assert trackedQuery != null && trackedQuery.active + : "We only expect tracked keys for currently-active queries."; + + this.storageLayer.updateTrackedQueryKeys(trackedQuery.id, added, removed); + // TODO: In the future we may want to try to prune the no-longer-tracked keys. + } + + @Override + public T runInTransaction(Callable callable) { + this.storageLayer.beginTransaction(); + try { + T result = callable.call(); + this.storageLayer.setTransactionSuccessful(); + return result; + } catch (Throwable e) { + throw new RuntimeException(e); + } finally { + this.storageLayer.endTransaction(); + } + } + + private void doPruneCheckAfterServerUpdate() { + serverCacheUpdatesSinceLastPruneCheck++; + if (cachePolicy.shouldCheckCacheSize(serverCacheUpdatesSinceLastPruneCheck)) { + if (logger.logsDebug()) { + logger.debug("Reached prune check threshold."); + } + serverCacheUpdatesSinceLastPruneCheck = 0; + boolean canPrune = true; + long cacheSize = storageLayer.serverCacheEstimatedSizeInBytes(); + if (logger.logsDebug()) { + logger.debug("Cache size: " + cacheSize); + } + while (canPrune + && cachePolicy.shouldPrune(cacheSize, trackedQueryManager.countOfPrunableQueries())) { + PruneForest pruneForest = this.trackedQueryManager.pruneOldQueries(cachePolicy); + if (pruneForest.prunesAnything()) { + this.storageLayer.pruneCache(Path.getEmptyPath(), pruneForest); + } else { + canPrune = false; + } + cacheSize = storageLayer.serverCacheEstimatedSizeInBytes(); + if (logger.logsDebug()) { + logger.debug("Cache size after prune: " + cacheSize); + } + } + } + } +} diff --git a/src/main/java/com/google/firebase/database/core/persistence/LRUCachePolicy.java b/src/main/java/com/google/firebase/database/core/persistence/LRUCachePolicy.java new file mode 100644 index 000000000..4d1df623d --- /dev/null +++ b/src/main/java/com/google/firebase/database/core/persistence/LRUCachePolicy.java @@ -0,0 +1,36 @@ +package com.google.firebase.database.core.persistence; + +public class LRUCachePolicy implements CachePolicy { + + private static final long SERVER_UPDATES_BETWEEN_CACHE_SIZE_CHECKS = 1000; + private static final long MAX_NUMBER_OF_PRUNABLE_QUERIES_TO_KEEP = 1000; + private static final float PERCENT_OF_QUERIES_TO_PRUNE_AT_ONCE = + 0.2f; // 20% at a time until we're below our max. + + public final long maxSizeBytes; + + public LRUCachePolicy(long maxSizeBytes) { + this.maxSizeBytes = maxSizeBytes; + } + + @Override + public boolean shouldPrune(long currentSizeBytes, long countOfPrunableQueries) { + return currentSizeBytes > maxSizeBytes + || countOfPrunableQueries > MAX_NUMBER_OF_PRUNABLE_QUERIES_TO_KEEP; + } + + @Override + public boolean shouldCheckCacheSize(long serverUpdatesSinceLastCheck) { + return serverUpdatesSinceLastCheck > SERVER_UPDATES_BETWEEN_CACHE_SIZE_CHECKS; + } + + @Override + public float getPercentOfQueriesToPruneAtOnce() { + return PERCENT_OF_QUERIES_TO_PRUNE_AT_ONCE; + } + + @Override + public long getMaxNumberOfQueriesToKeep() { + return MAX_NUMBER_OF_PRUNABLE_QUERIES_TO_KEEP; + } +} diff --git a/src/main/java/com/google/firebase/database/core/persistence/NoopPersistenceManager.java b/src/main/java/com/google/firebase/database/core/persistence/NoopPersistenceManager.java new file mode 100644 index 000000000..bd271fc03 --- /dev/null +++ b/src/main/java/com/google/firebase/database/core/persistence/NoopPersistenceManager.java @@ -0,0 +1,120 @@ +package com.google.firebase.database.core.persistence; + +import static com.google.firebase.database.utilities.Utilities.hardAssert; + +import com.google.firebase.database.core.CompoundWrite; +import com.google.firebase.database.core.Path; +import com.google.firebase.database.core.UserWriteRecord; +import com.google.firebase.database.core.view.CacheNode; +import com.google.firebase.database.core.view.QuerySpec; +import com.google.firebase.database.snapshot.ChildKey; +import com.google.firebase.database.snapshot.EmptyNode; +import com.google.firebase.database.snapshot.IndexedNode; +import com.google.firebase.database.snapshot.Node; +import java.util.Collections; +import java.util.List; +import java.util.Set; +import java.util.concurrent.Callable; + +public class NoopPersistenceManager implements PersistenceManager { + + private boolean insideTransaction = false; + + @Override + public void saveUserOverwrite(Path path, Node node, long writeId) { + verifyInsideTransaction(); + } + + @Override + public void saveUserMerge(Path path, CompoundWrite children, long writeId) { + verifyInsideTransaction(); + } + + @Override + public void removeUserWrite(long writeId) { + verifyInsideTransaction(); + } + + @Override + public void removeAllUserWrites() { + verifyInsideTransaction(); + } + + @Override + public void applyUserWriteToServerCache(Path path, Node node) { + verifyInsideTransaction(); + } + + @Override + public void applyUserWriteToServerCache(Path path, CompoundWrite merge) { + verifyInsideTransaction(); + } + + @Override + public List loadUserWrites() { + return Collections.emptyList(); + } + + @Override + public CacheNode serverCache(QuerySpec query) { + return new CacheNode( + IndexedNode.from(EmptyNode.Empty(), query.getIndex()), /*complete=*/ + false, /*filtered=*/ + false); + } + + @Override + public void updateServerCache(QuerySpec query, Node node) { + verifyInsideTransaction(); + } + + @Override + public void updateServerCache(Path path, CompoundWrite children) { + verifyInsideTransaction(); + } + + @Override + public void setQueryActive(QuerySpec query) { + verifyInsideTransaction(); + } + + @Override + public void setQueryInactive(QuerySpec query) { + verifyInsideTransaction(); + } + + @Override + public void setQueryComplete(QuerySpec query) { + verifyInsideTransaction(); + } + + @Override + public void setTrackedQueryKeys(QuerySpec query, Set keys) { + verifyInsideTransaction(); + } + + @Override + public void updateTrackedQueryKeys(QuerySpec query, Set added, Set removed) { + verifyInsideTransaction(); + } + + @Override + public T runInTransaction(Callable callable) { + // We still track insideTransaction, so we can catch bugs. + hardAssert( + !insideTransaction, + "runInTransaction called when an existing transaction is already in progress."); + insideTransaction = true; + try { + return callable.call(); + } catch (Throwable e) { + throw new RuntimeException(e); + } finally { + insideTransaction = false; + } + } + + private void verifyInsideTransaction() { + hardAssert(this.insideTransaction, "Transaction expected to already be in progress."); + } +} diff --git a/src/main/java/com/google/firebase/database/core/persistence/PersistenceManager.java b/src/main/java/com/google/firebase/database/core/persistence/PersistenceManager.java new file mode 100644 index 000000000..c4d87fb1d --- /dev/null +++ b/src/main/java/com/google/firebase/database/core/persistence/PersistenceManager.java @@ -0,0 +1,105 @@ +package com.google.firebase.database.core.persistence; + +import com.google.firebase.database.core.CompoundWrite; +import com.google.firebase.database.core.Path; +import com.google.firebase.database.core.UserWriteRecord; +import com.google.firebase.database.core.view.CacheNode; +import com.google.firebase.database.core.view.QuerySpec; +import com.google.firebase.database.snapshot.ChildKey; +import com.google.firebase.database.snapshot.Node; +import java.util.List; +import java.util.Set; +import java.util.concurrent.Callable; + +public interface PersistenceManager { + + /** + * Save a user overwrite + * + * @param path The path for this write + * @param node The node for this write + * @param writeId The write id that was used for this write + */ + void saveUserOverwrite(Path path, Node node, long writeId); + + /** + * Save a user merge + * + * @param path The path for this merge + * @param children The children for this merge + * @param writeId The write id that was used for this merge + */ + void saveUserMerge(Path path, CompoundWrite children, long writeId); + + /** + * Remove a write with the given write id. + * + * @param writeId The write id to remove + */ + void removeUserWrite(long writeId); + + /** + * Removes all writes + */ + void removeAllUserWrites(); + + /** + * @param path Path of user overwrite. + * @param node Data of user write. + */ + void applyUserWriteToServerCache(Path path, Node node); + + /** + * @param path Path of user merge. + * @param merge Data of user merge. + */ + void applyUserWriteToServerCache(Path path, CompoundWrite merge); + + /** + * Return a list of all writes that were persisted + * + * @return The list of writes + */ + List loadUserWrites(); + + /** + * Returns any cached node or children as a CacheNode. The query is *not* used to filter the node + * but rather to determine if it can be considered complete. + * + * @param query The query at the path + * @return The cached node or an empty CacheNode if no cache is available + */ + CacheNode serverCache(QuerySpec query); + + /** + * Overwrite the server cache with the given node for a given query. The query is considered to be + * complete after saving this node. + * + * @param query The query for which to apply this overwrite. + * @param node The node to replace in the cache at the given path + */ + void updateServerCache(QuerySpec query, Node node); + + /** + * Update the server cache at the given path with the given merge. + * + *

NOTE: This doesn't mark any queries complete, since the common case is that there's already + * a complete query above this location. + * + * @param path The path for this merge + * @param children The children to update + */ + void updateServerCache(Path path, CompoundWrite children); + + void setQueryActive(QuerySpec query); + + void setQueryInactive(QuerySpec query); + + void setQueryComplete(QuerySpec query); + + void setTrackedQueryKeys(QuerySpec query, Set keys); + + void updateTrackedQueryKeys(QuerySpec query, Set added, Set removed); + + T runInTransaction(Callable callable); +} diff --git a/src/main/java/com/google/firebase/database/core/persistence/PersistenceStorageEngine.java b/src/main/java/com/google/firebase/database/core/persistence/PersistenceStorageEngine.java new file mode 100644 index 000000000..f73d28d89 --- /dev/null +++ b/src/main/java/com/google/firebase/database/core/persistence/PersistenceStorageEngine.java @@ -0,0 +1,115 @@ +package com.google.firebase.database.core.persistence; + +import com.google.firebase.database.core.CompoundWrite; +import com.google.firebase.database.core.Path; +import com.google.firebase.database.core.UserWriteRecord; +import com.google.firebase.database.snapshot.ChildKey; +import com.google.firebase.database.snapshot.Node; +import java.util.List; +import java.util.Set; + +/** + * This class provides an interface to a persistent cache. The persistence cache persists user + * writes, cached server data and the corresponding completeness tree. There exists one + * PersistentCache per repo. + */ +public interface PersistenceStorageEngine { + + /** + * Save a user overwrite + * + * @param path The path for this write + * @param node The node for this write + * @param writeId The write id that was used for this write + */ + void saveUserOverwrite(Path path, Node node, long writeId); + + /** + * Save a user merge + * + * @param path The path for this merge + * @param children The children for this merge + * @param writeId The write id that was used for this merge + */ + void saveUserMerge(Path path, CompoundWrite children, long writeId); + + /** + * Remove a write with the given write id. + * + * @param writeId The write id to remove + */ + void removeUserWrite(long writeId); + + /** + * Return a list of all writes that were persisted + * + * @return The list of writes + */ + List loadUserWrites(); + + /** + * Removes all user writes + */ + void removeAllUserWrites(); + + /** + * Loads all data at a path. It has no knowledge of whether the data is "complete" or not. + * + * @param path The path at which to load the node. + * @return The node that was loaded. + */ + Node serverCache(Path path); + + /** + * Overwrite the server cache at the given path with the given node. + * + * @param path The path to update + * @param node The node to write to the cache. + */ + void overwriteServerCache(Path path, Node node); + + /** + * Update the server cache at the given path with the given node, merging each child into the + * cache. + * + * @param path The path to update + * @param node The node to merge into the cache. + */ + void mergeIntoServerCache(Path path, Node node); + + /** + * Update the server cache at the given path with the given children, merging each one into the + * cache. + * + * @param path The path for this merge + * @param children The children to update + */ + void mergeIntoServerCache(Path path, CompoundWrite children); + + long serverCacheEstimatedSizeInBytes(); + + void saveTrackedQuery(TrackedQuery trackedQuery); + + void deleteTrackedQuery(long trackedQueryId); + + List loadTrackedQueries(); + + void resetPreviouslyActiveTrackedQueries(long lastUse); + + void saveTrackedQueryKeys(long trackedQueryId, Set keys); + + void updateTrackedQueryKeys( + long trackedQueryId, Set added, Set removed); + + Set loadTrackedQueryKeys(long trackedQueryId); + + Set loadTrackedQueryKeys(Set trackedQueryIds); + + void pruneCache(Path root, PruneForest pruneForest); + + void beginTransaction(); + + void endTransaction(); + + void setTransactionSuccessful(); +} diff --git a/src/main/java/com/google/firebase/database/core/persistence/PruneForest.java b/src/main/java/com/google/firebase/database/core/persistence/PruneForest.java new file mode 100644 index 000000000..7a9102650 --- /dev/null +++ b/src/main/java/com/google/firebase/database/core/persistence/PruneForest.java @@ -0,0 +1,197 @@ +package com.google.firebase.database.core.persistence; + +import com.google.firebase.database.collection.ImmutableSortedMap; +import com.google.firebase.database.core.Path; +import com.google.firebase.database.core.utilities.ImmutableTree; +import com.google.firebase.database.core.utilities.Predicate; +import com.google.firebase.database.snapshot.ChildKey; +import java.util.Set; + +/** + * Forest of "prune trees" where a prune tree is a location that can be pruned with a tree of + * descendants that must be excluded from the pruning. + * + *

Internally we store this as a single tree of booleans with the following characteristics: * + * 'true' indicates a location that can be pruned, possibly with some excluded descendants. * + * 'false' indicates a location that we should keep (i.e. exclude from pruning). * 'true' (prune) + * cannot be a descendant of 'false' (keep). This will trigger an exception. * 'true' cannot be a + * descendant of 'true' (we'll just keep the more shallow 'true'). * 'false' cannot be a descendant + * of 'false' (we'll just keep the more shallow 'false'). + */ +public class PruneForest { + + private final ImmutableTree pruneForest; + + private static final Predicate KEEP_PREDICATE = + new Predicate() { + @Override + public boolean evaluate(Boolean prune) { + return !prune; + } + }; + + private static final Predicate PRUNE_PREDICATE = + new Predicate() { + @Override + public boolean evaluate(Boolean prune) { + return prune; + } + }; + + private static final ImmutableTree PRUNE_TREE = new ImmutableTree<>(true); + private static final ImmutableTree KEEP_TREE = new ImmutableTree<>(false); + + public PruneForest() { + this.pruneForest = ImmutableTree.emptyInstance(); + } + + private PruneForest(ImmutableTree pruneForest) { + this.pruneForest = pruneForest; + } + + public boolean prunesAnything() { + return this.pruneForest.containsMatchingValue(PRUNE_PREDICATE); + } + + /** + * Indicates that path is marked for pruning, so anything below it that didn't have keep() called + * on it should be pruned. + * + * @param path The path in question + * @return True if we should prune descendants that didn't have keep() called on them. + */ + public boolean shouldPruneUnkeptDescendants(Path path) { + Boolean shouldPrune = this.pruneForest.leafMostValue(path); + return shouldPrune != null && shouldPrune; + } + + public boolean shouldKeep(Path path) { + Boolean shouldPrune = this.pruneForest.leafMostValue(path); + return shouldPrune != null && !shouldPrune; + } + + public boolean affectsPath(Path path) { + return this.pruneForest.rootMostValue(path) != null + || !this.pruneForest.subtree(path).isEmpty(); + } + + public PruneForest child(ChildKey key) { + ImmutableTree childPruneTree = this.pruneForest.getChild(key); + if (childPruneTree == null) { + childPruneTree = new ImmutableTree<>(this.pruneForest.getValue()); + } else { + if (childPruneTree.getValue() == null && this.pruneForest.getValue() != null) { + childPruneTree = childPruneTree.set(Path.getEmptyPath(), this.pruneForest.getValue()); + } + } + return new PruneForest(childPruneTree); + } + + public PruneForest child(Path path) { + if (path.isEmpty()) { + return this; + } else { + return this.child(path.getFront()).child(path.popFront()); + } + } + + public T foldKeptNodes(T startValue, final ImmutableTree.TreeVisitor treeVisitor) { + return this.pruneForest.fold( + startValue, + new ImmutableTree.TreeVisitor() { + @Override + public T onNodeValue(Path relativePath, Boolean prune, T accum) { + if (!prune) { + return treeVisitor.onNodeValue(relativePath, null, accum); + } else { + return accum; + } + } + }); + } + + public PruneForest prune(Path path) { + if (this.pruneForest.rootMostValueMatching(path, KEEP_PREDICATE) != null) { + throw new IllegalArgumentException("Can't prune path that was kept previously!"); + } + if (this.pruneForest.rootMostValueMatching(path, PRUNE_PREDICATE) != null) { + // This path will already be pruned + return this; + } else { + ImmutableTree newPruneTree = this.pruneForest.setTree(path, PRUNE_TREE); + return new PruneForest(newPruneTree); + } + } + + public PruneForest keep(Path path) { + if (this.pruneForest.rootMostValueMatching(path, KEEP_PREDICATE) != null) { + // This path will already be kept + return this; + } else { + ImmutableTree newPruneTree = this.pruneForest.setTree(path, KEEP_TREE); + return new PruneForest(newPruneTree); + } + } + + public PruneForest keepAll(Path path, Set children) { + if (this.pruneForest.rootMostValueMatching(path, KEEP_PREDICATE) != null) { + // This path will already be kept + return this; + } else { + return doAll(path, children, KEEP_TREE); + } + } + + public PruneForest pruneAll(Path path, Set children) { + if (this.pruneForest.rootMostValueMatching(path, KEEP_PREDICATE) != null) { + throw new IllegalArgumentException("Can't prune path that was kept previously!"); + } + + if (this.pruneForest.rootMostValueMatching(path, PRUNE_PREDICATE) != null) { + // This path will already be kept + return this; + } else { + return doAll(path, children, PRUNE_TREE); + } + } + + private PruneForest doAll( + Path path, Set children, ImmutableTree keepOrPruneTree) { + ImmutableTree subtree = this.pruneForest.subtree(path); + ImmutableSortedMap> childrenMap = subtree.getChildren(); + for (ChildKey key : children) { + childrenMap = childrenMap.insert(key, keepOrPruneTree); + } + return new PruneForest( + this.pruneForest.setTree( + path, new ImmutableTree<>(subtree.getValue(), childrenMap))); + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof PruneForest)) { + return false; + } + + PruneForest that = (PruneForest) o; + + if (!pruneForest.equals(that.pruneForest)) { + return false; + } + + return true; + } + + @Override + public int hashCode() { + return pruneForest.hashCode(); + } + + @Override + public String toString() { + return "{PruneForest:" + pruneForest.toString() + "}"; + } +} diff --git a/src/main/java/com/google/firebase/database/core/persistence/TrackedQuery.java b/src/main/java/com/google/firebase/database/core/persistence/TrackedQuery.java new file mode 100644 index 000000000..3120f5050 --- /dev/null +++ b/src/main/java/com/google/firebase/database/core/persistence/TrackedQuery.java @@ -0,0 +1,80 @@ +package com.google.firebase.database.core.persistence; + +import com.google.firebase.database.core.view.QuerySpec; + +public class TrackedQuery { + + public final long id; + public final QuerySpec querySpec; + public final long lastUse; + public final boolean complete; + public final boolean active; + + public TrackedQuery( + long id, QuerySpec querySpec, long lastUse, boolean complete, boolean active) { + this.id = id; + if (querySpec.loadsAllData() && !querySpec.isDefault()) { + throw new IllegalArgumentException( + "Can't create TrackedQuery for a non-default query that loads all data"); + } + this.querySpec = querySpec; + this.lastUse = lastUse; + this.complete = complete; + this.active = active; + } + + public TrackedQuery updateLastUse(long lastUse) { + return new TrackedQuery(this.id, this.querySpec, lastUse, this.complete, this.active); + } + + public TrackedQuery setComplete() { + return new TrackedQuery(this.id, this.querySpec, this.lastUse, true, this.active); + } + + public TrackedQuery setActiveState(boolean isActive) { + return new TrackedQuery(this.id, this.querySpec, this.lastUse, this.complete, isActive); + } + + @Override + public boolean equals(Object o) { + if (o == this) { + return true; + } + if (o == null || o.getClass() != this.getClass()) { + return false; + } + + TrackedQuery query = (TrackedQuery) o; + return this.id == query.id + && this.querySpec.equals(query.querySpec) + && this.lastUse == query.lastUse + && this.complete == query.complete + && this.active == query.active; + } + + @Override + public int hashCode() { + int result = Long.valueOf(this.id).hashCode(); + result = 31 * result + this.querySpec.hashCode(); + result = 31 * result + Long.valueOf(this.lastUse).hashCode(); + result = 31 * result + Boolean.valueOf(this.complete).hashCode(); + result = 31 * result + Boolean.valueOf(this.active).hashCode(); + return result; + } + + @Override + public String toString() { + return "TrackedQuery{" + + "id=" + + id + + ", querySpec=" + + querySpec + + ", lastUse=" + + lastUse + + ", complete=" + + complete + + ", active=" + + active + + "}"; + } +} diff --git a/src/main/java/com/google/firebase/database/core/persistence/TrackedQueryManager.java b/src/main/java/com/google/firebase/database/core/persistence/TrackedQueryManager.java new file mode 100644 index 000000000..26d42d0ba --- /dev/null +++ b/src/main/java/com/google/firebase/database/core/persistence/TrackedQueryManager.java @@ -0,0 +1,404 @@ +package com.google.firebase.database.core.persistence; + +import static com.google.firebase.database.utilities.Utilities.hardAssert; + +import com.google.firebase.database.core.Path; +import com.google.firebase.database.core.utilities.ImmutableTree; +import com.google.firebase.database.core.utilities.Predicate; +import com.google.firebase.database.core.view.QueryParams; +import com.google.firebase.database.core.view.QuerySpec; +import com.google.firebase.database.logging.LogWrapper; +import com.google.firebase.database.snapshot.ChildKey; +import com.google.firebase.database.utilities.Clock; +import com.google.firebase.database.utilities.Utilities; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +public class TrackedQueryManager { + + private static final Predicate> HAS_DEFAULT_COMPLETE_PREDICATE = + new Predicate>() { + @Override + public boolean evaluate(Map trackedQueries) { + TrackedQuery trackedQuery = trackedQueries.get(QueryParams.DEFAULT_PARAMS); + return trackedQuery != null && trackedQuery.complete; + } + }; + + private static final Predicate> HAS_ACTIVE_DEFAULT_PREDICATE = + new Predicate>() { + @Override + public boolean evaluate(Map trackedQueries) { + TrackedQuery trackedQuery = trackedQueries.get(QueryParams.DEFAULT_PARAMS); + return trackedQuery != null && trackedQuery.active; + } + }; + + private static final Predicate IS_QUERY_PRUNABLE_PREDICATE = + new Predicate() { + @Override + public boolean evaluate(TrackedQuery query) { + return !query.active; + } + }; + + private static final Predicate IS_QUERY_UNPRUNABLE_PREDICATE = + new Predicate() { + @Override + public boolean evaluate(TrackedQuery query) { + return !IS_QUERY_PRUNABLE_PREDICATE.evaluate(query); + } + }; + + // In-memory cache of tracked queries. Should always be in-sync with the DB. + private ImmutableTree> trackedQueryTree; + + // DB, where we permanently store tracked queries. + private final PersistenceStorageEngine storageLayer; + + private final LogWrapper logger; + private final Clock clock; + + // ID we'll assign to the next tracked query. + private long currentQueryId = 0; + + private static void assertValidTrackedQuery(QuerySpec query) { + hardAssert( + !query.loadsAllData() || query.isDefault(), + "Can't have tracked non-default query that loads all data"); + } + + private static QuerySpec normalizeQuery(QuerySpec query) { + // If the query loadsAllData, we don't care about orderBy. + // So just treat it as a default query. + return query.loadsAllData() ? QuerySpec.defaultQueryAtPath(query.getPath()) : query; + } + + public TrackedQueryManager( + PersistenceStorageEngine storageLayer, LogWrapper logger, Clock clock) { + this.storageLayer = storageLayer; + this.logger = logger; + this.clock = clock; + this.trackedQueryTree = new ImmutableTree<>(null); + + resetPreviouslyActiveTrackedQueries(); + + // Populate our cache from the storage layer. + List trackedQueries = this.storageLayer.loadTrackedQueries(); + for (TrackedQuery query : trackedQueries) { + currentQueryId = Math.max(query.id + 1, currentQueryId); + cacheTrackedQuery(query); + } + } + + private void resetPreviouslyActiveTrackedQueries() { + // Minor hack: We do most of our transactions at the SyncTree level, but it is very inconvenient + // to do so here, so the transaction goes here. :-/ + try { + this.storageLayer.beginTransaction(); + this.storageLayer.resetPreviouslyActiveTrackedQueries(clock.millis()); + this.storageLayer.setTransactionSuccessful(); + } finally { + this.storageLayer.endTransaction(); + } + } + + public TrackedQuery findTrackedQuery(QuerySpec query) { + query = normalizeQuery(query); + Map set = this.trackedQueryTree.get(query.getPath()); + return (set != null) ? set.get(query.getParams()) : null; + } + + public void removeTrackedQuery(QuerySpec query) { + query = normalizeQuery(query); + TrackedQuery trackedQuery = findTrackedQuery(query); + assert trackedQuery != null : "Query must exist to be removed."; + + this.storageLayer.deleteTrackedQuery(trackedQuery.id); + Map trackedQueries = this.trackedQueryTree.get(query.getPath()); + trackedQueries.remove(query.getParams()); + if (trackedQueries.isEmpty()) { + this.trackedQueryTree = this.trackedQueryTree.remove(query.getPath()); + } + } + + public void setQueryActive(QuerySpec query) { + setQueryActiveFlag(query, true); + } + + public void setQueryInactive(QuerySpec query) { + setQueryActiveFlag(query, false); + } + + private void setQueryActiveFlag(QuerySpec query, boolean isActive) { + query = normalizeQuery(query); + TrackedQuery trackedQuery = findTrackedQuery(query); + + // Regardless of whether it's now active or no longer active, we update the lastUse time. + long lastUse = clock.millis(); + if (trackedQuery != null) { + trackedQuery = trackedQuery.updateLastUse(lastUse).setActiveState(isActive); + } else { + assert isActive : "If we're setting the query to inactive, we should already be tracking it!"; + trackedQuery = + new TrackedQuery(this.currentQueryId++, query, lastUse, /*complete=*/ false, isActive); + } + + saveTrackedQuery(trackedQuery); + } + + public void setQueryCompleteIfExists(QuerySpec query) { + query = normalizeQuery(query); + TrackedQuery trackedQuery = findTrackedQuery(query); + if (trackedQuery != null && !trackedQuery.complete) { + saveTrackedQuery(trackedQuery.setComplete()); + } + } + + public void setQueriesComplete(Path path) { + this.trackedQueryTree + .subtree(path) + .foreach( + new ImmutableTree.TreeVisitor, Void>() { + @Override + public Void onNodeValue( + Path relativePath, Map value, Void accum) { + for (Map.Entry e : value.entrySet()) { + TrackedQuery trackedQuery = e.getValue(); + if (!trackedQuery.complete) { + saveTrackedQuery(trackedQuery.setComplete()); + } + } + return null; + } + }); + } + + public boolean isQueryComplete(QuerySpec query) { + if (this.includedInDefaultCompleteQuery(query.getPath())) { + return true; + } else if (query.loadsAllData()) { + // We didn't find a default complete query, so must not be complete. + return false; + } else { + Map trackedQueries = this.trackedQueryTree.get(query.getPath()); + return trackedQueries != null + && trackedQueries.containsKey(query.getParams()) + && trackedQueries.get(query.getParams()).complete; + } + } + + public PruneForest pruneOldQueries(CachePolicy cachePolicy) { + List prunable = getQueriesMatching(IS_QUERY_PRUNABLE_PREDICATE); + long countToPrune = calculateCountToPrune(cachePolicy, prunable.size()); + PruneForest forest = new PruneForest(); + + if (logger.logsDebug()) { + logger.debug( + "Pruning old queries. Prunable: " + + prunable.size() + + " Count to prune: " + + countToPrune); + } + + Collections.sort( + prunable, + new Comparator() { + @Override + public int compare(TrackedQuery q1, TrackedQuery q2) { + return Utilities.compareLongs(q1.lastUse, q2.lastUse); + } + }); + + for (int i = 0; i < countToPrune; i++) { + TrackedQuery toPrune = prunable.get(i); + forest = forest.prune(toPrune.querySpec.getPath()); + removeTrackedQuery(toPrune.querySpec); + } + + // Keep the rest of the prunable queries. + for (int i = (int) countToPrune; i < prunable.size(); i++) { + TrackedQuery toKeep = prunable.get(i); + forest = forest.keep(toKeep.querySpec.getPath()); + } + + // Also keep the unprunable queries. + List unprunable = getQueriesMatching(IS_QUERY_UNPRUNABLE_PREDICATE); + if (logger.logsDebug()) { + logger.debug("Unprunable queries: " + unprunable.size()); + } + for (TrackedQuery toKeep : unprunable) { + forest = forest.keep(toKeep.querySpec.getPath()); + } + + return forest; + } + + private static long calculateCountToPrune(CachePolicy cachePolicy, long prunableCount) { + long countToKeep = prunableCount; + + // prune by percentage. + float percentToKeep = 1 - cachePolicy.getPercentOfQueriesToPruneAtOnce(); + countToKeep = (long) Math.floor(countToKeep * percentToKeep); + + // Make sure we're not keeping more than the max. + countToKeep = Math.min(countToKeep, cachePolicy.getMaxNumberOfQueriesToKeep()); + + // Now we know how many to prune. + return prunableCount - countToKeep; + } + + /** + * Uses our tracked queries to figure out what complete children we have. + * + * @param path Path to find complete data children under. + * @return Set of complete ChildKeys + */ + public Set getKnownCompleteChildren(Path path) { + assert !this.isQueryComplete(QuerySpec.defaultQueryAtPath(path)) : "Path is fully complete."; + + Set completeChildren = new HashSet<>(); + // First, get complete children from any queries at this location. + Set queryIds = filteredQueryIdsAtPath(path); + if (!queryIds.isEmpty()) { + completeChildren.addAll(storageLayer.loadTrackedQueryKeys(queryIds)); + } + + // Second, get any complete default queries immediately below us. + for (Map.Entry>> childEntry : + this.trackedQueryTree.subtree(path).getChildren()) { + ChildKey childKey = childEntry.getKey(); + ImmutableTree> childTree = childEntry.getValue(); + if (childTree.getValue() != null + && HAS_DEFAULT_COMPLETE_PREDICATE.evaluate(childTree.getValue())) { + completeChildren.add(childKey); + } + } + + return completeChildren; + } + + public void ensureCompleteTrackedQuery(Path path) { + if (!this.includedInDefaultCompleteQuery(path)) { + // TODO[persistence]: What if it's included in the tracked keys of a query? Do we still want + // to add a new tracked query for it? + + QuerySpec querySpec = QuerySpec.defaultQueryAtPath(path); + TrackedQuery trackedQuery = findTrackedQuery(querySpec); + if (trackedQuery == null) { + trackedQuery = + new TrackedQuery( + this.currentQueryId++, + querySpec, + clock.millis(), /*complete=*/ + true, /*active=*/ + false); + } else { + assert !trackedQuery.complete : "This should have been handled above!"; + trackedQuery = trackedQuery.setComplete(); + } + saveTrackedQuery(trackedQuery); + } + } + + public boolean hasActiveDefaultQuery(Path path) { + return this.trackedQueryTree.rootMostValueMatching(path, HAS_ACTIVE_DEFAULT_PREDICATE) != null; + } + + public long countOfPrunableQueries() { + return getQueriesMatching(IS_QUERY_PRUNABLE_PREDICATE).size(); + } + + // Used for tests to assert we're still in-sync with the DB. Don't call it in production, since + // it's slow. + void verifyCache() { + List storedTrackedQueries = this.storageLayer.loadTrackedQueries(); + + final List trackedQueries = new ArrayList<>(); + this.trackedQueryTree.foreach( + new ImmutableTree.TreeVisitor, Void>() { + @Override + public Void onNodeValue( + Path relativePath, Map value, Void accum) { + for (TrackedQuery trackedQuery : value.values()) { + trackedQueries.add(trackedQuery); + } + return null; + } + }); + Collections.sort( + trackedQueries, + new Comparator() { + @Override + public int compare(TrackedQuery o1, TrackedQuery o2) { + return Utilities.compareLongs(o1.id, o2.id); + } + }); + + hardAssert( + storedTrackedQueries.equals(trackedQueries), + "Tracked queries out of sync. Tracked queries: " + + trackedQueries + + " Stored queries: " + + storedTrackedQueries); + } + + private boolean includedInDefaultCompleteQuery(Path path) { + return this.trackedQueryTree.findRootMostMatchingPath(path, HAS_DEFAULT_COMPLETE_PREDICATE) + != null; + } + + private Set filteredQueryIdsAtPath(Path path) { + final Set ids = new HashSet<>(); + + Map queries = this.trackedQueryTree.get(path); + if (queries != null) { + for (TrackedQuery query : queries.values()) { + if (!query.querySpec.loadsAllData()) { + ids.add(query.id); + } + } + } + return ids; + } + + private void cacheTrackedQuery(TrackedQuery query) { + assertValidTrackedQuery(query.querySpec); + + Map trackedSet = + this.trackedQueryTree.get(query.querySpec.getPath()); + if (trackedSet == null) { + trackedSet = new HashMap<>(); + this.trackedQueryTree = this.trackedQueryTree.set(query.querySpec.getPath(), trackedSet); + } + + // Sanity check. + TrackedQuery existing = trackedSet.get(query.querySpec.getParams()); + hardAssert(existing == null || existing.id == query.id); + + trackedSet.put(query.querySpec.getParams(), query); + } + + private void saveTrackedQuery(TrackedQuery query) { + cacheTrackedQuery(query); + storageLayer.saveTrackedQuery(query); + } + + private List getQueriesMatching(Predicate predicate) { + List matching = new ArrayList<>(); + for (Map.Entry> entry : this.trackedQueryTree) { + for (TrackedQuery query : entry.getValue().values()) { + if (predicate.evaluate(query)) { + matching.add(query); + } + } + } + return matching; + } +} diff --git a/src/main/java/com/google/firebase/database/core/utilities/ImmutableTree.java b/src/main/java/com/google/firebase/database/core/utilities/ImmutableTree.java new file mode 100644 index 000000000..2b93e0092 --- /dev/null +++ b/src/main/java/com/google/firebase/database/core/utilities/ImmutableTree.java @@ -0,0 +1,343 @@ +package com.google.firebase.database.core.utilities; + +import com.google.firebase.database.collection.ImmutableSortedMap; +import com.google.firebase.database.collection.StandardComparator; +import com.google.firebase.database.core.Path; +import com.google.firebase.database.snapshot.ChildKey; +import java.util.AbstractMap; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Iterator; +import java.util.List; +import java.util.Map; + +/** */ +@SuppressWarnings("rawtypes") +public class ImmutableTree implements Iterable> { + + private final T value; + private final ImmutableSortedMap> children; + + /** */ + public interface TreeVisitor { + + R onNodeValue(Path relativePath, T value, R accum); + } + + private static final ImmutableSortedMap EMPTY_CHILDREN = + ImmutableSortedMap.Builder.emptyMap(StandardComparator.getComparator(ChildKey.class)); + + @SuppressWarnings("unchecked") + private static final ImmutableTree EMPTY = new ImmutableTree<>(null, EMPTY_CHILDREN); + + @SuppressWarnings("unchecked") + public static ImmutableTree emptyInstance() { + return EMPTY; + } + + public ImmutableTree(T value, ImmutableSortedMap> children) { + this.value = value; + this.children = children; + } + + @SuppressWarnings("unchecked") + public ImmutableTree(T value) { + this(value, EMPTY_CHILDREN); + } + + public T getValue() { + return this.value; + } + + public ImmutableSortedMap> getChildren() { + return this.children; + } + + public boolean isEmpty() { + return this.value == null && this.children.isEmpty(); + } + + public Path findRootMostMatchingPath(Path relativePath, Predicate predicate) { + if (this.value != null && predicate.evaluate(this.value)) { + return Path.getEmptyPath(); + } else { + if (relativePath.isEmpty()) { + return null; + } else { + ChildKey front = relativePath.getFront(); + ImmutableTree child = this.children.get(front); + if (child != null) { + Path path = child.findRootMostMatchingPath(relativePath.popFront(), predicate); + if (path != null) { + // TODO: this seems inefficient + return new Path(front).child(path); + } else { + return null; + } + } else { + return null; + } + } + } + } + + public Path findRootMostPathWithValue(Path relativePath) { + return findRootMostMatchingPath(relativePath, Predicate.TRUE); + } + + public T rootMostValue(Path relativePath) { + return rootMostValueMatching(relativePath, Predicate.TRUE); + } + + public T rootMostValueMatching(Path relativePath, Predicate predicate) { + if (this.value != null && predicate.evaluate(this.value)) { + return this.value; + } else { + ImmutableTree currentTree = this; + for (ChildKey key : relativePath) { + currentTree = currentTree.children.get(key); + if (currentTree == null) { + return null; + } else if (currentTree.value != null && predicate.evaluate(currentTree.value)) { + return currentTree.value; + } + } + return null; + } + } + + public T leafMostValue(Path relativePath) { + return leafMostValueMatching(relativePath, Predicate.TRUE); + } + + /** + * Returns the deepest value found between the root and the specified path that matches the + * predicate. + * + * @param path Path along which to look for matching values. + * @param predicate The predicate to evaluate values against. + * @return The deepest matching value, or null if no value matches. + */ + public T leafMostValueMatching(Path path, Predicate predicate) { + T currentValue = (this.value != null && predicate.evaluate(this.value)) ? this.value : null; + ImmutableTree currentTree = this; + for (ChildKey key : path) { + currentTree = currentTree.children.get(key); + if (currentTree == null) { + return currentValue; + } else { + if (currentTree.value != null && predicate.evaluate(currentTree.value)) { + currentValue = currentTree.value; + } + } + } + return currentValue; + } + + public boolean containsMatchingValue(Predicate predicate) { + if (this.value != null && predicate.evaluate(this.value)) { + return true; + } else { + for (Map.Entry> subtree : this.children) { + if (subtree.getValue().containsMatchingValue(predicate)) { + return true; + } + } + return false; + } + } + + public ImmutableTree getChild(ChildKey child) { + ImmutableTree childTree = this.children.get(child); + if (childTree != null) { + return childTree; + } else { + return emptyInstance(); + } + } + + public ImmutableTree subtree(Path relativePath) { + if (relativePath.isEmpty()) { + return this; + } else { + ChildKey front = relativePath.getFront(); + ImmutableTree childTree = this.children.get(front); + if (childTree != null) { + return childTree.subtree(relativePath.popFront()); + } else { + return emptyInstance(); + } + } + } + + public ImmutableTree set(Path relativePath, T value) { + if (relativePath.isEmpty()) { + return new ImmutableTree<>(value, this.children); + } else { + ChildKey front = relativePath.getFront(); + ImmutableTree child = this.children.get(front); + if (child == null) { + child = emptyInstance(); + } + ImmutableTree newChild = child.set(relativePath.popFront(), value); + ImmutableSortedMap> newChildren = + this.children.insert(front, newChild); + return new ImmutableTree<>(this.value, newChildren); + } + } + + public ImmutableTree remove(Path relativePath) { + if (relativePath.isEmpty()) { + if (this.children.isEmpty()) { + return emptyInstance(); + } else { + return new ImmutableTree<>(null, this.children); + } + } else { + ChildKey front = relativePath.getFront(); + ImmutableTree child = this.children.get(front); + if (child != null) { + ImmutableTree newChild = child.remove(relativePath.popFront()); + ImmutableSortedMap> newChildren; + if (newChild.isEmpty()) { + newChildren = this.children.remove(front); + } else { + newChildren = this.children.insert(front, newChild); + } + if (this.value == null && newChildren.isEmpty()) { + return emptyInstance(); + } else { + return new ImmutableTree<>(this.value, newChildren); + } + } else { + return this; + } + } + } + + public T get(Path relativePath) { + if (relativePath.isEmpty()) { + return this.value; + } else { + ChildKey front = relativePath.getFront(); + ImmutableTree child = this.children.get(front); + if (child != null) { + return child.get(relativePath.popFront()); + } else { + return null; + } + } + } + + public ImmutableTree setTree(Path relativePath, ImmutableTree newTree) { + if (relativePath.isEmpty()) { + return newTree; + } else { + ChildKey front = relativePath.getFront(); + ImmutableTree child = this.children.get(front); + if (child == null) { + child = emptyInstance(); + } + ImmutableTree newChild = child.setTree(relativePath.popFront(), newTree); + ImmutableSortedMap> newChildren; + if (newChild.isEmpty()) { + newChildren = this.children.remove(front); + } else { + newChildren = this.children.insert(front, newChild); + } + return new ImmutableTree<>(this.value, newChildren); + } + } + + public void foreach(TreeVisitor visitor) { + fold(Path.getEmptyPath(), visitor, null); + } + + public R fold(R accum, TreeVisitor visitor) { + return fold(Path.getEmptyPath(), visitor, accum); + } + + private R fold(Path relativePath, TreeVisitor visitor, R accum) { + for (Map.Entry> subtree : this.children) { + accum = subtree.getValue().fold(relativePath.child(subtree.getKey()), visitor, accum); + } + if (this.value != null) { + accum = visitor.onNodeValue(relativePath, this.value, accum); + } + return accum; + } + + public Collection values() { + final ArrayList list = new ArrayList<>(); + this.foreach( + new TreeVisitor() { + @Override + public Void onNodeValue(Path relativePath, T value, Void accum) { + list.add(value); + return null; + } + }); + return list; + } + + @Override + public Iterator> iterator() { + // This could probably be done more efficient than prefilling a list, however, it's also a bit + // tricky as we have to potentially scan all subtrees for a value that exists. Since iterators + // are consumed fully in most cases, this should give a fairly efficient implementation in most + // cases. + final List> list = new ArrayList<>(); + this.foreach( + new TreeVisitor() { + @Override + public Void onNodeValue(Path relativePath, T value, Void accum) { + list.add(new AbstractMap.SimpleImmutableEntry<>(relativePath, value)); + return null; + } + }); + return list.iterator(); + } + + @Override + public String toString() { + StringBuilder builder = new StringBuilder(); + builder.append("ImmutableTree { value="); + builder.append(getValue()); + builder.append(", children={"); + for (Map.Entry> child : children) { + builder.append(child.getKey().asString()); + builder.append("="); + builder.append(child.getValue()); + } + builder.append("} }"); + return builder.toString(); + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + + ImmutableTree that = (ImmutableTree) o; + + if (children != null ? !children.equals(that.children) : that.children != null) { + return false; + } + if (value != null ? !value.equals(that.value) : that.value != null) { + return false; + } + + return true; + } + + @Override + public int hashCode() { + int result = value != null ? value.hashCode() : 0; + result = 31 * result + (children != null ? children.hashCode() : 0); + return result; + } +} diff --git a/src/main/java/com/google/firebase/database/core/utilities/Predicate.java b/src/main/java/com/google/firebase/database/core/utilities/Predicate.java new file mode 100644 index 000000000..c67e19119 --- /dev/null +++ b/src/main/java/com/google/firebase/database/core/utilities/Predicate.java @@ -0,0 +1,14 @@ +package com.google.firebase.database.core.utilities; + +public interface Predicate { + + boolean evaluate(T object); + + Predicate TRUE = + new Predicate() { + @Override + public boolean evaluate(Object object) { + return true; + } + }; +} diff --git a/src/main/java/com/google/firebase/database/core/utilities/Tree.java b/src/main/java/com/google/firebase/database/core/utilities/Tree.java new file mode 100644 index 000000000..e13b1e6b6 --- /dev/null +++ b/src/main/java/com/google/firebase/database/core/utilities/Tree.java @@ -0,0 +1,181 @@ +package com.google.firebase.database.core.utilities; + +import com.google.firebase.database.core.Path; +import com.google.firebase.database.snapshot.ChildKey; +import java.util.Map; + +/** + * User: greg Date: 5/16/13 Time: 4:16 PM + */ +public class Tree { + + /** */ + public interface TreeVisitor { + + void visitTree(Tree tree); + } + + /** */ + public interface TreeFilter { + + boolean filterTreeNode(Tree tree); + } + + private ChildKey name; + private Tree parent; + private TreeNode node; + + public Tree(ChildKey name, Tree parent, TreeNode node) { + this.name = name; + this.parent = parent; + this.node = node; + } + + public Tree() { + this(null, null, new TreeNode()); + } + + public TreeNode lastNodeOnPath(Path path) { + TreeNode current = this.node; + ChildKey next = path.getFront(); + while (next != null) { + TreeNode childNode = + current.children.containsKey(next) ? current.children.get(next) : null; + if (childNode == null) { + return current; + } + current = childNode; + path = path.popFront(); + next = path.getFront(); + } + return current; + } + + public Tree subTree(Path path) { + Tree child = this; + ChildKey next = path.getFront(); + while (next != null) { + TreeNode childNode = + child.node.children.containsKey(next) ? child.node.children.get(next) : new TreeNode(); + child = new Tree<>(next, child, childNode); + path = path.popFront(); + next = path.getFront(); + } + return child; + } + + public T getValue() { + return node.value; + } + + public void setValue(T value) { + node.value = value; + updateParents(); + } + + public Tree getParent() { + return parent; + } + + public ChildKey getName() { + return name; + } + + public Path getPath() { + if (parent != null) { + assert name != null; + return parent.getPath().child(name); + } else { + return (name != null) ? new Path(name) : Path.getEmptyPath(); + } + } + + public boolean hasChildren() { + return !node.children.isEmpty(); + } + + public boolean isEmpty() { + return node.value == null && node.children.isEmpty(); + } + + public void forEachDescendant(TreeVisitor visitor) { + forEachDescendant(visitor, false, false); + } + + public void forEachDescendant(TreeVisitor visitor, boolean includeSelf) { + forEachDescendant(visitor, includeSelf, false); + } + + public void forEachDescendant( + final TreeVisitor visitor, boolean includeSelf, final boolean childrenFirst) { + if (includeSelf && !childrenFirst) { + visitor.visitTree(this); + } + + forEachChild( + new TreeVisitor() { + @Override + public void visitTree(Tree tree) { + tree.forEachDescendant(visitor, true, childrenFirst); + } + }); + + if (includeSelf && childrenFirst) { + visitor.visitTree(this); + } + } + + public boolean forEachAncestor(TreeFilter filter) { + return forEachAncestor(filter, false); + } + + public boolean forEachAncestor(TreeFilter filter, boolean includeSelf) { + Tree tree = includeSelf ? this : this.parent; + while (tree != null) { + if (filter.filterTreeNode(tree)) { + return true; + } + tree = tree.parent; + } + return false; + } + + public void forEachChild(TreeVisitor visitor) { + // Decouple from actual tree so we can avoid ConcurrentModification exceptions + Object[] entries = node.children.entrySet().toArray(); + for (int i = 0; i < entries.length; ++i) { + @SuppressWarnings("unchecked") + Map.Entry> entry = (Map.Entry>) entries[i]; + Tree subTree = new Tree<>(entry.getKey(), this, entry.getValue()); + visitor.visitTree(subTree); + } + } + + private void updateParents() { + if (parent != null) { + parent.updateChild(name, this); + } + } + + private void updateChild(ChildKey name, Tree child) { + boolean childEmpty = child.isEmpty(); + boolean childExists = node.children.containsKey(name); + if (childEmpty && childExists) { + node.children.remove(name); + updateParents(); + } else if (!childEmpty && !childExists) { + node.children.put(name, child.node); + updateParents(); + } + } + + @Override + public String toString() { + return toString(""); + } + + String toString(String prefix) { + String nodeName = name == null ? "" : name.asString(); + return prefix + nodeName + "\n" + node.toString(prefix + "\t"); + } +} diff --git a/src/main/java/com/google/firebase/database/core/utilities/TreeNode.java b/src/main/java/com/google/firebase/database/core/utilities/TreeNode.java new file mode 100644 index 000000000..35ab2649e --- /dev/null +++ b/src/main/java/com/google/firebase/database/core/utilities/TreeNode.java @@ -0,0 +1,30 @@ +package com.google.firebase.database.core.utilities; + +import com.google.firebase.database.snapshot.ChildKey; +import java.util.HashMap; +import java.util.Iterator; +import java.util.Map; + +public class TreeNode { + + public Map> children; + public T value; + + public TreeNode() { + children = new HashMap<>(); + } + + String toString(String prefix) { + String result = prefix + ": " + value + "\n"; + if (children.isEmpty()) { + return result + prefix + ""; + } else { + Iterator>> iter = children.entrySet().iterator(); + while (iter.hasNext()) { + Map.Entry> entry = iter.next(); + result += prefix + entry.getKey() + ":\n" + entry.getValue().toString(prefix + "\t") + "\n"; + } + } + return result; + } +} diff --git a/src/main/java/com/google/firebase/database/core/view/CacheNode.java b/src/main/java/com/google/firebase/database/core/view/CacheNode.java new file mode 100644 index 000000000..f41762e6e --- /dev/null +++ b/src/main/java/com/google/firebase/database/core/view/CacheNode.java @@ -0,0 +1,62 @@ +package com.google.firebase.database.core.view; + +import com.google.firebase.database.core.Path; +import com.google.firebase.database.snapshot.ChildKey; +import com.google.firebase.database.snapshot.IndexedNode; +import com.google.firebase.database.snapshot.Node; + +/** + * A cache node only stores complete children. Additionally it holds a flag whether the node can be + * considered fully initialized in the sense that we know at one point in time this represented a + * valid state of the world, e.g. initialized with data from the server, or a complete overwrite by + * the client. The filtered flag also tracks whether a node potentially had children removed due to + * a filter. + */ +public class CacheNode { + + private final IndexedNode indexedNode; + private final boolean fullyInitialized; + private final boolean filtered; + + public CacheNode(IndexedNode node, boolean fullyInitialized, boolean filtered) { + this.indexedNode = node; + this.fullyInitialized = fullyInitialized; + this.filtered = filtered; + } + + /** + * Returns whether this node was fully initialized with either server data or a complete overwrite + * by the client + */ + public boolean isFullyInitialized() { + return this.fullyInitialized; + } + + /** + * Returns whether this node is potentially missing children due to a filter applied to the node + */ + public boolean isFiltered() { + return this.filtered; + } + + public boolean isCompleteForPath(Path path) { + if (path.isEmpty()) { + return this.isFullyInitialized() && !this.filtered; + } else { + ChildKey childKey = path.getFront(); + return isCompleteForChild(childKey); + } + } + + public boolean isCompleteForChild(ChildKey key) { + return (this.isFullyInitialized() && !this.filtered) || indexedNode.getNode().hasChild(key); + } + + public Node getNode() { + return this.indexedNode.getNode(); + } + + public IndexedNode getIndexedNode() { + return this.indexedNode; + } +} diff --git a/src/main/java/com/google/firebase/database/core/view/CancelEvent.java b/src/main/java/com/google/firebase/database/core/view/CancelEvent.java new file mode 100644 index 000000000..4df2726bb --- /dev/null +++ b/src/main/java/com/google/firebase/database/core/view/CancelEvent.java @@ -0,0 +1,33 @@ +package com.google.firebase.database.core.view; + +import com.google.firebase.database.DatabaseError; +import com.google.firebase.database.core.EventRegistration; +import com.google.firebase.database.core.Path; + +public class CancelEvent implements Event { + + private final Path path; + private final EventRegistration eventRegistration; + private final DatabaseError error; + + public CancelEvent(EventRegistration eventRegistration, DatabaseError error, Path path) { + this.eventRegistration = eventRegistration; + this.path = path; + this.error = error; + } + + @Override + public Path getPath() { + return this.path; + } + + @Override + public void fire() { + this.eventRegistration.fireCancelEvent(this.error); + } + + @Override + public String toString() { + return this.getPath() + ":" + "CANCEL"; + } +} diff --git a/src/main/java/com/google/firebase/database/core/view/Change.java b/src/main/java/com/google/firebase/database/core/view/Change.java new file mode 100644 index 000000000..a68414477 --- /dev/null +++ b/src/main/java/com/google/firebase/database/core/view/Change.java @@ -0,0 +1,94 @@ +package com.google.firebase.database.core.view; + +import com.google.firebase.database.snapshot.ChildKey; +import com.google.firebase.database.snapshot.IndexedNode; +import com.google.firebase.database.snapshot.Node; + +public class Change { + + private final Event.EventType eventType; + private final IndexedNode indexedNode; + private final IndexedNode oldIndexedNode; + private final ChildKey childKey; + private final ChildKey prevName; + + private Change( + Event.EventType eventType, + IndexedNode indexedNode, + ChildKey childKey, + ChildKey prevName, + IndexedNode oldIndexedNode) { + this.eventType = eventType; + this.indexedNode = indexedNode; + this.childKey = childKey; + this.prevName = prevName; + this.oldIndexedNode = oldIndexedNode; + } + + public static Change valueChange(IndexedNode snapshot) { + return new Change(Event.EventType.VALUE, snapshot, null, null, null); + } + + public static Change childAddedChange(ChildKey childKey, Node snapshot) { + return childAddedChange(childKey, IndexedNode.from(snapshot)); + } + + public static Change childAddedChange(ChildKey childKey, IndexedNode snapshot) { + return new Change(Event.EventType.CHILD_ADDED, snapshot, childKey, null, null); + } + + public static Change childRemovedChange(ChildKey childKey, Node snapshot) { + return childRemovedChange(childKey, IndexedNode.from(snapshot)); + } + + public static Change childRemovedChange(ChildKey childKey, IndexedNode snapshot) { + return new Change(Event.EventType.CHILD_REMOVED, snapshot, childKey, null, null); + } + + public static Change childChangedChange(ChildKey childKey, Node newSnapshot, Node oldSnapshot) { + return childChangedChange( + childKey, IndexedNode.from(newSnapshot), IndexedNode.from(oldSnapshot)); + } + + public static Change childChangedChange( + ChildKey childKey, IndexedNode newSnapshot, IndexedNode oldSnapshot) { + return new Change(Event.EventType.CHILD_CHANGED, newSnapshot, childKey, null, oldSnapshot); + } + + public static Change childMovedChange(ChildKey childKey, Node snapshot) { + return Change.childMovedChange(childKey, IndexedNode.from(snapshot)); + } + + public static Change childMovedChange(ChildKey childKey, IndexedNode snapshot) { + return new Change(Event.EventType.CHILD_MOVED, snapshot, childKey, null, null); + } + + public Change changeWithPrevName(ChildKey prevName) { + return new Change(eventType, indexedNode, childKey, prevName, oldIndexedNode); + } + + public ChildKey getChildKey() { + return childKey; + } + + public Event.EventType getEventType() { + return eventType; + } + + public IndexedNode getIndexedNode() { + return indexedNode; + } + + public ChildKey getPrevName() { + return prevName; + } + + public IndexedNode getOldIndexedNode() { + return this.oldIndexedNode; + } + + @Override + public String toString() { + return "Change: " + eventType + " " + childKey; + } +} diff --git a/src/main/java/com/google/firebase/database/core/view/DataEvent.java b/src/main/java/com/google/firebase/database/core/view/DataEvent.java new file mode 100644 index 000000000..1ce440701 --- /dev/null +++ b/src/main/java/com/google/firebase/database/core/view/DataEvent.java @@ -0,0 +1,67 @@ +package com.google.firebase.database.core.view; + +import com.google.firebase.database.DataSnapshot; +import com.google.firebase.database.core.EventRegistration; +import com.google.firebase.database.core.Path; + +public class DataEvent implements Event { + + private final EventType eventType; + private final EventRegistration eventRegistration; + private final DataSnapshot snapshot; + private final String prevName; + + public DataEvent( + EventType eventType, + EventRegistration eventRegistration, + DataSnapshot snapshot, + String prevName) { + this.eventType = eventType; + this.eventRegistration = eventRegistration; + this.snapshot = snapshot; + this.prevName = prevName; + } + + @Override + public Path getPath() { + Path path = this.snapshot.getRef().getPath(); + if (this.eventType == EventType.VALUE) { + return path; + } else { + return path.getParent(); + } + } + + public DataSnapshot getSnapshot() { + return this.snapshot; + } + + public String getPreviousName() { + return this.prevName; + } + + public EventType getEventType() { + return this.eventType; + } + + @Override + public void fire() { + this.eventRegistration.fireEvent(this); + } + + @Override + public String toString() { + if (this.eventType == EventType.VALUE) { + return this.getPath() + ": " + this.eventType + ": " + this.snapshot.getValue(true); + } else { + return this.getPath() + + ": " + + this.eventType + + ": { " + + this.snapshot.getKey() + + ": " + + this.snapshot.getValue(true) + + " }"; + } + } +} diff --git a/src/main/java/com/google/firebase/database/core/view/Event.java b/src/main/java/com/google/firebase/database/core/view/Event.java new file mode 100644 index 000000000..341165d9c --- /dev/null +++ b/src/main/java/com/google/firebase/database/core/view/Event.java @@ -0,0 +1,23 @@ +package com.google.firebase.database.core.view; + +import com.google.firebase.database.core.Path; + +public interface Event { + + /** */ + enum EventType { + // The order is important here and reflects the order events should be raised in + CHILD_REMOVED, + CHILD_ADDED, + CHILD_MOVED, + CHILD_CHANGED, + VALUE + } + + Path getPath(); + + void fire(); + + @Override + String toString(); +} diff --git a/src/main/java/com/google/firebase/database/core/view/EventGenerator.java b/src/main/java/com/google/firebase/database/core/view/EventGenerator.java new file mode 100644 index 000000000..c9b3768ca --- /dev/null +++ b/src/main/java/com/google/firebase/database/core/view/EventGenerator.java @@ -0,0 +1,98 @@ +package com.google.firebase.database.core.view; + +import com.google.firebase.database.core.EventRegistration; +import com.google.firebase.database.snapshot.ChildKey; +import com.google.firebase.database.snapshot.Index; +import com.google.firebase.database.snapshot.IndexedNode; +import com.google.firebase.database.snapshot.NamedNode; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.List; + +public class EventGenerator { + + private final QuerySpec query; + private final Index index; + + public EventGenerator(QuerySpec query) { + this.query = query; + this.index = query.getIndex(); + } + + private void generateEventsForType( + List events, + Event.EventType type, + List changes, + List eventRegistrations, + IndexedNode eventCache) { + List filteredChanges = new ArrayList<>(); + for (Change change : changes) { + if (change.getEventType().equals(type)) { + filteredChanges.add(change); + } + } + Collections.sort(filteredChanges, changeComparator()); + for (Change change : filteredChanges) { + for (EventRegistration registration : eventRegistrations) { + if (registration.respondsTo(type)) { + events.add(generateEvent(change, registration, eventCache)); + } + } + } + } + + private DataEvent generateEvent( + Change change, EventRegistration registration, IndexedNode eventCache) { + Change newChange; + if (change.getEventType().equals(Event.EventType.VALUE) + || change.getEventType().equals(Event.EventType.CHILD_REMOVED)) { + newChange = change; + } else { + ChildKey prevChildKey = + eventCache.getPredecessorChildName( + change.getChildKey(), change.getIndexedNode().getNode(), this.index); + newChange = change.changeWithPrevName(prevChildKey); + } + return registration.createEvent(newChange, this.query); + } + + public List generateEventsForChanges( + List changes, IndexedNode eventCache, List eventRegistrations) { + List events = new ArrayList<>(); + + List moves = new ArrayList<>(); + for (Change change : changes) { + if (change.getEventType().equals(Event.EventType.CHILD_CHANGED) + && index.indexedValueChanged( + change.getOldIndexedNode().getNode(), change.getIndexedNode().getNode())) { + moves.add(Change.childMovedChange(change.getChildKey(), change.getIndexedNode())); + } + } + + generateEventsForType( + events, Event.EventType.CHILD_REMOVED, changes, eventRegistrations, eventCache); + generateEventsForType( + events, Event.EventType.CHILD_ADDED, changes, eventRegistrations, eventCache); + generateEventsForType( + events, Event.EventType.CHILD_MOVED, moves, eventRegistrations, eventCache); + generateEventsForType( + events, Event.EventType.CHILD_CHANGED, changes, eventRegistrations, eventCache); + generateEventsForType(events, Event.EventType.VALUE, changes, eventRegistrations, eventCache); + + return events; + } + + private Comparator changeComparator() { + return new Comparator() { + @Override + public int compare(Change a, Change b) { + // should only be comparing child_* events + assert a.getChildKey() != null && b.getChildKey() != null; + NamedNode namedNodeA = new NamedNode(a.getChildKey(), a.getIndexedNode().getNode()); + NamedNode namedNodeB = new NamedNode(b.getChildKey(), b.getIndexedNode().getNode()); + return index.compare(namedNodeA, namedNodeB); + } + }; + } +} diff --git a/src/main/java/com/google/firebase/database/core/view/EventRaiser.java b/src/main/java/com/google/firebase/database/core/view/EventRaiser.java new file mode 100644 index 000000000..eb529d8f2 --- /dev/null +++ b/src/main/java/com/google/firebase/database/core/view/EventRaiser.java @@ -0,0 +1,46 @@ +package com.google.firebase.database.core.view; + +import com.google.firebase.database.core.Context; +import com.google.firebase.database.core.EventTarget; +import com.google.firebase.database.logging.LogWrapper; +import java.util.ArrayList; +import java.util.List; + +/** + * Each view owns an instance of this class, and it is used to send events to the event target + * thread. + * + *

Note that it is safe to post events directly to that thread, since a shutdown will not occur + * unless there are no listeners. If there are no listeners, all instances of this class will be + * cleaned up. + */ +public class EventRaiser { + + private final EventTarget eventTarget; + private final LogWrapper logger; + + public EventRaiser(Context ctx) { + eventTarget = ctx.getEventTarget(); + logger = ctx.getLogger("EventRaiser"); + } + + public void raiseEvents(final List events) { + if (logger.logsDebug()) { + logger.debug("Raising " + events.size() + " event(s)"); + } + // TODO: Use an immutable data structure for events so we don't have to clone to be safe. + final ArrayList eventsClone = new ArrayList<>(events); + eventTarget.postEvent( + new Runnable() { + @Override + public void run() { + for (Event event : eventsClone) { + if (logger.logsDebug()) { + logger.debug("Raising " + event.toString()); + } + event.fire(); + } + } + }); + } +} diff --git a/src/main/java/com/google/firebase/database/core/view/QueryParams.java b/src/main/java/com/google/firebase/database/core/view/QueryParams.java new file mode 100644 index 000000000..2fa0e7637 --- /dev/null +++ b/src/main/java/com/google/firebase/database/core/view/QueryParams.java @@ -0,0 +1,360 @@ +package com.google.firebase.database.core.view; + +import static com.google.firebase.database.snapshot.NodeUtilities.NodeFromJSON; + +import com.google.firebase.database.core.view.filter.IndexedFilter; +import com.google.firebase.database.core.view.filter.LimitedFilter; +import com.google.firebase.database.core.view.filter.NodeFilter; +import com.google.firebase.database.core.view.filter.RangedFilter; +import com.google.firebase.database.snapshot.BooleanNode; +import com.google.firebase.database.snapshot.ChildKey; +import com.google.firebase.database.snapshot.DoubleNode; +import com.google.firebase.database.snapshot.EmptyNode; +import com.google.firebase.database.snapshot.Index; +import com.google.firebase.database.snapshot.LongNode; +import com.google.firebase.database.snapshot.Node; +import com.google.firebase.database.snapshot.PriorityIndex; +import com.google.firebase.database.snapshot.PriorityUtilities; +import com.google.firebase.database.snapshot.StringNode; +import com.google.firebase.database.util.JsonMapper; +import java.io.IOException; +import java.util.HashMap; +import java.util.Map; + +public class QueryParams { + + public static final QueryParams DEFAULT_PARAMS = new QueryParams(); + + private static final String INDEX_START_VALUE = "sp"; + private static final String INDEX_START_NAME = "sn"; + private static final String INDEX_END_VALUE = "ep"; + private static final String INDEX_END_NAME = "en"; + private static final String LIMIT = "l"; + private static final String VIEW_FROM = "vf"; + private static final String INDEX = "i"; + + private enum ViewFrom { + LEFT, + RIGHT + } + + private Integer limit; + private ViewFrom viewFrom; + private Node indexStartValue = null; + private ChildKey indexStartName = null; + private Node indexEndValue = null; + private ChildKey indexEndName = null; + + private Index index = PriorityIndex.getInstance(); + + private String jsonSerialization = null; + + public boolean hasStart() { + return indexStartValue != null; + } + + public Node getIndexStartValue() { + if (!hasStart()) { + throw new IllegalArgumentException("Cannot get index start value if start has not been set"); + } + return indexStartValue; + } + + public ChildKey getIndexStartName() { + if (!hasStart()) { + throw new IllegalArgumentException("Cannot get index start name if start has not been set"); + } + if (indexStartName != null) { + return indexStartName; + } else { + return ChildKey.getMinName(); + } + } + + public boolean hasEnd() { + return indexEndValue != null; + } + + public Node getIndexEndValue() { + if (!hasEnd()) { + throw new IllegalArgumentException("Cannot get index end value if start has not been set"); + } + return indexEndValue; + } + + public ChildKey getIndexEndName() { + if (!hasEnd()) { + throw new IllegalArgumentException("Cannot get index end name if start has not been set"); + } + if (indexEndName != null) { + return indexEndName; + } else { + return ChildKey.getMaxName(); + } + } + + public boolean hasLimit() { + return limit != null; + } + + public boolean hasAnchoredLimit() { + return hasLimit() && this.viewFrom != null; + } + + public int getLimit() { + if (!hasLimit()) { + throw new IllegalArgumentException("Cannot get limit if limit has not been set"); + } + return this.limit; + } + + public Index getIndex() { + return this.index; + } + + private QueryParams copy() { + QueryParams params = new QueryParams(); + params.limit = limit; + params.indexStartValue = indexStartValue; + params.indexStartName = indexStartName; + params.indexEndValue = indexEndValue; + params.indexEndName = indexEndName; + params.viewFrom = viewFrom; + params.index = index; + return params; + } + + public QueryParams limitToFirst(int limit) { + QueryParams copy = copy(); + copy.limit = limit; + copy.viewFrom = ViewFrom.LEFT; + return copy; + } + + public QueryParams limitToLast(int limit) { + QueryParams copy = copy(); + copy.limit = limit; + copy.viewFrom = ViewFrom.RIGHT; + return copy; + } + + public QueryParams startAt(Node indexStartValue, ChildKey indexStartName) { + assert indexStartValue.isLeafNode() || indexStartValue.isEmpty(); + QueryParams copy = copy(); + copy.indexStartValue = indexStartValue; + copy.indexStartName = indexStartName; + return copy; + } + + public QueryParams endAt(Node indexEndValue, ChildKey indexEndName) { + assert indexEndValue.isLeafNode() || indexEndValue.isEmpty(); + QueryParams copy = copy(); + copy.indexEndValue = indexEndValue; + copy.indexEndName = indexEndName; + return copy; + } + + public QueryParams orderBy(Index index) { + QueryParams copy = copy(); + copy.index = index; + return copy; + } + + public boolean isViewFromLeft() { + return this.viewFrom != null ? this.viewFrom == ViewFrom.LEFT : hasStart(); + } + + // NOTE: Don't change this unless you're changing the wire protocol! + public Map getWireProtocolParams() { + Map queryObject = new HashMap<>(); + if (hasStart()) { + queryObject.put(INDEX_START_VALUE, indexStartValue.getValue()); + if (indexStartName != null) { + queryObject.put(INDEX_START_NAME, indexStartName.asString()); + } + } + if (hasEnd()) { + queryObject.put(INDEX_END_VALUE, indexEndValue.getValue()); + if (indexEndName != null) { + queryObject.put(INDEX_END_NAME, indexEndName.asString()); + } + } + if (limit != null) { + queryObject.put(LIMIT, limit); + ViewFrom viewFromToAdd = viewFrom; + if (viewFromToAdd == null) { + // limit(), rather than limitToFirst or limitToLast was called. + // This means that only one of hasStart() and hasEnd() is true. Use them + // to calculate which side of the view to anchor to. If neither is set, + // anchor to the end. + if (hasStart()) { + viewFromToAdd = ViewFrom.LEFT; + } else { + // endSet_ or neither set + viewFromToAdd = ViewFrom.RIGHT; + } + } + switch (viewFromToAdd) { + case LEFT: + queryObject.put(VIEW_FROM, "l"); + break; + case RIGHT: + queryObject.put(VIEW_FROM, "r"); + break; + } + } + if (!index.equals(PriorityIndex.getInstance())) { + queryObject.put(INDEX, index.getQueryDefinition()); + } + return queryObject; + } + + public boolean loadsAllData() { + return !(hasStart() || hasEnd() || hasLimit()); + } + + public boolean isDefault() { + return loadsAllData() && index.equals(PriorityIndex.getInstance()); + } + + public boolean isValid() { + return !(hasStart() && hasEnd() && hasLimit() && !hasAnchoredLimit()); + } + + public String toJSON() { + if (jsonSerialization == null) { + try { + jsonSerialization = JsonMapper.serializeJson(getWireProtocolParams()); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + return jsonSerialization; + } + + public static QueryParams fromQueryObject(Map map) { + QueryParams params = new QueryParams(); + params.limit = (Integer) map.get(LIMIT); + + if (map.containsKey(INDEX_START_VALUE)) { + Object indexStartValue = map.get(INDEX_START_VALUE); + params.indexStartValue = normalizeValue(NodeFromJSON(indexStartValue)); + String indexStartName = (String) map.get(INDEX_START_NAME); + if (indexStartName != null) { + params.indexStartName = ChildKey.fromString(indexStartName); + } + } + + if (map.containsKey(INDEX_END_VALUE)) { + Object indexEndValue = map.get(INDEX_END_VALUE); + params.indexEndValue = normalizeValue(NodeFromJSON(indexEndValue)); + String indexEndName = (String) map.get(INDEX_END_NAME); + if (indexEndName != null) { + params.indexEndName = ChildKey.fromString(indexEndName); + } + } + + String viewFrom = (String) map.get(VIEW_FROM); + if (viewFrom != null) { + params.viewFrom = viewFrom.equals("l") ? ViewFrom.LEFT : ViewFrom.RIGHT; + } + + String indexStr = (String) map.get(INDEX); + if (indexStr != null) { + params.index = Index.fromQueryDefinition(indexStr); + } + + return params; + } + + public NodeFilter getNodeFilter() { + if (this.loadsAllData()) { + return new IndexedFilter(this.getIndex()); + } else if (this.hasLimit()) { + return new LimitedFilter(this); + } else { + return new RangedFilter(this); + } + } + + @Override + public String toString() { + return getWireProtocolParams().toString(); + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + + QueryParams that = (QueryParams) o; + + if (limit != null ? !limit.equals(that.limit) : that.limit != null) { + return false; + } + if (index != null ? !index.equals(that.index) : that.index != null) { + return false; + } + if (indexEndName != null + ? !indexEndName.equals(that.indexEndName) + : that.indexEndName != null) { + return false; + } + if (indexEndValue != null + ? !indexEndValue.equals(that.indexEndValue) + : that.indexEndValue != null) { + return false; + } + if (indexStartName != null + ? !indexStartName.equals(that.indexStartName) + : that.indexStartName != null) { + return false; + } + if (indexStartValue != null + ? !indexStartValue.equals(that.indexStartValue) + : that.indexStartValue != null) { + return false; + } + // viewFrom might be null, but we really want to compare left vs right + if (isViewFromLeft() != that.isViewFromLeft()) { + return false; + } + + return true; + } + + @Override + public int hashCode() { + int result = limit != null ? limit : 0; + result = 31 * result + (isViewFromLeft() ? 1231 : 1237); + result = 31 * result + (indexStartValue != null ? indexStartValue.hashCode() : 0); + result = 31 * result + (indexStartName != null ? indexStartName.hashCode() : 0); + result = 31 * result + (indexEndValue != null ? indexEndValue.hashCode() : 0); + result = 31 * result + (indexEndName != null ? indexEndName.hashCode() : 0); + result = 31 * result + (index != null ? index.hashCode() : 0); + return result; + } + + private static Node normalizeValue(Node value) { + if (value instanceof StringNode + || value instanceof BooleanNode + || value instanceof DoubleNode + || value instanceof EmptyNode) { + + return value; + } else if (value instanceof LongNode) { + // We normalize longs to doubles. This is *ESSENTIAL* to prevent our persistence + // code from breaking, since integer-valued doubles get turned into longs after being + // saved to persistence (as JSON) and then read back. (see http://b/30153920/) + return new DoubleNode( + ((Long) value.getValue()).doubleValue(), PriorityUtilities.NullPriority()); + } else { + throw new IllegalStateException( + "Unexpected value passed to normalizeValue: " + value.getValue()); + } + } +} diff --git a/src/main/java/com/google/firebase/database/core/view/QuerySpec.java b/src/main/java/com/google/firebase/database/core/view/QuerySpec.java new file mode 100644 index 000000000..15285b720 --- /dev/null +++ b/src/main/java/com/google/firebase/database/core/view/QuerySpec.java @@ -0,0 +1,77 @@ +package com.google.firebase.database.core.view; + +import com.google.firebase.database.core.Path; +import com.google.firebase.database.snapshot.Index; +import java.util.Map; + +public class QuerySpec { + + public static QuerySpec defaultQueryAtPath(Path path) { + return new QuerySpec(path, QueryParams.DEFAULT_PARAMS); + } + + public QuerySpec(Path path, QueryParams params) { + this.path = path; + this.params = params; + } + + private final Path path; + private final QueryParams params; + + public Path getPath() { + return this.path; + } + + public QueryParams getParams() { + return this.params; + } + + public static QuerySpec fromPathAndQueryObject(Path path, Map map) { + QueryParams params = QueryParams.fromQueryObject(map); + return new QuerySpec(path, params); + } + + public Index getIndex() { + return this.params.getIndex(); + } + + public boolean isDefault() { + return this.params.isDefault(); + } + + public boolean loadsAllData() { + return this.params.loadsAllData(); + } + + @Override + public String toString() { + return this.path + ":" + params; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + QuerySpec that = (QuerySpec) o; + + if (!path.equals(that.path)) { + return false; + } + if (!params.equals(that.params)) { + return false; + } + + return true; + } + + @Override + public int hashCode() { + int result = path.hashCode(); + result = 31 * result + params.hashCode(); + return result; + } +} diff --git a/src/main/java/com/google/firebase/database/core/view/View.java b/src/main/java/com/google/firebase/database/core/view/View.java new file mode 100644 index 000000000..1bf1cf084 --- /dev/null +++ b/src/main/java/com/google/firebase/database/core/view/View.java @@ -0,0 +1,196 @@ +package com.google.firebase.database.core.view; + +import com.google.firebase.database.DatabaseError; +import com.google.firebase.database.annotations.NotNull; +import com.google.firebase.database.annotations.Nullable; +import com.google.firebase.database.core.EventRegistration; +import com.google.firebase.database.core.Path; +import com.google.firebase.database.core.WriteTreeRef; +import com.google.firebase.database.core.operation.Operation; +import com.google.firebase.database.core.view.filter.IndexedFilter; +import com.google.firebase.database.core.view.filter.NodeFilter; +import com.google.firebase.database.snapshot.EmptyNode; +import com.google.firebase.database.snapshot.IndexedNode; +import com.google.firebase.database.snapshot.NamedNode; +import com.google.firebase.database.snapshot.Node; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +public class View { + + private final QuerySpec query; + private final ViewProcessor processor; + private ViewCache viewCache; + private final List eventRegistrations; + private final EventGenerator eventGenerator; + + public View(QuerySpec query, ViewCache initialViewCache) { + this.query = query; + IndexedFilter indexFilter = new IndexedFilter(query.getIndex()); + NodeFilter filter = query.getParams().getNodeFilter(); + this.processor = new ViewProcessor(filter); + CacheNode initialServerCache = initialViewCache.getServerCache(); + CacheNode initialEventCache = initialViewCache.getEventCache(); + + // Don't filter server node with other filter than index, wait for tagged listen + IndexedNode emptyIndexedNode = IndexedNode.from(EmptyNode.Empty(), query.getIndex()); + IndexedNode serverSnap = + indexFilter.updateFullNode(emptyIndexedNode, initialServerCache.getIndexedNode(), null); + IndexedNode eventSnap = + filter.updateFullNode(emptyIndexedNode, initialEventCache.getIndexedNode(), null); + CacheNode newServerCache = + new CacheNode( + serverSnap, initialServerCache.isFullyInitialized(), indexFilter.filtersNodes()); + CacheNode newEventCache = + new CacheNode(eventSnap, initialEventCache.isFullyInitialized(), filter.filtersNodes()); + + this.viewCache = new ViewCache(newEventCache, newServerCache); + + this.eventRegistrations = new ArrayList<>(); + + this.eventGenerator = new EventGenerator(query); + } + + /** */ + public static class OperationResult { + + public final List events; + public final List changes; + + public OperationResult(List events, List changes) { + this.events = events; + this.changes = changes; + } + } + + public QuerySpec getQuery() { + return this.query; + } + + public Node getCompleteNode() { + return this.viewCache.getCompleteEventSnap(); + } + + public Node getServerCache() { + return this.viewCache.getServerCache().getNode(); + } + + public Node getEventCache() { + return this.viewCache.getEventCache().getNode(); + } + + public Node getCompleteServerCache(Path path) { + Node cache = this.viewCache.getCompleteServerSnap(); + if (cache != null) { + // If this isn't a "loadsAllData" view, then cache isn't actually a complete cache and + // we need to see if it contains the child we're interested in. + if (this.query.loadsAllData() + || (!path.isEmpty() && !cache.getImmediateChild(path.getFront()).isEmpty())) { + return cache.getChild(path); + } + } + return null; + } + + public boolean isEmpty() { + return this.eventRegistrations.isEmpty(); + } + + public void addEventRegistration(@NotNull EventRegistration registration) { + this.eventRegistrations.add(registration); + } + + public List removeEventRegistration( + @Nullable EventRegistration registration, DatabaseError cancelError) { + List cancelEvents; + if (cancelError != null) { + cancelEvents = new ArrayList<>(); + assert registration == null : "A cancel should cancel all event registrations"; + Path path = this.query.getPath(); + for (EventRegistration eventRegistration : this.eventRegistrations) { + cancelEvents.add(new CancelEvent(eventRegistration, cancelError, path)); + } + } else { + cancelEvents = Collections.emptyList(); + } + if (registration != null) { + // We prefer an event registration that is already zombied, as this indicates it came + // from a query.unregister call and to choose another would cause a temporary imbalance + int indexToDelete = -1; + for (int i = 0; i < eventRegistrations.size(); i++) { + EventRegistration candidate = eventRegistrations.get(i); + if (candidate.isSameListener(registration)) { + indexToDelete = i; + if (candidate.isZombied()) { + break; + } + } + } + if (indexToDelete != -1) { + EventRegistration deletedRegistration = eventRegistrations.get(indexToDelete); + this.eventRegistrations.remove(indexToDelete); + deletedRegistration.zombify(); + } + } else { + for (EventRegistration eventRegistration : eventRegistrations) { + eventRegistration.zombify(); + } + this.eventRegistrations.clear(); + } + return cancelEvents; + } + + public OperationResult applyOperation( + Operation operation, WriteTreeRef writesCache, Node optCompleteServerCache) { + if (operation.getType() == Operation.OperationType.Merge + && operation.getSource().getQueryParams() != null) { + assert this.viewCache.getCompleteServerSnap() != null + : "We should always have a full cache before handling merges"; + assert this.viewCache.getCompleteEventSnap() != null + : "Missing event cache, even though we have a server cache"; + } + ViewCache oldViewCache = this.viewCache; + ViewProcessor.ProcessorResult result = + this.processor.applyOperation(oldViewCache, operation, writesCache, optCompleteServerCache); + + assert result.viewCache.getServerCache().isFullyInitialized() + || !oldViewCache.getServerCache().isFullyInitialized() + : "Once a server snap is complete, it should never go back"; + + this.viewCache = result.viewCache; + List events = + this.generateEventsForChanges( + result.changes, result.viewCache.getEventCache().getIndexedNode(), null); + return new OperationResult(events, result.changes); + } + + public List getInitialEvents(EventRegistration registration) { + CacheNode eventSnap = this.viewCache.getEventCache(); + List initialChanges = new ArrayList<>(); + for (NamedNode child : eventSnap.getNode()) { + initialChanges.add(Change.childAddedChange(child.getName(), child.getNode())); + } + if (eventSnap.isFullyInitialized()) { + initialChanges.add(Change.valueChange(eventSnap.getIndexedNode())); + } + return this.generateEventsForChanges(initialChanges, eventSnap.getIndexedNode(), registration); + } + + private List generateEventsForChanges( + List changes, IndexedNode eventCache, EventRegistration registration) { + List registrations; + if (registration == null) { + registrations = this.eventRegistrations; + } else { + registrations = Arrays.asList(registration); + } + return this.eventGenerator.generateEventsForChanges(changes, eventCache, registrations); + } + + // Package private for testing purposes only + List getEventRegistrations() { + return eventRegistrations; + } +} diff --git a/src/main/java/com/google/firebase/database/core/view/ViewCache.java b/src/main/java/com/google/firebase/database/core/view/ViewCache.java new file mode 100644 index 000000000..68b095beb --- /dev/null +++ b/src/main/java/com/google/firebase/database/core/view/ViewCache.java @@ -0,0 +1,39 @@ +package com.google.firebase.database.core.view; + +import com.google.firebase.database.snapshot.IndexedNode; +import com.google.firebase.database.snapshot.Node; + +public class ViewCache { + + private final CacheNode eventSnap; + private final CacheNode serverSnap; + + public ViewCache(CacheNode eventSnap, CacheNode serverSnap) { + this.eventSnap = eventSnap; + this.serverSnap = serverSnap; + } + + public ViewCache updateEventSnap(IndexedNode eventSnap, boolean complete, boolean filtered) { + return new ViewCache(new CacheNode(eventSnap, complete, filtered), this.serverSnap); + } + + public ViewCache updateServerSnap(IndexedNode serverSnap, boolean complete, boolean filtered) { + return new ViewCache(this.eventSnap, new CacheNode(serverSnap, complete, filtered)); + } + + public CacheNode getEventCache() { + return this.eventSnap; + } + + public Node getCompleteEventSnap() { + return (this.eventSnap.isFullyInitialized()) ? this.eventSnap.getNode() : null; + } + + public CacheNode getServerCache() { + return this.serverSnap; + } + + public Node getCompleteServerSnap() { + return this.serverSnap.isFullyInitialized() ? this.serverSnap.getNode() : null; + } +} diff --git a/src/main/java/com/google/firebase/database/core/view/ViewProcessor.java b/src/main/java/com/google/firebase/database/core/view/ViewProcessor.java new file mode 100644 index 000000000..3f1827a42 --- /dev/null +++ b/src/main/java/com/google/firebase/database/core/view/ViewProcessor.java @@ -0,0 +1,711 @@ +package com.google.firebase.database.core.view; + +import com.google.firebase.database.core.CompoundWrite; +import com.google.firebase.database.core.Path; +import com.google.firebase.database.core.WriteTreeRef; +import com.google.firebase.database.core.operation.AckUserWrite; +import com.google.firebase.database.core.operation.Merge; +import com.google.firebase.database.core.operation.Operation; +import com.google.firebase.database.core.operation.Overwrite; +import com.google.firebase.database.core.utilities.ImmutableTree; +import com.google.firebase.database.core.view.filter.ChildChangeAccumulator; +import com.google.firebase.database.core.view.filter.NodeFilter; +import com.google.firebase.database.snapshot.ChildKey; +import com.google.firebase.database.snapshot.ChildrenNode; +import com.google.firebase.database.snapshot.EmptyNode; +import com.google.firebase.database.snapshot.Index; +import com.google.firebase.database.snapshot.IndexedNode; +import com.google.firebase.database.snapshot.KeyIndex; +import com.google.firebase.database.snapshot.NamedNode; +import com.google.firebase.database.snapshot.Node; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +public class ViewProcessor { + + private final NodeFilter filter; + + public ViewProcessor(NodeFilter filter) { + this.filter = filter; + } + + /** */ + public static class ProcessorResult { + + public final ViewCache viewCache; + public final List changes; + + public ProcessorResult(ViewCache viewCache, List changes) { + this.viewCache = viewCache; + this.changes = changes; + } + } + + public ProcessorResult applyOperation( + ViewCache oldViewCache, + Operation operation, + WriteTreeRef writesCache, + Node optCompleteCache) { + ChildChangeAccumulator accumulator = new ChildChangeAccumulator(); + ViewCache newViewCache; + switch (operation.getType()) { + case Overwrite: { + Overwrite overwrite = (Overwrite) operation; + if (overwrite.getSource().isFromUser()) { + newViewCache = + this.applyUserOverwrite( + oldViewCache, + overwrite.getPath(), + overwrite.getSnapshot(), + writesCache, + optCompleteCache, + accumulator); + } else { + assert overwrite.getSource().isFromServer(); + // We filter the node if it's a tagged update or the node has been previously filtered + // and the update is not at the root in which case it is ok (and necessary) to mark the + // node unfiltered again + boolean filterServerNode = + overwrite.getSource().isTagged() + || (oldViewCache.getServerCache().isFiltered() + && !overwrite.getPath().isEmpty()); + newViewCache = + this.applyServerOverwrite( + oldViewCache, + overwrite.getPath(), + overwrite.getSnapshot(), + writesCache, + optCompleteCache, + filterServerNode, + accumulator); + } + break; + } + case Merge: { + Merge merge = (Merge) operation; + if (merge.getSource().isFromUser()) { + newViewCache = + this.applyUserMerge( + oldViewCache, + merge.getPath(), + merge.getChildren(), + writesCache, + optCompleteCache, + accumulator); + } else { + assert merge.getSource().isFromServer(); + // We filter the node if it's a tagged update or the node has been previously filtered + boolean filterServerNode = + merge.getSource().isTagged() || oldViewCache.getServerCache().isFiltered(); + newViewCache = + this.applyServerMerge( + oldViewCache, + merge.getPath(), + merge.getChildren(), + writesCache, + optCompleteCache, + filterServerNode, + accumulator); + } + break; + } + case AckUserWrite: { + AckUserWrite ackUserWrite = (AckUserWrite) operation; + if (!ackUserWrite.isRevert()) { + newViewCache = + this.ackUserWrite( + oldViewCache, + ackUserWrite.getPath(), + ackUserWrite.getAffectedTree(), + writesCache, + optCompleteCache, + accumulator); + } else { + newViewCache = + this.revertUserWrite( + oldViewCache, + ackUserWrite.getPath(), + writesCache, + optCompleteCache, + accumulator); + } + break; + } + case ListenComplete: { + newViewCache = + this.listenComplete(oldViewCache, operation.getPath(), writesCache, accumulator); + break; + } + default: { + throw new AssertionError("Unknown operation: " + operation.getType()); + } + } + List changes = new ArrayList<>(accumulator.getChanges()); + maybeAddValueEvent(oldViewCache, newViewCache, changes); + return new ProcessorResult(newViewCache, changes); + } + + private void maybeAddValueEvent( + ViewCache oldViewCache, ViewCache newViewCache, List accumulator) { + CacheNode eventSnap = newViewCache.getEventCache(); + if (eventSnap.isFullyInitialized()) { + boolean isLeafOrEmpty = eventSnap.getNode().isLeafNode() || eventSnap.getNode().isEmpty(); + if (!accumulator.isEmpty() + || !oldViewCache.getEventCache().isFullyInitialized() + || (isLeafOrEmpty && !eventSnap.getNode().equals(oldViewCache.getCompleteEventSnap())) + || !eventSnap + .getNode() + .getPriority() + .equals(oldViewCache.getCompleteEventSnap().getPriority())) { + accumulator.add(Change.valueChange(eventSnap.getIndexedNode())); + } + } + } + + private ViewCache generateEventCacheAfterServerEvent( + ViewCache viewCache, + Path changePath, + WriteTreeRef writesCache, + NodeFilter.CompleteChildSource source, + ChildChangeAccumulator accumulator) { + CacheNode oldEventSnap = viewCache.getEventCache(); + if (writesCache.shadowingWrite(changePath) != null) { + // we have a shadowing write, ignore changes + return viewCache; + } else { + IndexedNode newEventCache; + if (changePath.isEmpty()) { + // TODO: figure out how this plays with "sliding ack windows" + assert viewCache.getServerCache().isFullyInitialized() + : "If change path is empty, we must have complete server data"; + Node nodeWithLocalWrites; + if (viewCache.getServerCache().isFiltered()) { + // We need to special case this, because we need to only apply writes to complete + // children, or we might end up raising events for incomplete children. If the server data + // is filtered deep writes cannot be guaranteed to be complete + Node serverCache = viewCache.getCompleteServerSnap(); + Node completeChildren = + (serverCache instanceof ChildrenNode) ? serverCache : EmptyNode.Empty(); + nodeWithLocalWrites = writesCache.calcCompleteEventChildren(completeChildren); + } else { + nodeWithLocalWrites = + writesCache.calcCompleteEventCache(viewCache.getCompleteServerSnap()); + } + IndexedNode indexedNode = IndexedNode.from(nodeWithLocalWrites, this.filter.getIndex()); + newEventCache = + this.filter.updateFullNode( + viewCache.getEventCache().getIndexedNode(), indexedNode, accumulator); + } else { + ChildKey childKey = changePath.getFront(); + if (childKey.isPriorityChildName()) { + assert changePath.size() == 1 : "Can't have a priority with additional path components"; + Node oldEventNode = oldEventSnap.getNode(); + Node serverNode = viewCache.getServerCache().getNode(); + // we might have overwrites for this priority + Node updatedPriority = + writesCache.calcEventCacheAfterServerOverwrite(changePath, oldEventNode, serverNode); + if (updatedPriority != null) { + newEventCache = + this.filter.updatePriority(oldEventSnap.getIndexedNode(), updatedPriority); + } else { + // priority didn't change, keep old node + newEventCache = oldEventSnap.getIndexedNode(); + } + } else { + Path childChangePath = changePath.popFront(); + // update child + Node newEventChild; + if (oldEventSnap.isCompleteForChild(childKey)) { + Node serverNode = viewCache.getServerCache().getNode(); + Node eventChildUpdate = + writesCache.calcEventCacheAfterServerOverwrite( + changePath, oldEventSnap.getNode(), serverNode); + if (eventChildUpdate != null) { + newEventChild = + oldEventSnap + .getNode() + .getImmediateChild(childKey) + .updateChild(childChangePath, eventChildUpdate); + } else { + // Nothing changed, just keep the old child + newEventChild = oldEventSnap.getNode().getImmediateChild(childKey); + } + } else { + newEventChild = writesCache.calcCompleteChild(childKey, viewCache.getServerCache()); + } + if (newEventChild != null) { + newEventCache = + this.filter.updateChild( + oldEventSnap.getIndexedNode(), + childKey, + newEventChild, + childChangePath, + source, + accumulator); + } else { + // no complete child available or no change + newEventCache = oldEventSnap.getIndexedNode(); + } + } + } + return viewCache.updateEventSnap( + newEventCache, + oldEventSnap.isFullyInitialized() || changePath.isEmpty(), + this.filter.filtersNodes()); + } + } + + private ViewCache applyServerOverwrite( + ViewCache oldViewCache, + Path changePath, + Node changedSnap, + WriteTreeRef writesCache, + Node optCompleteCache, + boolean filterServerNode, + ChildChangeAccumulator accumulator) { + CacheNode oldServerSnap = oldViewCache.getServerCache(); + IndexedNode newServerCache; + NodeFilter serverFilter = filterServerNode ? this.filter : this.filter.getIndexedFilter(); + if (changePath.isEmpty()) { + newServerCache = + serverFilter.updateFullNode( + oldServerSnap.getIndexedNode(), + IndexedNode.from(changedSnap, serverFilter.getIndex()), + null); + } else if (serverFilter.filtersNodes() && !oldServerSnap.isFiltered()) { + // we want to filter the server node, but we didn't filter the server node yet, so simulate a + // full update + assert !changePath.isEmpty() : "An empty path should have been caught in the other branch"; + ChildKey childKey = changePath.getFront(); + Path updatePath = changePath.popFront(); + Node newChild = + oldServerSnap.getNode().getImmediateChild(childKey).updateChild(updatePath, changedSnap); + IndexedNode newServerNode = oldServerSnap.getIndexedNode().updateChild(childKey, newChild); + newServerCache = + serverFilter.updateFullNode(oldServerSnap.getIndexedNode(), newServerNode, null); + } else { + ChildKey childKey = changePath.getFront(); + if (!oldServerSnap.isCompleteForPath(changePath) && changePath.size() > 1) { + // We don't update incomplete nodes with updates intended for other listeners + return oldViewCache; + } + Path childChangePath = changePath.popFront(); + Node childNode = oldServerSnap.getNode().getImmediateChild(childKey); + Node newChildNode = childNode.updateChild(childChangePath, changedSnap); + if (childKey.isPriorityChildName()) { + newServerCache = serverFilter.updatePriority(oldServerSnap.getIndexedNode(), newChildNode); + } else { + newServerCache = + serverFilter.updateChild( + oldServerSnap.getIndexedNode(), + childKey, + newChildNode, + childChangePath, + NO_COMPLETE_SOURCE, + null); + } + } + ViewCache newViewCache = + oldViewCache.updateServerSnap( + newServerCache, + oldServerSnap.isFullyInitialized() || changePath.isEmpty(), + serverFilter.filtersNodes()); + NodeFilter.CompleteChildSource source = + new WriteTreeCompleteChildSource(writesCache, newViewCache, optCompleteCache); + return generateEventCacheAfterServerEvent( + newViewCache, changePath, writesCache, source, accumulator); + } + + private ViewCache applyUserOverwrite( + ViewCache oldViewCache, + Path changePath, + Node changedSnap, + WriteTreeRef writesCache, + Node optCompleteCache, + ChildChangeAccumulator accumulator) { + CacheNode oldEventSnap = oldViewCache.getEventCache(); + ViewCache newViewCache; + NodeFilter.CompleteChildSource source = + new WriteTreeCompleteChildSource(writesCache, oldViewCache, optCompleteCache); + if (changePath.isEmpty()) { + IndexedNode newIndexed = IndexedNode.from(changedSnap, this.filter.getIndex()); + IndexedNode newEventCache = + this.filter.updateFullNode( + oldViewCache.getEventCache().getIndexedNode(), newIndexed, accumulator); + newViewCache = oldViewCache.updateEventSnap(newEventCache, true, this.filter.filtersNodes()); + } else { + ChildKey childKey = changePath.getFront(); + if (childKey.isPriorityChildName()) { + IndexedNode newEventCache = + this.filter.updatePriority(oldViewCache.getEventCache().getIndexedNode(), changedSnap); + newViewCache = + oldViewCache.updateEventSnap( + newEventCache, oldEventSnap.isFullyInitialized(), oldEventSnap.isFiltered()); + } else { + Path childChangePath = changePath.popFront(); + Node oldChild = oldEventSnap.getNode().getImmediateChild(childKey); + Node newChild; + if (childChangePath.isEmpty()) { + // Child overwrite, we can replace the child + newChild = changedSnap; + } else { + Node childNode = source.getCompleteChild(childKey); + if (childNode != null) { + if (childChangePath.getBack().isPriorityChildName() + && childNode.getChild(childChangePath.getParent()).isEmpty()) { + // This is a priority update on an empty node. If this node exists on the server, the + // server will send down the priority in the update, so ignore for now + newChild = childNode; + } else { + newChild = childNode.updateChild(childChangePath, changedSnap); + } + } else { + // There is no complete child node available + newChild = EmptyNode.Empty(); + } + } + if (!oldChild.equals(newChild)) { + IndexedNode newEventSnap = + this.filter.updateChild( + oldEventSnap.getIndexedNode(), + childKey, + newChild, + childChangePath, + source, + accumulator); + newViewCache = + oldViewCache.updateEventSnap( + newEventSnap, oldEventSnap.isFullyInitialized(), this.filter.filtersNodes()); + } else { + newViewCache = oldViewCache; + } + } + } + return newViewCache; + } + + private static boolean cacheHasChild(ViewCache viewCache, ChildKey childKey) { + return viewCache.getEventCache().isCompleteForChild(childKey); + } + + private ViewCache applyUserMerge( + final ViewCache viewCache, + final Path path, + CompoundWrite changedChildren, + final WriteTreeRef writesCache, + final Node serverCache, + final ChildChangeAccumulator accumulator) { + // HACK: In the case of a limit query, there may be some changes that bump things out of the + // window leaving room for new items. It's important we process these changes first, so we + // iterate the changes twice, first processing any that affect items currently in view. + // TODO: I consider an item "in view" if cacheHasChild is true, which checks both the server + // and event snap. I'm not sure if this will result in edge cases when a child is in one but + // not the other. + assert changedChildren.rootWrite() == null : "Can't have a merge that is an overwrite"; + ViewCache currentViewCache = viewCache; + for (Map.Entry entry : changedChildren) { + Path writePath = path.child(entry.getKey()); + if (ViewProcessor.cacheHasChild(viewCache, writePath.getFront())) { + currentViewCache = + applyUserOverwrite( + currentViewCache, + writePath, + entry.getValue(), + writesCache, + serverCache, + accumulator); + } + } + + for (Map.Entry entry : changedChildren) { + Path writePath = path.child(entry.getKey()); + if (!ViewProcessor.cacheHasChild(viewCache, writePath.getFront())) { + currentViewCache = + applyUserOverwrite( + currentViewCache, + writePath, + entry.getValue(), + writesCache, + serverCache, + accumulator); + } + } + return currentViewCache; + } + + private ViewCache applyServerMerge( + final ViewCache viewCache, + final Path path, + CompoundWrite changedChildren, + final WriteTreeRef writesCache, + final Node serverCache, + final boolean filterServerNode, + final ChildChangeAccumulator accumulator) { + // If we don't have a cache yet, this merge was intended for a previously listen in the same + // location. Ignore it and wait for the complete data update coming soon. + if (viewCache.getServerCache().getNode().isEmpty() + && !viewCache.getServerCache().isFullyInitialized()) { + return viewCache; + } + + // HACK: In the case of a limit query, there may be some changes that bump things out of the + // window leaving room for new items. It's important we process these changes first, so we + // iterate the changes twice, first processing any that affect items currently in view. + // TODO: I consider an item "in view" if cacheHasChild is true, which checks both the server + // and event snap. I'm not sure if this will result in edge cases when a child is in one but + // not the other. + ViewCache curViewCache = viewCache; + assert changedChildren.rootWrite() == null : "Can't have a merge that is an overwrite"; + CompoundWrite actualMerge; + if (path.isEmpty()) { + actualMerge = changedChildren; + } else { + actualMerge = CompoundWrite.emptyWrite().addWrites(path, changedChildren); + } + Node serverNode = viewCache.getServerCache().getNode(); + Map childCompoundWrites = actualMerge.childCompoundWrites(); + for (Map.Entry childMerge : childCompoundWrites.entrySet()) { + ChildKey childKey = childMerge.getKey(); + if (serverNode.hasChild(childKey)) { + Node serverChild = serverNode.getImmediateChild(childKey); + Node newChild = childMerge.getValue().apply(serverChild); + curViewCache = + applyServerOverwrite( + curViewCache, + new Path(childKey), + newChild, + writesCache, + serverCache, + filterServerNode, + accumulator); + } + } + for (Map.Entry childMerge : childCompoundWrites.entrySet()) { + ChildKey childKey = childMerge.getKey(); + CompoundWrite childCompoundWrite = childMerge.getValue(); + boolean isUnknownDeepMerge = + !viewCache.getServerCache().isCompleteForChild(childKey) + && childCompoundWrite.rootWrite() == null; + if (!serverNode.hasChild(childKey) && !isUnknownDeepMerge) { + Node serverChild = serverNode.getImmediateChild(childKey); + Node newChild = childMerge.getValue().apply(serverChild); + curViewCache = + applyServerOverwrite( + curViewCache, + new Path(childKey), + newChild, + writesCache, + serverCache, + filterServerNode, + accumulator); + } + } + + return curViewCache; + } + + private ViewCache ackUserWrite( + ViewCache viewCache, + Path ackPath, + ImmutableTree affectedTree, + WriteTreeRef writesCache, + Node optCompleteCache, + ChildChangeAccumulator accumulator) { + if (writesCache.shadowingWrite(ackPath) != null) { + return viewCache; + } + + // Only filter server node if it is currently filtered + boolean filterServerNode = viewCache.getServerCache().isFiltered(); + + // Essentially we'll just get our existing server cache for the affected paths and re-apply it + // as a server update now that it won't be shadowed. + CacheNode serverCache = viewCache.getServerCache(); + if (affectedTree.getValue() != null) { + // This is an overwrite. + if ((ackPath.isEmpty() && serverCache.isFullyInitialized()) + || serverCache.isCompleteForPath(ackPath)) { + return applyServerOverwrite( + viewCache, + ackPath, + serverCache.getNode().getChild(ackPath), + writesCache, + optCompleteCache, + filterServerNode, + accumulator); + } else if (ackPath.isEmpty()) { + // This is a goofy edge case where we are acking data at this location but don't have full + // data. We should just re-apply whatever we have in our cache as a merge. + CompoundWrite changedChildren = CompoundWrite.emptyWrite(); + for (NamedNode child : serverCache.getNode()) { + changedChildren = changedChildren.addWrite(child.getName(), child.getNode()); + } + return applyServerMerge( + viewCache, + ackPath, + changedChildren, + writesCache, + optCompleteCache, + filterServerNode, + accumulator); + } else { + return viewCache; + } + } else { + // This is a merge. + CompoundWrite changedChildren = CompoundWrite.emptyWrite(); + for (Map.Entry entry : affectedTree) { + Path mergePath = entry.getKey(); + Path serverCachePath = ackPath.child(mergePath); + if (serverCache.isCompleteForPath(serverCachePath)) { + changedChildren = + changedChildren.addWrite(mergePath, serverCache.getNode().getChild(serverCachePath)); + } + } + return applyServerMerge( + viewCache, + ackPath, + changedChildren, + writesCache, + optCompleteCache, + filterServerNode, + accumulator); + } + } + + public ViewCache revertUserWrite( + ViewCache viewCache, + Path path, + WriteTreeRef writesCache, + Node optCompleteServerCache, + ChildChangeAccumulator accumulator) { + if (writesCache.shadowingWrite(path) != null) { + return viewCache; + } else { + NodeFilter.CompleteChildSource source = + new WriteTreeCompleteChildSource(writesCache, viewCache, optCompleteServerCache); + IndexedNode oldEventCache = viewCache.getEventCache().getIndexedNode(); + IndexedNode newEventCache; + if (path.isEmpty() || path.getFront().isPriorityChildName()) { + Node newNode; + if (viewCache.getServerCache().isFullyInitialized()) { + newNode = writesCache.calcCompleteEventCache(viewCache.getCompleteServerSnap()); + } else { + newNode = writesCache.calcCompleteEventChildren(viewCache.getServerCache().getNode()); + } + IndexedNode indexedNode = IndexedNode.from(newNode, this.filter.getIndex()); + newEventCache = this.filter.updateFullNode(oldEventCache, indexedNode, accumulator); + } else { + ChildKey childKey = path.getFront(); + Node newChild = writesCache.calcCompleteChild(childKey, viewCache.getServerCache()); + if (newChild == null && viewCache.getServerCache().isCompleteForChild(childKey)) { + newChild = oldEventCache.getNode().getImmediateChild(childKey); + } + if (newChild != null) { + newEventCache = + this.filter.updateChild( + oldEventCache, childKey, newChild, path.popFront(), source, accumulator); + } else if (newChild == null && viewCache.getEventCache().getNode().hasChild(childKey)) { + // No complete child available, delete the existing one, if any + newEventCache = + this.filter.updateChild( + oldEventCache, childKey, EmptyNode.Empty(), path.popFront(), source, accumulator); + } else { + newEventCache = oldEventCache; + } + if (newEventCache.getNode().isEmpty() && viewCache.getServerCache().isFullyInitialized()) { + // We might have reverted all child writes. Maybe the old event was a leaf node + Node complete = writesCache.calcCompleteEventCache(viewCache.getCompleteServerSnap()); + if (complete.isLeafNode()) { + IndexedNode indexedNode = IndexedNode.from(complete, this.filter.getIndex()); + newEventCache = this.filter.updateFullNode(newEventCache, indexedNode, accumulator); + } + } + } + boolean complete = + viewCache.getServerCache().isFullyInitialized() + || writesCache.shadowingWrite(Path.getEmptyPath()) != null; + return viewCache.updateEventSnap(newEventCache, complete, this.filter.filtersNodes()); + } + } + + private ViewCache listenComplete( + ViewCache viewCache, + Path path, + WriteTreeRef writesCache, + ChildChangeAccumulator accumulator) { + CacheNode oldServerNode = viewCache.getServerCache(); + ViewCache newViewCache = + viewCache.updateServerSnap( + oldServerNode.getIndexedNode(), + oldServerNode.isFullyInitialized() || path.isEmpty(), + oldServerNode.isFiltered()); + return generateEventCacheAfterServerEvent( + newViewCache, path, writesCache, NO_COMPLETE_SOURCE, accumulator); + } + + /** + * An implementation of CompleteChildSource that never returns any additional children + */ + private static final NodeFilter.CompleteChildSource NO_COMPLETE_SOURCE = + new NodeFilter.CompleteChildSource() { + @Override + public Node getCompleteChild(ChildKey childKey) { + return null; + } + + @Override + public NamedNode getChildAfterChild(Index index, NamedNode child, boolean reverse) { + return null; + } + }; + + /** + * An implementation of CompleteChildSource that uses a WriteTree in addition to any other server + * data or old event caches available to calculate complete children. + */ + private static class WriteTreeCompleteChildSource implements NodeFilter.CompleteChildSource { + + private final WriteTreeRef writes; + private final ViewCache viewCache; + private final Node optCompleteServerCache; + + public WriteTreeCompleteChildSource( + WriteTreeRef writes, ViewCache viewCache, Node optCompleteServerCache) { + this.writes = writes; + this.viewCache = viewCache; + this.optCompleteServerCache = optCompleteServerCache; + } + + @Override + public Node getCompleteChild(ChildKey childKey) { + CacheNode node = viewCache.getEventCache(); + if (node.isCompleteForChild(childKey)) { + return node.getNode().getImmediateChild(childKey); + } else { + CacheNode serverNode; + if (this.optCompleteServerCache != null) { + // Since we're only ever getting child nodes, we can use the key index here + serverNode = + new CacheNode( + IndexedNode.from(this.optCompleteServerCache, KeyIndex.getInstance()), + true, + false); + } else { + serverNode = viewCache.getServerCache(); + } + return this.writes.calcCompleteChild(childKey, serverNode); + } + } + + @Override + public NamedNode getChildAfterChild(Index index, NamedNode child, boolean reverse) { + Node completeServerData = + optCompleteServerCache != null + ? optCompleteServerCache + : viewCache.getCompleteServerSnap(); + return writes.calcNextNodeAfterPost(completeServerData, child, reverse, index); + } + } +} diff --git a/src/main/java/com/google/firebase/database/core/view/filter/ChildChangeAccumulator.java b/src/main/java/com/google/firebase/database/core/view/filter/ChildChangeAccumulator.java new file mode 100644 index 000000000..09f18fd4a --- /dev/null +++ b/src/main/java/com/google/firebase/database/core/view/filter/ChildChangeAccumulator.java @@ -0,0 +1,60 @@ +package com.google.firebase.database.core.view.filter; + +import com.google.firebase.database.core.view.Change; +import com.google.firebase.database.core.view.Event; +import com.google.firebase.database.snapshot.ChildKey; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +public class ChildChangeAccumulator { + + private final Map changeMap; + + public ChildChangeAccumulator() { + this.changeMap = new HashMap<>(); + } + + public void trackChildChange(Change change) { + Event.EventType type = change.getEventType(); + ChildKey childKey = change.getChildKey(); + assert type == Event.EventType.CHILD_ADDED + || type == Event.EventType.CHILD_CHANGED + || type == Event.EventType.CHILD_REMOVED + : "Only child changes supported for tracking"; + assert !change.getChildKey().isPriorityChildName(); + if (changeMap.containsKey(childKey)) { + Change oldChange = changeMap.get(childKey); + Event.EventType oldType = oldChange.getEventType(); + if (type == Event.EventType.CHILD_ADDED && oldType == Event.EventType.CHILD_REMOVED) { + changeMap.put( + change.getChildKey(), + Change.childChangedChange( + childKey, change.getIndexedNode(), oldChange.getIndexedNode())); + } else if (type == Event.EventType.CHILD_REMOVED && oldType == Event.EventType.CHILD_ADDED) { + changeMap.remove(childKey); + } else if (type == Event.EventType.CHILD_REMOVED + && oldType == Event.EventType.CHILD_CHANGED) { + changeMap.put(childKey, Change.childRemovedChange(childKey, oldChange.getOldIndexedNode())); + } else if (type == Event.EventType.CHILD_CHANGED && oldType == Event.EventType.CHILD_ADDED) { + changeMap.put(childKey, Change.childAddedChange(childKey, change.getIndexedNode())); + } else if (type == Event.EventType.CHILD_CHANGED + && oldType == Event.EventType.CHILD_CHANGED) { + changeMap.put( + childKey, + Change.childChangedChange( + childKey, change.getIndexedNode(), oldChange.getOldIndexedNode())); + } else { + throw new IllegalStateException( + "Illegal combination of changes: " + change + " occurred after " + oldChange); + } + } else { + changeMap.put(change.getChildKey(), change); + } + } + + public List getChanges() { + return new ArrayList<>(this.changeMap.values()); + } +} diff --git a/src/main/java/com/google/firebase/database/core/view/filter/IndexedFilter.java b/src/main/java/com/google/firebase/database/core/view/filter/IndexedFilter.java new file mode 100644 index 000000000..436be351e --- /dev/null +++ b/src/main/java/com/google/firebase/database/core/view/filter/IndexedFilter.java @@ -0,0 +1,121 @@ +package com.google.firebase.database.core.view.filter; + +import com.google.firebase.database.core.Path; +import com.google.firebase.database.core.view.Change; +import com.google.firebase.database.snapshot.ChildKey; +import com.google.firebase.database.snapshot.Index; +import com.google.firebase.database.snapshot.IndexedNode; +import com.google.firebase.database.snapshot.NamedNode; +import com.google.firebase.database.snapshot.Node; + +/** + * Doesn't really filter nodes but applies an index to the node and keeps track of any changes + */ +public class IndexedFilter implements NodeFilter { + + private final Index index; + + public IndexedFilter(Index index) { + this.index = index; + } + + @Override + public IndexedNode updateChild( + IndexedNode indexedNode, + ChildKey key, + Node newChild, + Path affectedPath, + CompleteChildSource source, + ChildChangeAccumulator optChangeAccumulator) { + assert indexedNode.hasIndex(this.index) : "The index must match the filter"; + Node snap = indexedNode.getNode(); + Node oldChild = snap.getImmediateChild(key); + // Check if anything actually changed. + if (oldChild.getChild(affectedPath).equals(newChild.getChild(affectedPath))) { + // There's an edge case where a child can enter or leave the view because affectedPath was + // set to null. In this case, affectedPath will appear null in both the old and new snapshots. + // So we need to avoid treating these cases as "nothing changed." + if (oldChild.isEmpty() == newChild.isEmpty()) { + // Nothing changed. + + // This assert should be valid, but it's expensive (can dominate perf testing) so don't + // actually do it. + //assert oldChild.equals(newChild): "Old and new snapshots should be equal."; + return indexedNode; + } + } + if (optChangeAccumulator != null) { + if (newChild.isEmpty()) { + if (snap.hasChild(key)) { + optChangeAccumulator.trackChildChange(Change.childRemovedChange(key, oldChild)); + } else { + assert snap.isLeafNode() + : "A child remove without an old child only makes sense on a leaf node"; + } + } else if (oldChild.isEmpty()) { + optChangeAccumulator.trackChildChange(Change.childAddedChange(key, newChild)); + } else { + optChangeAccumulator.trackChildChange(Change.childChangedChange(key, newChild, oldChild)); + } + } + if (snap.isLeafNode() && newChild.isEmpty()) { + return indexedNode; + } else { + // Make sure the node is indexed + return indexedNode.updateChild(key, newChild); + } + } + + @Override + public IndexedNode updateFullNode( + IndexedNode oldSnap, IndexedNode newSnap, ChildChangeAccumulator optChangeAccumulator) { + assert newSnap.hasIndex(this.index) : "Can't use IndexedNode that doesn't have filter's index"; + if (optChangeAccumulator != null) { + for (NamedNode child : oldSnap.getNode()) { + if (!newSnap.getNode().hasChild(child.getName())) { + optChangeAccumulator.trackChildChange( + Change.childRemovedChange(child.getName(), child.getNode())); + } + } + if (!newSnap.getNode().isLeafNode()) { + for (NamedNode child : newSnap.getNode()) { + if (oldSnap.getNode().hasChild(child.getName())) { + Node oldChild = oldSnap.getNode().getImmediateChild(child.getName()); + if (!oldChild.equals(child.getNode())) { + optChangeAccumulator.trackChildChange( + Change.childChangedChange(child.getName(), child.getNode(), oldChild)); + } + } else { + optChangeAccumulator.trackChildChange( + Change.childAddedChange(child.getName(), child.getNode())); + } + } + } + } + return newSnap; + } + + @Override + public IndexedNode updatePriority(IndexedNode oldSnap, Node newPriority) { + if (oldSnap.getNode().isEmpty()) { + return oldSnap; + } else { + return oldSnap.updatePriority(newPriority); + } + } + + @Override + public NodeFilter getIndexedFilter() { + return this; + } + + @Override + public Index getIndex() { + return this.index; + } + + @Override + public boolean filtersNodes() { + return false; + } +} diff --git a/src/main/java/com/google/firebase/database/core/view/filter/LimitedFilter.java b/src/main/java/com/google/firebase/database/core/view/filter/LimitedFilter.java new file mode 100644 index 000000000..5039df52c --- /dev/null +++ b/src/main/java/com/google/firebase/database/core/view/filter/LimitedFilter.java @@ -0,0 +1,192 @@ +package com.google.firebase.database.core.view.filter; + +import com.google.firebase.database.core.Path; +import com.google.firebase.database.core.view.Change; +import com.google.firebase.database.core.view.QueryParams; +import com.google.firebase.database.snapshot.ChildKey; +import com.google.firebase.database.snapshot.EmptyNode; +import com.google.firebase.database.snapshot.Index; +import com.google.firebase.database.snapshot.IndexedNode; +import com.google.firebase.database.snapshot.NamedNode; +import com.google.firebase.database.snapshot.Node; +import com.google.firebase.database.snapshot.PriorityUtilities; +import java.util.Iterator; + +/** + * Applies a limit and a range to a node and uses RangedFilter to do the heavy lifting where + * possible + */ +public class LimitedFilter implements NodeFilter { + + private final RangedFilter rangedFilter; + private final Index index; + private final int limit; + private final boolean reverse; + + public LimitedFilter(QueryParams params) { + this.rangedFilter = new RangedFilter(params); + this.index = params.getIndex(); + this.limit = params.getLimit(); + this.reverse = !params.isViewFromLeft(); + } + + @Override + public IndexedNode updateChild( + IndexedNode snap, + ChildKey key, + Node newChild, + Path affectedPath, + CompleteChildSource source, + ChildChangeAccumulator optChangeAccumulator) { + if (!rangedFilter.matches(new NamedNode(key, newChild))) { + newChild = EmptyNode.Empty(); + } + if (snap.getNode().getImmediateChild(key).equals(newChild)) { + // No change + return snap; + } else if (snap.getNode().getChildCount() < this.limit) { + return rangedFilter + .getIndexedFilter() + .updateChild(snap, key, newChild, affectedPath, source, optChangeAccumulator); + } else { + return fullLimitUpdateChild(snap, key, newChild, source, optChangeAccumulator); + } + } + + private IndexedNode fullLimitUpdateChild( + IndexedNode oldIndexed, + ChildKey childKey, + Node childSnap, + CompleteChildSource source, + ChildChangeAccumulator optChangeAccumulator) { + // TODO: rename all cache stuff etc to general snap terminology + assert oldIndexed.getNode().getChildCount() == this.limit; + NamedNode newChildNamedNode = new NamedNode(childKey, childSnap); + NamedNode windowBoundary = + this.reverse ? oldIndexed.getFirstChild() : oldIndexed.getLastChild(); + boolean inRange = rangedFilter.matches(newChildNamedNode); + if (oldIndexed.getNode().hasChild(childKey)) { + Node oldChildSnap = oldIndexed.getNode().getImmediateChild(childKey); + NamedNode nextChild = source.getChildAfterChild(this.index, windowBoundary, this.reverse); + while (nextChild != null + && (nextChild.getName().equals(childKey) + || oldIndexed.getNode().hasChild(nextChild.getName()))) { + // There is a weird edge case where a node is updated as part of a merge in the write tree, + // but hasn't been applied to the limited filter yet. Ignore this next child which will be + // updated later in the limited filter... + nextChild = source.getChildAfterChild(this.index, nextChild, this.reverse); + } + int compareNext = + nextChild == null ? 1 : index.compare(nextChild, newChildNamedNode, this.reverse); + boolean remainsInWindow = inRange && !childSnap.isEmpty() && compareNext >= 0; + if (remainsInWindow) { + if (optChangeAccumulator != null) { + optChangeAccumulator.trackChildChange( + Change.childChangedChange(childKey, childSnap, oldChildSnap)); + } + return oldIndexed.updateChild(childKey, childSnap); + } else { + if (optChangeAccumulator != null) { + optChangeAccumulator.trackChildChange(Change.childRemovedChange(childKey, oldChildSnap)); + } + IndexedNode newIndexed = oldIndexed.updateChild(childKey, EmptyNode.Empty()); + boolean nextChildInRange = nextChild != null && rangedFilter.matches(nextChild); + if (nextChildInRange) { + if (optChangeAccumulator != null) { + optChangeAccumulator.trackChildChange( + Change.childAddedChange(nextChild.getName(), nextChild.getNode())); + } + return newIndexed.updateChild(nextChild.getName(), nextChild.getNode()); + } else { + return newIndexed; + } + } + } else if (childSnap.isEmpty()) { + // we're deleting a node, but it was not in the window, so ignore it + return oldIndexed; + } else if (inRange) { + if (this.index.compare(windowBoundary, newChildNamedNode, this.reverse) >= 0) { + if (optChangeAccumulator != null) { + optChangeAccumulator.trackChildChange( + Change.childRemovedChange(windowBoundary.getName(), windowBoundary.getNode())); + optChangeAccumulator.trackChildChange(Change.childAddedChange(childKey, childSnap)); + } + return oldIndexed + .updateChild(childKey, childSnap) + .updateChild(windowBoundary.getName(), EmptyNode.Empty()); + } else { + return oldIndexed; + } + } else { + return oldIndexed; + } + } + + @Override + public IndexedNode updateFullNode( + IndexedNode oldSnap, IndexedNode newSnap, ChildChangeAccumulator optChangeAccumulator) { + IndexedNode filtered; + if (newSnap.getNode().isLeafNode() || newSnap.getNode().isEmpty()) { + // Make sure we have a children node with the correct index, not an empty or leaf node; + filtered = IndexedNode.from(EmptyNode.Empty(), this.index); + } else { + filtered = newSnap; + // Don't support priorities on queries + filtered = filtered.updatePriority(PriorityUtilities.NullPriority()); + NamedNode startPost; + NamedNode endPost; + Iterator iterator; + int sign; + if (this.reverse) { + iterator = newSnap.reverseIterator(); + startPost = rangedFilter.getEndPost(); + endPost = rangedFilter.getStartPost(); + sign = -1; + } else { + iterator = newSnap.iterator(); + startPost = rangedFilter.getStartPost(); + endPost = rangedFilter.getEndPost(); + sign = 1; + } + + int count = 0; + boolean foundStartPost = false; + while (iterator.hasNext()) { + NamedNode next = iterator.next(); + if (!foundStartPost && index.compare(startPost, next) * sign <= 0) { + // start adding + foundStartPost = true; + } + boolean inRange = + foundStartPost && count < this.limit && index.compare(next, endPost) * sign <= 0; + if (inRange) { + count++; + } else { + filtered = filtered.updateChild(next.getName(), EmptyNode.Empty()); + } + } + } + return rangedFilter.getIndexedFilter().updateFullNode(oldSnap, filtered, optChangeAccumulator); + } + + @Override + public IndexedNode updatePriority(IndexedNode oldSnap, Node newPriority) { + // Don't support priorities on queries + return oldSnap; + } + + @Override + public NodeFilter getIndexedFilter() { + return rangedFilter.getIndexedFilter(); + } + + @Override + public Index getIndex() { + return this.index; + } + + @Override + public boolean filtersNodes() { + return true; + } +} diff --git a/src/main/java/com/google/firebase/database/core/view/filter/NodeFilter.java b/src/main/java/com/google/firebase/database/core/view/filter/NodeFilter.java new file mode 100644 index 000000000..4622eecc4 --- /dev/null +++ b/src/main/java/com/google/firebase/database/core/view/filter/NodeFilter.java @@ -0,0 +1,70 @@ +package com.google.firebase.database.core.view.filter; + +import com.google.firebase.database.core.Path; +import com.google.firebase.database.snapshot.ChildKey; +import com.google.firebase.database.snapshot.Index; +import com.google.firebase.database.snapshot.IndexedNode; +import com.google.firebase.database.snapshot.NamedNode; +import com.google.firebase.database.snapshot.Node; + +/** + * NodeFilter is used to update nodes and complete children of nodes while applying queries on the + * fly and keeping track of any child changes. This class does not track value changes as value + * changes depend on more than just the node itself. Different kind of queries require different + * kind of implementations of this interface. + */ +public interface NodeFilter { + + /** + * Update a single complete child in the snap. If the child equals the old child in the snap, this + * is a no-op. The method expects an indexed snap. + */ + IndexedNode updateChild( + IndexedNode node, + ChildKey key, + Node newChild, + Path affectedPath, + CompleteChildSource source, + ChildChangeAccumulator optChangeAccumulator); + + /** + * Update a node in full and output any resulting change from this complete update. + */ + IndexedNode updateFullNode( + IndexedNode oldSnap, IndexedNode newSnap, ChildChangeAccumulator optChangeAccumulator); + + /** + * Update the priority of the root node + */ + IndexedNode updatePriority(IndexedNode oldSnap, Node newPriority); + + /** + * Returns true if children might be filtered due to query criteria + */ + boolean filtersNodes(); + + /** + * Returns the index filter that this filter uses to get a NodeFilter that doesn't filter any + * children. + */ + NodeFilter getIndexedFilter(); + + /** + * Returns the index that this filter uses + */ + Index getIndex(); + + /** + * Since updates to filtered nodes might require nodes to be pulled in from "outside" the node, + * this interface can help to get complete children that can be pulled in. A class implementing + * this interface takes potentially multiple sources (e.g. user writes, server data from other + * views etc.) to try it's best to get a complete child that might be useful in pulling into the + * view. + */ + interface CompleteChildSource { + + Node getCompleteChild(ChildKey childKey); + + NamedNode getChildAfterChild(Index index, NamedNode child, boolean reverse); + } +} diff --git a/src/main/java/com/google/firebase/database/core/view/filter/RangedFilter.java b/src/main/java/com/google/firebase/database/core/view/filter/RangedFilter.java new file mode 100644 index 000000000..4c04193f9 --- /dev/null +++ b/src/main/java/com/google/firebase/database/core/view/filter/RangedFilter.java @@ -0,0 +1,119 @@ +package com.google.firebase.database.core.view.filter; + +import com.google.firebase.database.core.Path; +import com.google.firebase.database.core.view.QueryParams; +import com.google.firebase.database.snapshot.ChildKey; +import com.google.firebase.database.snapshot.EmptyNode; +import com.google.firebase.database.snapshot.Index; +import com.google.firebase.database.snapshot.IndexedNode; +import com.google.firebase.database.snapshot.NamedNode; +import com.google.firebase.database.snapshot.Node; +import com.google.firebase.database.snapshot.PriorityUtilities; + +/** + * Filters nodes by range and uses an IndexFilter to track any changes after filtering the node + */ +public class RangedFilter implements NodeFilter { + + private final IndexedFilter indexedFilter; + private final Index index; + private final NamedNode startPost; + private final NamedNode endPost; + + public RangedFilter(QueryParams params) { + this.indexedFilter = new IndexedFilter(params.getIndex()); + this.index = params.getIndex(); + this.startPost = getStartPost(params); + this.endPost = getEndPost(params); + } + + public NamedNode getStartPost() { + return this.startPost; + } + + public NamedNode getEndPost() { + return this.endPost; + } + + private static NamedNode getStartPost(QueryParams params) { + if (params.hasStart()) { + ChildKey startName = params.getIndexStartName(); + return params.getIndex().makePost(startName, params.getIndexStartValue()); + } else { + return params.getIndex().minPost(); + } + } + + private static NamedNode getEndPost(QueryParams params) { + if (params.hasEnd()) { + ChildKey endName = params.getIndexEndName(); + return params.getIndex().makePost(endName, params.getIndexEndValue()); + } else { + return params.getIndex().maxPost(); + } + } + + public boolean matches(NamedNode node) { + if (this.index.compare(this.getStartPost(), node) <= 0 + && this.index.compare(node, this.getEndPost()) <= 0) { + return true; + } else { + return false; + } + } + + @Override + public IndexedNode updateChild( + IndexedNode snap, + ChildKey key, + Node newChild, + Path affectedPath, + CompleteChildSource source, + ChildChangeAccumulator optChangeAccumulator) { + if (!matches(new NamedNode(key, newChild))) { + newChild = EmptyNode.Empty(); + } + return indexedFilter.updateChild( + snap, key, newChild, affectedPath, source, optChangeAccumulator); + } + + @Override + public IndexedNode updateFullNode( + IndexedNode oldSnap, IndexedNode newSnap, ChildChangeAccumulator optChangeAccumulator) { + IndexedNode filtered; + if (newSnap.getNode().isLeafNode()) { + // Make sure we have a children node with the correct index, not an empty or leaf node; + filtered = IndexedNode.from(EmptyNode.Empty(), this.index); + } else { + // Don't support priorities on queries + filtered = newSnap.updatePriority(PriorityUtilities.NullPriority()); + for (NamedNode child : newSnap) { + if (!matches(child)) { + filtered = filtered.updateChild(child.getName(), EmptyNode.Empty()); + } + } + } + return indexedFilter.updateFullNode(oldSnap, filtered, optChangeAccumulator); + } + + @Override + public IndexedNode updatePriority(IndexedNode oldSnap, Node newPriority) { + // Don't support priorities on queries + return oldSnap; + } + + @Override + public NodeFilter getIndexedFilter() { + return this.indexedFilter; + } + + @Override + public Index getIndex() { + return this.index; + } + + @Override + public boolean filtersNodes() { + return true; + } +} diff --git a/src/main/java/com/google/firebase/database/logging/DefaultLogger.java b/src/main/java/com/google/firebase/database/logging/DefaultLogger.java new file mode 100644 index 000000000..5d66d681c --- /dev/null +++ b/src/main/java/com/google/firebase/database/logging/DefaultLogger.java @@ -0,0 +1,76 @@ +package com.google.firebase.database.logging; + +import java.util.Date; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +public class DefaultLogger implements Logger { + + private final Set enabledComponents; + private final Level minLevel; + + public DefaultLogger(Level level, List enabledComponents) { + if (enabledComponents != null) { + this.enabledComponents = new HashSet<>(enabledComponents); + } else { + this.enabledComponents = null; + } + minLevel = level; + } + + @Override + public Level getLogLevel() { + return this.minLevel; + } + + @Override + public void onLogMessage(Level level, String tag, String message, long msTimestamp) { + if (shouldLog(level, tag)) { + String toLog = buildLogMessage(level, tag, message, msTimestamp); + switch (level) { + case ERROR: + error(tag, toLog); + break; + case WARN: + warn(tag, toLog); + break; + case INFO: + info(tag, toLog); + break; + case DEBUG: + debug(tag, toLog); + break; + default: + throw new RuntimeException("Should not reach here!"); + } + } + } + + protected String buildLogMessage(Level level, String tag, String message, long msTimestamp) { + Date now = new Date(msTimestamp); + return now.toString() + " " + "[" + level + "] " + tag + ": " + message; + } + + protected void error(String tag, String toLog) { + System.err.println(toLog); + } + + protected void warn(String tag, String toLog) { + System.out.println(toLog); + } + + protected void info(String tag, String toLog) { + System.out.println(toLog); + } + + protected void debug(String tag, String toLog) { + System.out.println(toLog); + } + + protected boolean shouldLog(Level level, String tag) { + return (level.ordinal() >= minLevel.ordinal() + && (enabledComponents == null || level.ordinal() > Level.DEBUG.ordinal() + || enabledComponents.contains(tag))); + } +} diff --git a/src/main/java/com/google/firebase/database/logging/LogWrapper.java b/src/main/java/com/google/firebase/database/logging/LogWrapper.java new file mode 100644 index 000000000..aa0780cf6 --- /dev/null +++ b/src/main/java/com/google/firebase/database/logging/LogWrapper.java @@ -0,0 +1,84 @@ +package com.google.firebase.database.logging; + +import java.io.PrintWriter; +import java.io.StringWriter; + +/** + * User: greg + * Date: 6/12/13 + * Time: 2:23 PM + */ +public class LogWrapper { + + private static String exceptionStacktrace(Throwable e) { + StringWriter writer = new StringWriter(); + PrintWriter printWriter = new PrintWriter(writer); + e.printStackTrace(printWriter); + return writer.toString(); + } + + private final Logger logger; + private final String component; + private final String prefix; + + public LogWrapper(Logger logger, String component) { + this(logger, component, null); + } + + public LogWrapper(Logger logger, String component, String prefix) { + this.logger = logger; + this.component = component; + this.prefix = prefix; + } + + public void error(String message, Throwable e) { + String logMsg = toLog(message) + "\n" + exceptionStacktrace(e); + logger.onLogMessage(Logger.Level.ERROR, component, logMsg, now()); + } + + public void warn(String message) { + warn(message, null); + } + + public void warn(String message, Throwable e) { + String logMsg = toLog(message); + if (e != null) { + logMsg = logMsg + "\n" + exceptionStacktrace(e); + } + logger.onLogMessage(Logger.Level.WARN, component, logMsg, now()); + } + + public void info(String message) { + logger.onLogMessage(Logger.Level.INFO, component, toLog(message), now()); + } + + public void debug(String message, Object... args) { + this.debug(message, null, args); + } + + public boolean logsDebug() { + return this.logger.getLogLevel().ordinal() <= Logger.Level.DEBUG.ordinal(); + } + + /** + * Log a non-fatal exception. Typically something like an IO error on a failed connection + */ + public void debug(String message, Throwable e, Object... args) { + if (this.logsDebug()) { + String logMsg = toLog(message, args); + if (e != null) { + logMsg = logMsg + "\n" + exceptionStacktrace(e); + } + logger.onLogMessage(Logger.Level.DEBUG, component, logMsg, now()); + } + } + + private long now() { + return System.currentTimeMillis(); + } + + private String toLog(String message, Object... args) { + String formatted = (args.length > 0) ? String.format(message, args) : message; + return prefix == null ? formatted : prefix + " - " + formatted; + } +} diff --git a/src/main/java/com/google/firebase/database/logging/Logger.java b/src/main/java/com/google/firebase/database/logging/Logger.java new file mode 100644 index 000000000..1412ba6ea --- /dev/null +++ b/src/main/java/com/google/firebase/database/logging/Logger.java @@ -0,0 +1,27 @@ +package com.google.firebase.database.logging; + +/** + * Private (internal) logging interface used by Firebase Database. + * See {@link com.google.firebase.database.Config Config} for more information. + */ +public interface Logger { + + /** + * The log levels used by the Firebase Database library + */ + enum Level { + DEBUG, INFO, WARN, ERROR, NONE + } + + /** + * This method will be triggered whenever the library has something to log + * + * @param level The level of the log message + * @param tag The component that this log message is coming from + * @param message The message to be logged + * @param msTimestamp The timestamp, in milliseconds, at which this message was generated + */ + void onLogMessage(Level level, String tag, String message, long msTimestamp); + + Level getLogLevel(); +} diff --git a/src/main/java/com/google/firebase/database/snapshot/BooleanNode.java b/src/main/java/com/google/firebase/database/snapshot/BooleanNode.java new file mode 100644 index 000000000..c83293417 --- /dev/null +++ b/src/main/java/com/google/firebase/database/snapshot/BooleanNode.java @@ -0,0 +1,50 @@ +package com.google.firebase.database.snapshot; + +public class BooleanNode extends LeafNode { + + private final boolean value; + + public BooleanNode(Boolean value, Node priority) { + super(priority); + this.value = value; + } + + @Override + public Object getValue() { + return value; + } + + @Override + public String getHashRepresentation(HashVersion version) { + return getPriorityHash(version) + "boolean:" + value; + } + + @Override + public BooleanNode updatePriority(Node priority) { + return new BooleanNode(value, priority); + } + + @Override + protected LeafType getLeafType() { + return LeafType.Boolean; + } + + @Override + protected int compareLeafValues(BooleanNode other) { + return this.value == other.value ? 0 : (value ? 1 : -1); + } + + @Override + public boolean equals(Object other) { + if (!(other instanceof BooleanNode)) { + return false; + } + BooleanNode otherBooleanNode = (BooleanNode) other; + return value == otherBooleanNode.value && priority.equals(otherBooleanNode.priority); + } + + @Override + public int hashCode() { + return (this.value ? 1 : 0) + this.priority.hashCode(); + } +} diff --git a/src/main/java/com/google/firebase/database/snapshot/ChildKey.java b/src/main/java/com/google/firebase/database/snapshot/ChildKey.java new file mode 100644 index 000000000..69b230793 --- /dev/null +++ b/src/main/java/com/google/firebase/database/snapshot/ChildKey.java @@ -0,0 +1,132 @@ +package com.google.firebase.database.snapshot; + +import com.google.firebase.database.utilities.Utilities; + +public class ChildKey implements Comparable { + + private final String key; + + private static final ChildKey MIN_KEY = new ChildKey("[MIN_KEY]"); + private static final ChildKey MAX_KEY = new ChildKey("[MAX_KEY]"); + + // Singleton for priority child keys + private static final ChildKey PRIORITY_CHILD_KEY = new ChildKey(".priority"); + private static final ChildKey INFO_CHILD_KEY = new ChildKey(".info"); + + public static ChildKey getMinName() { + return MIN_KEY; + } + + public static ChildKey getMaxName() { + return MAX_KEY; + } + + public static ChildKey getPriorityKey() { + return PRIORITY_CHILD_KEY; + } + + public static ChildKey getInfoKey() { + return INFO_CHILD_KEY; + } + + private ChildKey(String key) { + this.key = key; + } + + public String asString() { + return this.key; + } + + public boolean isPriorityChildName() { + return this == PRIORITY_CHILD_KEY; + } + + protected boolean isInt() { + return false; + } + + protected int intValue() { + return 0; + } + + @Override + public int compareTo(ChildKey other) { + if (this == other) { + return 0; + } else if (this == MIN_KEY || other == MAX_KEY) { + return -1; + } else if (other == MIN_KEY || this == MAX_KEY) { + return 1; + } else if (this.isInt()) { + if (other.isInt()) { + int cmp = Utilities.compareInts(this.intValue(), other.intValue()); + return cmp == 0 ? Utilities.compareInts(this.key.length(), other.key.length()) : cmp; + } else { + return -1; + } + } else if (other.isInt()) { + return 1; + } else { + return this.key.compareTo(other.key); + } + } + + @Override + public String toString() { + return "ChildKey(\"" + this.key + "\")"; + } + + @Override + public int hashCode() { + return this.key.hashCode(); + } + + @Override + public boolean equals(Object obj) { + if (!(obj instanceof ChildKey)) { + return false; + } + if (this == obj) { + return true; + } + ChildKey other = (ChildKey) obj; + return this.key.equals(other.key); + } + + public static ChildKey fromString(String key) { + Integer intValue = Utilities.tryParseInt(key); + if (intValue != null) { + return new IntegerChildKey(key, intValue); + } else if (key.equals(".priority")) { + return PRIORITY_CHILD_KEY; + } else { + assert !key.contains("/"); + return new ChildKey(key); + } + } + + private static class IntegerChildKey extends ChildKey { + + private final int intValue; + + IntegerChildKey(String name, int intValue) { + super(name); + this.intValue = intValue; + } + + @Override + protected boolean isInt() { + return true; + } + + @Override + protected int intValue() { + return this.intValue; + } + + @Override + public String toString() { + return "IntegerChildName(\"" + super.key + "\")"; + } + } +} diff --git a/src/main/java/com/google/firebase/database/snapshot/ChildrenNode.java b/src/main/java/com/google/firebase/database/snapshot/ChildrenNode.java new file mode 100644 index 000000000..06e5bd5f8 --- /dev/null +++ b/src/main/java/com/google/firebase/database/snapshot/ChildrenNode.java @@ -0,0 +1,424 @@ +package com.google.firebase.database.snapshot; + +import com.google.firebase.database.collection.ImmutableSortedMap; +import com.google.firebase.database.collection.LLRBNode; +import com.google.firebase.database.core.Path; +import com.google.firebase.database.utilities.Utilities; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.HashMap; +import java.util.Iterator; +import java.util.List; +import java.util.Map; + +/** + * User: greg Date: 5/16/13 Time: 4:47 PM + */ +public class ChildrenNode implements Node { + + public static final Comparator NAME_ONLY_COMPARATOR = + new Comparator() { + @Override + public int compare(ChildKey o1, ChildKey o2) { + return o1.compareTo(o2); + } + }; + + private final ImmutableSortedMap children; + private final Node priority; + + private String lazyHash = null; + + private static class NamedNodeIterator implements Iterator { + + private final Iterator> iterator; + + public NamedNodeIterator(Iterator> iterator) { + this.iterator = iterator; + } + + @Override + public boolean hasNext() { + return iterator.hasNext(); + } + + @Override + public NamedNode next() { + Map.Entry entry = iterator.next(); + return new NamedNode(entry.getKey(), entry.getValue()); + } + + @Override + public void remove() { + iterator.remove(); + } + } + + /** */ + public abstract static class ChildVisitor extends LLRBNode.NodeVisitor { + + @Override + public void visitEntry(ChildKey key, Node value) { + visitChild(key, value); + } + + public abstract void visitChild(ChildKey name, Node child); + } + + protected ChildrenNode() { + this.children = ImmutableSortedMap.Builder.emptyMap(NAME_ONLY_COMPARATOR); + this.priority = PriorityUtilities.NullPriority(); + } + + protected ChildrenNode(ImmutableSortedMap children, Node priority) { + if (children.isEmpty() && !priority.isEmpty()) { + throw new IllegalArgumentException("Can't create empty ChildrenNode with priority!"); + } + this.priority = priority; + this.children = children; + } + + @Override + public boolean hasChild(ChildKey name) { + return !this.getImmediateChild(name).isEmpty(); + } + + @Override + public boolean isEmpty() { + return children.isEmpty(); + } + + @Override + public int getChildCount() { + return children.size(); + } + + @Override + public Object getValue() { + return getValue(false); + } + + @Override + public Object getValue(boolean useExportFormat) { + if (isEmpty()) { + return null; + } + + int numKeys = 0; + int maxKey = 0; + boolean allIntegerKeys = true; + Map result = new HashMap<>(); + for (Map.Entry entry : children) { + String key = entry.getKey().asString(); + result.put(key, entry.getValue().getValue(useExportFormat)); + numKeys++; + // If we already found a string key, don't bother with any of this + if (allIntegerKeys) { + if (key.length() > 1 && key.charAt(0) == '0') { + allIntegerKeys = false; + } else { + Integer keyAsInt = Utilities.tryParseInt(key); + if (keyAsInt != null && keyAsInt >= 0) { + if (keyAsInt > maxKey) { + maxKey = keyAsInt; + } + } else { + allIntegerKeys = false; + } + } + } + } + + if (!useExportFormat && allIntegerKeys && maxKey < 2 * numKeys) { + // convert to an array + List arrayResult = new ArrayList<>(maxKey + 1); + for (int i = 0; i <= maxKey; ++i) { + // Map.get will return null for non-existent values, so we don't have to worry about + // filling them in manually + arrayResult.add(result.get("" + i)); + } + return arrayResult; + } else { + if (useExportFormat && !priority.isEmpty()) { + result.put(".priority", priority.getValue()); + } + return result; + } + } + + @Override + public ChildKey getPredecessorChildKey(ChildKey childKey) { + return this.children.getPredecessorKey(childKey); + } + + @Override + public ChildKey getSuccessorChildKey(ChildKey childKey) { + return this.children.getSuccessorKey(childKey); + } + + @Override + public String getHashRepresentation(HashVersion version) { + if (version != HashVersion.V1) { + throw new IllegalArgumentException("Hashes on children nodes only supported for V1"); + } + final StringBuilder toHash = new StringBuilder(); + if (!priority.isEmpty()) { + toHash.append("priority:"); + toHash.append(priority.getHashRepresentation(HashVersion.V1)); + toHash.append(":"); + } + List nodes = new ArrayList<>(); + boolean sawPriority = false; + for (NamedNode node : this) { + nodes.add(node); + sawPriority = sawPriority || !node.getNode().getPriority().isEmpty(); + } + if (sawPriority) { + Collections.sort(nodes, PriorityIndex.getInstance()); + } + for (NamedNode node : nodes) { + String hashString = node.getNode().getHash(); + if (!hashString.equals("")) { + toHash.append(":"); + toHash.append(node.getName().asString()); + toHash.append(":"); + toHash.append(hashString); + } + } + return toHash.toString(); + } + + @Override + public String getHash() { + if (this.lazyHash == null) { + String hashString = getHashRepresentation(HashVersion.V1); + this.lazyHash = hashString.isEmpty() ? "" : Utilities.sha1HexDigest(hashString); + } + return this.lazyHash; + } + + @Override + public boolean isLeafNode() { + return false; + } + + @Override + public Node getPriority() { + return priority; + } + + @Override + public Node updatePriority(Node priority) { + if (this.children.isEmpty()) { + return EmptyNode.Empty(); + } else { + return new ChildrenNode(this.children, priority); + } + } + + @Override + public Node getImmediateChild(ChildKey name) { + // Hack to treat priority as a regular child + if (name.isPriorityChildName() && !this.priority.isEmpty()) { + return this.priority; + } else if (children.containsKey(name)) { + return children.get(name); + } else { + return EmptyNode.Empty(); + } + } + + @Override + public Node getChild(Path path) { + ChildKey front = path.getFront(); + if (front == null) { + return this; + } else { + return getImmediateChild(front).getChild(path.popFront()); + } + } + + public void forEachChild(final ChildVisitor visitor) { + forEachChild(visitor, /*includePriority=*/ false); + } + + public void forEachChild(final ChildVisitor visitor, boolean includePriority) { + if (!includePriority || this.getPriority().isEmpty()) { + children.inOrderTraversal(visitor); + } else { + children.inOrderTraversal( + new LLRBNode.NodeVisitor() { + boolean passedPriorityKey = false; + + @Override + public void visitEntry(ChildKey key, Node value) { + if (!passedPriorityKey && key.compareTo(ChildKey.getPriorityKey()) > 0) { + passedPriorityKey = true; + visitor.visitChild(ChildKey.getPriorityKey(), getPriority()); + } + visitor.visitChild(key, value); + } + }); + } + } + + public ChildKey getFirstChildKey() { + return children.getMinKey(); + } + + public ChildKey getLastChildKey() { + return children.getMaxKey(); + } + + @Override + public Node updateChild(Path path, Node newChildNode) { + ChildKey front = path.getFront(); + if (front == null) { + return newChildNode; + } else if (front.isPriorityChildName()) { + assert PriorityUtilities.isValidPriority(newChildNode); + return updatePriority(newChildNode); + } else { + Node newImmediateChild = getImmediateChild(front).updateChild(path.popFront(), newChildNode); + return updateImmediateChild(front, newImmediateChild); + } + } + + @Override + public Iterator iterator() { + return new NamedNodeIterator(children.iterator()); + } + + @Override + public Iterator reverseIterator() { + return new NamedNodeIterator(children.reverseIterator()); + } + + @Override + public Node updateImmediateChild(ChildKey key, Node newChildNode) { + if (key.isPriorityChildName()) { + return updatePriority(newChildNode); + } else { + ImmutableSortedMap newChildren = children; + if (newChildren.containsKey(key)) { + newChildren = newChildren.remove(key); + } + if (!newChildNode.isEmpty()) { + newChildren = newChildren.insert(key, newChildNode); + } + if (newChildren.isEmpty()) { + // Ignore priorities on empty nodes + return EmptyNode.Empty(); + } else { + return new ChildrenNode(newChildren, this.priority); + } + } + } + + @Override + public int compareTo(Node o) { + if (this.isEmpty()) { + if (o.isEmpty()) { + return 0; + } else { + return -1; + } + } else if (o.isLeafNode()) { + // Children nodes are greater than all leaf nodes + return 1; + } else if (o.isEmpty()) { + return 1; + } else if (o == Node.MAX_NODE) { + return -1; + } else { + // Must be another Children node + return 0; + } + } + + @Override + public boolean equals(Object otherObj) { + if (otherObj == null) { + return false; + } + if (otherObj == this) { + return true; + } + if (!(otherObj instanceof ChildrenNode)) { + return false; + } + ChildrenNode other = (ChildrenNode) otherObj; + if (!this.getPriority().equals(other.getPriority())) { + return false; + } else if (this.children.size() != other.children.size()) { + return false; + } else { + Iterator> thisIterator = this.children.iterator(); + Iterator> otherIterator = other.children.iterator(); + while (thisIterator.hasNext() && otherIterator.hasNext()) { + Map.Entry thisNameNode = thisIterator.next(); + Map.Entry otherNamedNode = otherIterator.next(); + if (!thisNameNode.getKey().equals(otherNamedNode.getKey()) + || !thisNameNode.getValue().equals(otherNamedNode.getValue())) { + return false; + } + } + if (thisIterator.hasNext() || otherIterator.hasNext()) { + throw new IllegalStateException("Something went wrong internally."); + } + return true; + } + } + + @Override + public int hashCode() { + int hashCode = 0; + for (NamedNode entry : this) { + hashCode = 31 * hashCode + entry.getName().hashCode(); + hashCode = 17 * hashCode + entry.getNode().hashCode(); + } + return hashCode; + } + + @Override + public String toString() { + StringBuilder builder = new StringBuilder(); + toString(builder, 0); + return builder.toString(); + } + + private static void addIndentation(StringBuilder builder, int indentation) { + for (int i = 0; i < indentation; i++) { + builder.append(" "); + } + } + + private void toString(StringBuilder builder, int indentation) { + if (this.children.isEmpty() && this.priority.isEmpty()) { + builder.append("{ }"); + } else { + builder.append("{\n"); + for (Map.Entry childEntry : this.children) { + addIndentation(builder, indentation + 2); + builder.append(childEntry.getKey().asString()); + builder.append("="); + if (childEntry.getValue() instanceof ChildrenNode) { + ChildrenNode childrenNode = (ChildrenNode) childEntry.getValue(); + childrenNode.toString(builder, indentation + 2); + } else { + builder.append(childEntry.getValue().toString()); + } + builder.append("\n"); + } + if (!this.priority.isEmpty()) { + addIndentation(builder, indentation + 2); + builder.append(".priority="); + builder.append(this.priority.toString()); + builder.append("\n"); + } + addIndentation(builder, indentation); + builder.append("}"); + } + } +} diff --git a/src/main/java/com/google/firebase/database/snapshot/CompoundHash.java b/src/main/java/com/google/firebase/database/snapshot/CompoundHash.java new file mode 100644 index 000000000..5c94f6eab --- /dev/null +++ b/src/main/java/com/google/firebase/database/snapshot/CompoundHash.java @@ -0,0 +1,226 @@ +package com.google.firebase.database.snapshot; + +import static com.google.firebase.database.utilities.Utilities.hardAssert; + +import com.google.firebase.database.core.Path; +import com.google.firebase.database.utilities.NodeSizeEstimator; +import com.google.firebase.database.utilities.Utilities; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Stack; + +public class CompoundHash { + + private final List posts; + private final List hashes; + + private CompoundHash(List posts, List hashes) { + if (posts.size() != hashes.size() - 1) { + throw new IllegalArgumentException( + "Number of posts need to be n-1 for n hashes in CompoundHash"); + } + this.posts = posts; + this.hashes = hashes; + } + + public List getPosts() { + return Collections.unmodifiableList(this.posts); + } + + public List getHashes() { + return Collections.unmodifiableList(this.hashes); + } + + /** */ + public interface SplitStrategy { + + boolean shouldSplit(CompoundHashBuilder state); + } + + private static class SimpleSizeSplitStrategy implements SplitStrategy { + + private final long splitThreshold; + + public SimpleSizeSplitStrategy(Node node) { + long estimatedNodeSize = NodeSizeEstimator.estimateSerializedNodeSize(node); + // Splits for + // 1k -> 512 (2 parts) + // 5k -> 715 (7 parts) + // 100k -> 3.2k (32 parts) + // 500k -> 7k (71 parts) + // 5M -> 23k (228 parts) + this.splitThreshold = Math.max(512, (long) Math.sqrt(estimatedNodeSize * 100)); + } + + @Override + public boolean shouldSplit(CompoundHashBuilder state) { + // Never split on priorities + return state.currentHashLength() > this.splitThreshold + && (state.currentPath().isEmpty() + || !state.currentPath().getBack().equals(ChildKey.getPriorityKey())); + } + } + + static class CompoundHashBuilder { + + // NOTE: We use the existence of this to know if we've started building a range (i.e. + // encountered a leaf node). + private StringBuilder optHashValueBuilder = null; + + // The current path as a stack. This is used in combination with currentPathDepth to + // simultaneously store the last leaf node path. The depth is changed when descending and + // ascending, at the same time the current key is set for the current depth. Because the keys + // are left unchanged for ascending the path will also contain the path of the last visited leaf + // node (using lastLeafDepth elements) + private Stack currentPath = new Stack<>(); + private int lastLeafDepth = -1; + private int currentPathDepth; + + private boolean needsComma = true; + + private final List currentPaths = new ArrayList<>(); + private final List currentHashes = new ArrayList<>(); + private final SplitStrategy splitStrategy; + + public CompoundHashBuilder(SplitStrategy strategy) { + this.splitStrategy = strategy; + } + + public boolean buildingRange() { + return this.optHashValueBuilder != null; + } + + public int currentHashLength() { + return this.optHashValueBuilder.length(); + } + + public Path currentPath() { + return this.currentPath(this.currentPathDepth); + } + + private Path currentPath(int depth) { + ChildKey[] segments = new ChildKey[depth]; + for (int i = 0; i < depth; i++) { + segments[i] = currentPath.get(i); + } + return new Path(segments); + } + + private void ensureRange() { + if (!buildingRange()) { + optHashValueBuilder = new StringBuilder(); + optHashValueBuilder.append("("); + for (ChildKey key : currentPath(currentPathDepth)) { + appendKey(optHashValueBuilder, key); + optHashValueBuilder.append(":("); + } + needsComma = false; + } + } + + private void appendKey(StringBuilder builder, ChildKey key) { + builder.append(Utilities.stringHashV2Representation(key.asString())); + } + + private void processLeaf(LeafNode node) { + ensureRange(); + + lastLeafDepth = currentPathDepth; + optHashValueBuilder.append(node.getHashRepresentation(Node.HashVersion.V2)); + needsComma = true; + if (splitStrategy.shouldSplit(this)) { + endRange(); + } + } + + private void startChild(ChildKey key) { + ensureRange(); + + if (needsComma) { + optHashValueBuilder.append(","); + } + appendKey(optHashValueBuilder, key); + optHashValueBuilder.append(":("); + + if (currentPathDepth == currentPath.size()) { + currentPath.add(key); + } else { + currentPath.set(currentPathDepth, key); + } + currentPathDepth++; + needsComma = false; + } + + private void endChild() { + currentPathDepth--; + if (buildingRange()) { + optHashValueBuilder.append(")"); + } + needsComma = true; + } + + private void finishHashing() { + hardAssert(currentPathDepth == 0, "Can't finish hashing in the middle processing a child"); + if (buildingRange()) { + endRange(); // Finish final range + } + // Always close with the empty hash for the remaining range to allow simple appending + currentHashes.add(""); + } + + private void endRange() { + hardAssert(buildingRange(), "Can't end range without starting a range!"); + // Add closing parenthesis for current depth + for (int i = 0; i < currentPathDepth; i++) { + optHashValueBuilder.append(")"); + } + optHashValueBuilder.append(")"); + + Path lastLeafPath = currentPath(lastLeafDepth); + String hash = Utilities.sha1HexDigest(optHashValueBuilder.toString()); + currentHashes.add(hash); + currentPaths.add(lastLeafPath); + + optHashValueBuilder = null; + } + } + + public static CompoundHash fromNode(Node node) { + return fromNode(node, new SimpleSizeSplitStrategy(node)); + } + + public static CompoundHash fromNode(Node node, SplitStrategy strategy) { + if (node.isEmpty()) { + return new CompoundHash(Collections.emptyList(), Collections.singletonList("")); + } else { + CompoundHashBuilder state = new CompoundHashBuilder(strategy); + processNode(node, state); + state.finishHashing(); + return new CompoundHash(state.currentPaths, state.currentHashes); + } + } + + private static void processNode(Node node, final CompoundHashBuilder state) { + if (node.isLeafNode()) { + state.processLeaf((LeafNode) node); + } else if (node.isEmpty()) { + throw new IllegalArgumentException("Can't calculate hash on empty node!"); + } else { + if (!(node instanceof ChildrenNode)) { + throw new IllegalStateException("Expected children node, but got: " + node); + } + ChildrenNode childrenNode = (ChildrenNode) node; + ChildrenNode.ChildVisitor visitor = + new ChildrenNode.ChildVisitor() { + @Override + public void visitChild(ChildKey name, Node child) { + state.startChild(name); + processNode(child, state); + state.endChild(); + } + }; + childrenNode.forEachChild(visitor, /*includePriority=*/ true); + } + } +} diff --git a/src/main/java/com/google/firebase/database/snapshot/DeferredValueNode.java b/src/main/java/com/google/firebase/database/snapshot/DeferredValueNode.java new file mode 100644 index 000000000..750385574 --- /dev/null +++ b/src/main/java/com/google/firebase/database/snapshot/DeferredValueNode.java @@ -0,0 +1,55 @@ +package com.google.firebase.database.snapshot; + +import java.util.Map; + +public class DeferredValueNode extends LeafNode { + + private Map value; + + public DeferredValueNode(Map value, Node priority) { + super(priority); + this.value = value; + } + + @Override + public Object getValue() { + return value; + } + + @Override + public String getHashRepresentation(HashVersion version) { + return getPriorityHash(version) + "deferredValue:" + value; + } + + @Override + public DeferredValueNode updatePriority(Node priority) { + assert PriorityUtilities.isValidPriority(priority); + return new DeferredValueNode(value, priority); + } + + @Override + protected LeafType getLeafType() { + return LeafType.DeferredValue; + } + + @Override + protected int compareLeafValues(DeferredValueNode other) { + // Deferred value nodes are always equal + return 0; + } + + @Override + public boolean equals(Object other) { + if (!(other instanceof DeferredValueNode)) { + return false; + } + DeferredValueNode otherDeferredValueNode = (DeferredValueNode) other; + return value.equals(otherDeferredValueNode.value) + && priority.equals(otherDeferredValueNode.priority); + } + + @Override + public int hashCode() { + return value.hashCode() + this.priority.hashCode(); + } +} diff --git a/src/main/java/com/google/firebase/database/snapshot/DoubleNode.java b/src/main/java/com/google/firebase/database/snapshot/DoubleNode.java new file mode 100644 index 000000000..19b330488 --- /dev/null +++ b/src/main/java/com/google/firebase/database/snapshot/DoubleNode.java @@ -0,0 +1,60 @@ +package com.google.firebase.database.snapshot; + +import com.google.firebase.database.utilities.Utilities; + +/** + * User: greg Date: 5/17/13 Time: 2:51 PM + */ +public class DoubleNode extends LeafNode { + + private final Double value; + + public DoubleNode(Double value, Node priority) { + super(priority); + this.value = value; + } + + @Override + public Object getValue() { + return value; + } + + @Override + public String getHashRepresentation(HashVersion version) { + String toHash = getPriorityHash(version); + toHash += "number:"; + toHash += Utilities.doubleToHashString(value); + return toHash; + } + + @Override + public DoubleNode updatePriority(Node priority) { + assert PriorityUtilities.isValidPriority(priority); + return new DoubleNode(value, priority); + } + + @Override + protected LeafType getLeafType() { + return LeafType.Number; + } + + @Override + protected int compareLeafValues(DoubleNode other) { + // TODO: unify number nodes + return this.value.compareTo(other.value); + } + + @Override + public boolean equals(Object other) { + if (!(other instanceof DoubleNode)) { + return false; + } + DoubleNode otherDoubleNode = (DoubleNode) other; + return value.equals(otherDoubleNode.value) && priority.equals(otherDoubleNode.priority); + } + + @Override + public int hashCode() { + return this.value.hashCode() + this.priority.hashCode(); + } +} diff --git a/src/main/java/com/google/firebase/database/snapshot/EmptyNode.java b/src/main/java/com/google/firebase/database/snapshot/EmptyNode.java new file mode 100644 index 000000000..75f2993ce --- /dev/null +++ b/src/main/java/com/google/firebase/database/snapshot/EmptyNode.java @@ -0,0 +1,150 @@ +package com.google.firebase.database.snapshot; + +import com.google.firebase.database.core.Path; +import java.util.Collections; +import java.util.Iterator; + +public class EmptyNode extends ChildrenNode implements Node { + + private static final EmptyNode empty = new EmptyNode(); + + private EmptyNode() { + // prevent instantiation + } + + public static EmptyNode Empty() { + return empty; + } + + @Override + public boolean isLeafNode() { + return false; + } + + @Override + public Node getPriority() { + return this; + } + + @Override + public Node getChild(Path path) { + return this; + } + + @Override + public Node getImmediateChild(ChildKey name) { + return this; + } + + @Override + public Node updateImmediateChild(ChildKey name, Node node) { + if (node.isEmpty()) { + return this; + } else if (name.isPriorityChildName()) { + // Don't allow priorities on empty nodes + return this; + } else { + return new ChildrenNode().updateImmediateChild(name, node); + } + } + + @Override + public Node updateChild(Path path, Node node) { + if (path.isEmpty()) { + return node; + } else { + ChildKey name = path.getFront(); + Node newImmediateChild = getImmediateChild(name).updateChild(path.popFront(), node); + return updateImmediateChild(name, newImmediateChild); + } + } + + @Override + public EmptyNode updatePriority(Node priority) { + // Don't allow priorities on empty nodes + return this; + } + + @Override + public boolean hasChild(ChildKey name) { + return false; + } + + @Override + public boolean isEmpty() { + return true; + } + + @Override + public int getChildCount() { + return 0; + } + + @Override + public Object getValue() { + return null; + } + + @Override + public Object getValue(boolean useExportFormat) { + return null; + } + + @Override + public ChildKey getPredecessorChildKey(ChildKey childKey) { + return null; + } + + @Override + public ChildKey getSuccessorChildKey(ChildKey childKey) { + return null; + } + + @Override + public String getHash() { + return ""; + } + + @Override + public String getHashRepresentation(HashVersion version) { + return ""; + } + + @Override + public Iterator iterator() { + return Collections.emptyList().iterator(); + } + + @Override + public Iterator reverseIterator() { + return Collections.emptyList().iterator(); + } + + @Override + public int compareTo(Node o) { + return o.isEmpty() ? 0 : -1; + } + + @Override + public boolean equals(Object o) { + if (o instanceof EmptyNode) { + // We don't have a priority, so we know were always equal + return true; + } else { + // have to check for an empty ChildrenNode, aka isEmpty is true + return (o instanceof Node) + && ((Node) o).isEmpty() + && getPriority().equals(((Node) o).getPriority()); + } + } + + @Override + public int hashCode() { + return 0; + } + + @Override + public String toString() { + return ""; + } +} diff --git a/src/main/java/com/google/firebase/database/snapshot/Index.java b/src/main/java/com/google/firebase/database/snapshot/Index.java new file mode 100644 index 000000000..4a406aba7 --- /dev/null +++ b/src/main/java/com/google/firebase/database/snapshot/Index.java @@ -0,0 +1,46 @@ +package com.google.firebase.database.snapshot; + +import com.google.firebase.database.core.Path; +import java.util.Comparator; + +public abstract class Index implements Comparator { + + public abstract boolean isDefinedOn(Node a); + + public boolean indexedValueChanged(Node oldNode, Node newNode) { + NamedNode oldWrapped = new NamedNode(ChildKey.getMinName(), oldNode); + NamedNode newWrapped = new NamedNode(ChildKey.getMinName(), newNode); + return this.compare(oldWrapped, newWrapped) != 0; + } + + public abstract NamedNode makePost(ChildKey name, Node value); + + public NamedNode minPost() { + return NamedNode.getMinNode(); + } + + public abstract NamedNode maxPost(); + + public abstract String getQueryDefinition(); + + public static Index fromQueryDefinition(String str) { + if (str.equals(".value")) { + return ValueIndex.getInstance(); + } else if (str.equals(".key")) { + return KeyIndex.getInstance(); + } else if (str.equals(".priority")) { + throw new IllegalStateException( + "queryDefinition shouldn't ever be .priority since it's the default"); + } else { + return new PathIndex(new Path(str)); + } + } + + public int compare(NamedNode one, NamedNode two, boolean reverse) { + if (reverse) { + return this.compare(two, one); + } else { + return this.compare(one, two); + } + } +} diff --git a/src/main/java/com/google/firebase/database/snapshot/IndexedNode.java b/src/main/java/com/google/firebase/database/snapshot/IndexedNode.java new file mode 100644 index 000000000..fa68523d2 --- /dev/null +++ b/src/main/java/com/google/firebase/database/snapshot/IndexedNode.java @@ -0,0 +1,165 @@ +package com.google.firebase.database.snapshot; + +import com.google.firebase.database.collection.ImmutableSortedSet; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Iterator; +import java.util.List; + +/** + * Represents a node together with an index. The index and node are updated in unison. In the case + * where the index does not affect the ordering (i.e. the ordering is identical to the key ordering) + * this class uses a fallback index to save memory. Everything operating on the index must special + * case the fallback index. + */ +public class IndexedNode implements Iterable { + + /** + * This is a sentinal value, so it's fine to just use null for the comparator as it will never be + * invoked. + */ + private static final ImmutableSortedSet FALLBACK_INDEX = + new ImmutableSortedSet<>(Collections.emptyList(), null); + + private final Node node; + /** + * The indexed set is initialized lazily for cases where we don't need to access any order + * specific methods + */ + private ImmutableSortedSet indexed; + + private final Index index; + + private IndexedNode(Node node, Index index) { + this.index = index; + this.node = node; + // Index lazily + this.indexed = null; + } + + private IndexedNode(Node node, Index index, ImmutableSortedSet indexed) { + this.index = index; + this.node = node; + this.indexed = indexed; + } + + private void ensureIndexed() { + if (this.indexed == null) { + // Not indexed yet, create now + if (this.index.equals(KeyIndex.getInstance())) { + this.indexed = FALLBACK_INDEX; + } else { + List children = new ArrayList<>(); + boolean sawIndexedValue = false; + for (NamedNode entry : node) { + sawIndexedValue = sawIndexedValue || index.isDefinedOn(entry.getNode()); + NamedNode namedNode = new NamedNode(entry.getName(), entry.getNode()); + children.add(namedNode); + } + if (sawIndexedValue) { + this.indexed = new ImmutableSortedSet<>(children, index); + } else { + this.indexed = FALLBACK_INDEX; + } + } + } + } + + public static IndexedNode from(Node node) { + return new IndexedNode(node, PriorityIndex.getInstance()); + } + + public static IndexedNode from(Node node, Index index) { + return new IndexedNode(node, index); + } + + public boolean hasIndex(Index index) { + return this.index.equals(index); + } + + public Node getNode() { + return this.node; + } + + @Override + public Iterator iterator() { + ensureIndexed(); + if (this.indexed == FALLBACK_INDEX) { + return this.node.iterator(); + } else { + return this.indexed.iterator(); + } + } + + public Iterator reverseIterator() { + ensureIndexed(); + if (this.indexed == FALLBACK_INDEX) { + return this.node.reverseIterator(); + } else { + return this.indexed.reverseIterator(); + } + } + + public IndexedNode updateChild(ChildKey key, Node child) { + Node newNode = this.node.updateImmediateChild(key, child); + if (this.indexed == FALLBACK_INDEX && !this.index.isDefinedOn(child)) { + // doesn't affect the index, no need to create an index + return new IndexedNode(newNode, this.index, FALLBACK_INDEX); + } else if (this.indexed == null || this.indexed == FALLBACK_INDEX) { + // No need to index yet, index lazily + return new IndexedNode(newNode, this.index, null); + } else { + Node oldChild = this.node.getImmediateChild(key); + ImmutableSortedSet newIndexed = this.indexed.remove(new NamedNode(key, oldChild)); + if (!child.isEmpty()) { + newIndexed = newIndexed.insert(new NamedNode(key, child)); + } + return new IndexedNode(newNode, this.index, newIndexed); + } + } + + public IndexedNode updatePriority(Node priority) { + return new IndexedNode(node.updatePriority(priority), this.index, this.indexed); + } + + public NamedNode getFirstChild() { + if (!(this.node instanceof ChildrenNode)) { + return null; + } else { + ensureIndexed(); + if (this.indexed == FALLBACK_INDEX) { + ChildKey firstKey = ((ChildrenNode) this.node).getFirstChildKey(); + return new NamedNode(firstKey, this.node.getImmediateChild(firstKey)); + } else { + return this.indexed.getMinEntry(); + } + } + } + + public NamedNode getLastChild() { + if (!(this.node instanceof ChildrenNode)) { + return null; + } else { + ensureIndexed(); + if (this.indexed == FALLBACK_INDEX) { + ChildKey lastKey = ((ChildrenNode) this.node).getLastChildKey(); + return new NamedNode(lastKey, this.node.getImmediateChild(lastKey)); + } else { + return this.indexed.getMaxEntry(); + } + } + } + + public ChildKey getPredecessorChildName(ChildKey childKey, Node childNode, Index index) { + if (!this.index.equals(KeyIndex.getInstance()) && !this.index.equals(index)) { + throw new IllegalArgumentException("Index not available in IndexedNode!"); + } + ensureIndexed(); + if (this.indexed == FALLBACK_INDEX) { + return this.node.getPredecessorChildKey(childKey); + } else { + NamedNode node = this.indexed.getPredecessorEntry(new NamedNode(childKey, childNode)); + return node != null ? node.getName() : null; + } + } +} diff --git a/src/main/java/com/google/firebase/database/snapshot/KeyIndex.java b/src/main/java/com/google/firebase/database/snapshot/KeyIndex.java new file mode 100644 index 000000000..0e748d360 --- /dev/null +++ b/src/main/java/com/google/firebase/database/snapshot/KeyIndex.java @@ -0,0 +1,57 @@ +package com.google.firebase.database.snapshot; + +public class KeyIndex extends Index { + + private static final KeyIndex INSTANCE = new KeyIndex(); + + public static KeyIndex getInstance() { + return INSTANCE; + } + + private KeyIndex() { + // prevent instantiation + } + + @Override + public boolean isDefinedOn(Node a) { + return true; + } + + @Override + public NamedNode makePost(ChildKey name, Node value) { + assert value instanceof StringNode; + // We just use empty node, but it'll never be compared, since our comparator only looks at name + return new NamedNode(ChildKey.fromString((String) value.getValue()), EmptyNode.Empty()); + } + + @Override + public NamedNode maxPost() { + return NamedNode.getMaxNode(); + } + + @Override + public String getQueryDefinition() { + return ".key"; + } + + @Override + public int compare(NamedNode o1, NamedNode o2) { + return o1.getName().compareTo(o2.getName()); + } + + @Override + public boolean equals(Object o) { + return o instanceof KeyIndex; + } + + @Override + public int hashCode() { + // chosen by a fair dice roll. Guaranteed to be random + return 37; + } + + @Override + public String toString() { + return "KeyIndex"; + } +} diff --git a/src/main/java/com/google/firebase/database/snapshot/LeafNode.java b/src/main/java/com/google/firebase/database/snapshot/LeafNode.java new file mode 100644 index 000000000..147a86df2 --- /dev/null +++ b/src/main/java/com/google/firebase/database/snapshot/LeafNode.java @@ -0,0 +1,205 @@ +package com.google.firebase.database.snapshot; + +import com.google.firebase.database.core.Path; +import com.google.firebase.database.utilities.Utilities; +import java.util.Collections; +import java.util.HashMap; +import java.util.Iterator; +import java.util.Map; + +/** + * User: greg Date: 5/16/13 Time: 4:42 PM + */ +public abstract class LeafNode implements Node { + + /** */ + protected enum LeafType { + // The order here defines the ordering of leaf nodes + DeferredValue, + Boolean, + Number, + String + } + + protected final Node priority; + private String lazyHash; + + LeafNode(Node priority) { + this.priority = priority; + } + + @Override + public boolean hasChild(ChildKey childKey) { + return false; + } + + @Override + public boolean isLeafNode() { + return true; + } + + @Override + public Node getPriority() { + return priority; + } + + @Override + public Node getChild(Path path) { + if (path.isEmpty()) { + return this; + } else if (path.getFront().isPriorityChildName()) { + return this.priority; + } else { + return EmptyNode.Empty(); + } + } + + @Override + public Node updateChild(Path path, Node node) { + ChildKey front = path.getFront(); + if (front == null) { + return node; + } else if (node.isEmpty() && !front.isPriorityChildName()) { + return this; + } else { + assert !path.getFront().isPriorityChildName() || path.size() == 1; + return updateImmediateChild(front, EmptyNode.Empty().updateChild(path.popFront(), node)); + } + } + + @Override + public boolean isEmpty() { + return false; + } + + @Override + public int getChildCount() { + return 0; + } + + @Override + public ChildKey getPredecessorChildKey(ChildKey childKey) { + return null; + } + + @Override + public ChildKey getSuccessorChildKey(ChildKey childKey) { + return null; + } + + @Override + public Node getImmediateChild(ChildKey name) { + if (name.isPriorityChildName()) { + return this.priority; + } else { + return EmptyNode.Empty(); + } + } + + @Override + public Object getValue(boolean useExportFormat) { + if (!useExportFormat || priority.isEmpty()) { + return getValue(); + } else { + Map result = new HashMap<>(); + result.put(".value", getValue()); + result.put(".priority", priority.getValue()); + return result; + } + } + + @Override + public Node updateImmediateChild(ChildKey name, Node node) { + if (name.isPriorityChildName()) { + return this.updatePriority(node); + } else if (node.isEmpty()) { + return this; + } else { + return EmptyNode.Empty().updateImmediateChild(name, node).updatePriority(this.priority); + } + } + + @Override + public String getHash() { + if (this.lazyHash == null) { + this.lazyHash = Utilities.sha1HexDigest(getHashRepresentation(HashVersion.V1)); + } + return this.lazyHash; + } + + protected String getPriorityHash(HashVersion version) { + switch (version) { + case V1: + case V2: + if (priority.isEmpty()) { + return ""; + } else { + return "priority:" + priority.getHashRepresentation(version) + ":"; + } + default: + throw new IllegalArgumentException("Unknown hash version: " + version); + } + } + + protected abstract LeafType getLeafType(); + + @Override + public Iterator iterator() { + return Collections.emptyList().iterator(); + } + + @Override + public Iterator reverseIterator() { + return Collections.emptyList().iterator(); + } + + private static int compareLongDoubleNodes(LongNode longNode, DoubleNode doubleNode) { + Double longDoubleValue = Double.valueOf((Long) longNode.getValue()); + return (longDoubleValue).compareTo((Double) doubleNode.getValue()); + } + + @Override + public int compareTo(Node other) { + if (other.isEmpty()) { + return 1; + } else if (other instanceof ChildrenNode) { + return -1; + } else { + assert other.isLeafNode() : "Node is not leaf node!"; + if (this instanceof LongNode && other instanceof DoubleNode) { + return compareLongDoubleNodes((LongNode) this, (DoubleNode) other); + } else if (this instanceof DoubleNode && other instanceof LongNode) { + return -1 * compareLongDoubleNodes((LongNode) other, (DoubleNode) this); + } else { + return this.leafCompare((LeafNode) other); + } + } + } + + protected abstract int compareLeafValues(T other); + + protected int leafCompare(LeafNode other) { + LeafType thisLeafType = this.getLeafType(); + LeafType otherLeafType = other.getLeafType(); + if (thisLeafType.equals(otherLeafType)) { + // leaf type is the same, so we can safely cast to the right type + @SuppressWarnings("unchecked") + int value = this.compareLeafValues((T) other); + return value; + } else { + return thisLeafType.compareTo(otherLeafType); + } + } + + @Override + public abstract boolean equals(Object other); + + @Override + public abstract int hashCode(); + + @Override + public String toString() { + String str = getValue(true).toString(); + return str.length() <= 100 ? str : (str.substring(0, 100) + "..."); + } +} diff --git a/src/main/java/com/google/firebase/database/snapshot/LongNode.java b/src/main/java/com/google/firebase/database/snapshot/LongNode.java new file mode 100644 index 000000000..24a16e68b --- /dev/null +++ b/src/main/java/com/google/firebase/database/snapshot/LongNode.java @@ -0,0 +1,59 @@ +package com.google.firebase.database.snapshot; + +import com.google.firebase.database.utilities.Utilities; + +/** + * User: greg Date: 5/17/13 Time: 2:47 PM + */ +public class LongNode extends LeafNode { + + private final long value; + + public LongNode(Long value, Node priority) { + super(priority); + this.value = value; + } + + @Override + public Object getValue() { + return value; + } + + @Override + public String getHashRepresentation(HashVersion version) { + String toHash = getPriorityHash(version); + toHash += "number:"; + toHash += Utilities.doubleToHashString((double) value); + return toHash; + } + + @Override + public LongNode updatePriority(Node priority) { + return new LongNode(value, priority); + } + + @Override + protected LeafType getLeafType() { + // TODO: unify number nodes + return LeafType.Number; + } + + @Override + protected int compareLeafValues(LongNode other) { + return Utilities.compareLongs(this.value, other.value); + } + + @Override + public boolean equals(Object other) { + if (!(other instanceof LongNode)) { + return false; + } + LongNode otherLongNode = (LongNode) other; + return value == otherLongNode.value && priority.equals(otherLongNode.priority); + } + + @Override + public int hashCode() { + return (int) (value ^ (value >>> 32)) + priority.hashCode(); + } +} diff --git a/src/main/java/com/google/firebase/database/snapshot/NamedNode.java b/src/main/java/com/google/firebase/database/snapshot/NamedNode.java new file mode 100644 index 000000000..121dc53ab --- /dev/null +++ b/src/main/java/com/google/firebase/database/snapshot/NamedNode.java @@ -0,0 +1,64 @@ +package com.google.firebase.database.snapshot; + +public class NamedNode { + + private final ChildKey name; + private final Node node; + + private static final NamedNode MIN_NODE = new NamedNode(ChildKey.getMinName(), EmptyNode.Empty()); + private static final NamedNode MAX_NODE = new NamedNode(ChildKey.getMaxName(), Node.MAX_NODE); + + public static NamedNode getMinNode() { + return MIN_NODE; + } + + public static NamedNode getMaxNode() { + return MAX_NODE; + } + + public NamedNode(ChildKey name, Node node) { + this.name = name; + this.node = node; + } + + public ChildKey getName() { + return this.name; + } + + public Node getNode() { + return this.node; + } + + @Override + public String toString() { + return "NamedNode{" + "name=" + name + ", node=" + node + '}'; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + + NamedNode namedNode = (NamedNode) o; + + if (!name.equals(namedNode.name)) { + return false; + } + if (!node.equals(namedNode.node)) { + return false; + } + + return true; + } + + @Override + public int hashCode() { + int result = name.hashCode(); + result = 31 * result + node.hashCode(); + return result; + } +} diff --git a/src/main/java/com/google/firebase/database/snapshot/Node.java b/src/main/java/com/google/firebase/database/snapshot/Node.java new file mode 100644 index 000000000..25ee2e4b9 --- /dev/null +++ b/src/main/java/com/google/firebase/database/snapshot/Node.java @@ -0,0 +1,94 @@ +package com.google.firebase.database.snapshot; + +import com.google.firebase.database.core.Path; +import java.util.Iterator; + +/** + * User: greg Date: 5/16/13 Time: 4:38 PM + */ +public interface Node extends Comparable, Iterable { + + /** */ + enum HashVersion { + // V1 is the initial hashing schema used by Firebase Database + V1, + // V2 escapes and quotes strings and is used by compound hashing + V2 + } + + boolean isLeafNode(); + + Node getPriority(); + + Node getChild(Path path); + + Node getImmediateChild(ChildKey name); + + Node updateImmediateChild(ChildKey name, Node node); + + ChildKey getPredecessorChildKey(ChildKey childKey); + + ChildKey getSuccessorChildKey(ChildKey childKey); + + Node updateChild(Path path, Node node); + + Node updatePriority(Node priority); + + boolean hasChild(ChildKey name); + + boolean isEmpty(); + + int getChildCount(); + + Object getValue(); + + Object getValue(boolean useExportFormat); + + String getHash(); + + String getHashRepresentation(HashVersion version); + + Iterator reverseIterator(); + + ChildrenNode MAX_NODE = + new ChildrenNode() { + @Override + public int compareTo(Node other) { + return (other == this) ? 0 : 1; + } + + @Override + public boolean equals(Object other) { + return other == this; + } + + @Override + public Node getPriority() { + return this; + } + + @Override + public boolean isEmpty() { + return false; + } + + @Override + public boolean hasChild(ChildKey childKey) { + return false; + } + + @Override + public Node getImmediateChild(ChildKey name) { + if (name.isPriorityChildName()) { + return getPriority(); + } else { + return EmptyNode.Empty(); + } + } + + @Override + public String toString() { + return ""; + } + }; +} diff --git a/src/main/java/com/google/firebase/database/snapshot/NodeUtilities.java b/src/main/java/com/google/firebase/database/snapshot/NodeUtilities.java new file mode 100644 index 000000000..d9fbb78b9 --- /dev/null +++ b/src/main/java/com/google/firebase/database/snapshot/NodeUtilities.java @@ -0,0 +1,109 @@ +package com.google.firebase.database.snapshot; + +import com.google.firebase.database.DatabaseException; +import com.google.firebase.database.collection.ImmutableSortedMap; +import com.google.firebase.database.core.ServerValues; +import java.util.HashMap; +import java.util.Iterator; +import java.util.List; +import java.util.Map; + +/** + * Utility functions to convert Node data to and from JSON. + */ +public class NodeUtilities { + + public static Node NodeFromJSON(Object value) throws DatabaseException { + return NodeFromJSON(value, PriorityUtilities.NullPriority()); + } + + public static Node NodeFromJSON(Object value, Node priority) throws DatabaseException { + try { + if (value instanceof Map) { + Map mapValue = (Map) value; + if (mapValue.containsKey(".priority")) { + priority = PriorityUtilities.parsePriority(mapValue.get(".priority")); + } + + if (mapValue.containsKey(".value")) { + value = mapValue.get(".value"); + } + } + + if (value == null) { + return EmptyNode.Empty(); + } else if (value instanceof String) { + return new StringNode((String) value, priority); + } else if (value instanceof Long) { + return new LongNode((Long) value, priority); + } else if (value instanceof Integer) { + return new LongNode((long) (Integer) value, priority); + } else if (value instanceof Double) { + return new DoubleNode((Double) value, priority); + } else if (value instanceof Boolean) { + return new BooleanNode((Boolean) value, priority); + } else if (value instanceof Map || value instanceof List) { + Map childData; + // TODO: refine this and use same code to iterate over array and map by building + // List + if (value instanceof Map) { + Map mapValue = (Map) value; + if (mapValue.containsKey(ServerValues.NAME_SUBKEY_SERVERVALUE)) { + @SuppressWarnings("unchecked") + Node node = new DeferredValueNode(mapValue, priority); + return node; + } else { + childData = new HashMap<>(mapValue.size()); + @SuppressWarnings("unchecked") + Iterator keyIter = (Iterator) mapValue.keySet().iterator(); + while (keyIter.hasNext()) { + String key = keyIter.next(); + if (!key.startsWith(".")) { + Node childNode = NodeFromJSON(mapValue.get(key)); + if (!childNode.isEmpty()) { + ChildKey childKey = ChildKey.fromString(key); + childData.put(childKey, childNode); + } + } + } + } + } else { // List + List listValue = (List) value; + childData = new HashMap<>(listValue.size()); + for (int i = 0; i < listValue.size(); ++i) { + String key = "" + i; + Node childNode = NodeFromJSON(listValue.get(i)); + if (!childNode.isEmpty()) { + ChildKey childKey = ChildKey.fromString(key); + childData.put(childKey, childNode); + } + } + } + + if (childData.isEmpty()) { + return EmptyNode.Empty(); + } else { + ImmutableSortedMap childSet = + ImmutableSortedMap.Builder.fromMap(childData, ChildrenNode.NAME_ONLY_COMPARATOR); + return new ChildrenNode(childSet, priority); + } + } else { + throw new DatabaseException( + "Failed to parse node with class " + value.getClass().toString()); + } + } catch (ClassCastException e) { + throw new DatabaseException("Failed to parse node", e); + } + } + + public static int nameAndPriorityCompare( + ChildKey aKey, Node aPriority, ChildKey bKey, Node bPriority) { + + int priCmp = aPriority.compareTo(bPriority); + if (priCmp != 0) { + return priCmp; + } else { + return aKey.compareTo(bKey); + } + } +} diff --git a/src/main/java/com/google/firebase/database/snapshot/PathIndex.java b/src/main/java/com/google/firebase/database/snapshot/PathIndex.java new file mode 100644 index 000000000..c763927af --- /dev/null +++ b/src/main/java/com/google/firebase/database/snapshot/PathIndex.java @@ -0,0 +1,73 @@ +package com.google.firebase.database.snapshot; + +import com.google.firebase.database.core.Path; + +public class PathIndex extends Index { + + private final Path indexPath; + + public PathIndex(Path indexPath) { + if (indexPath.size() == 1 && indexPath.getFront().isPriorityChildName()) { + throw new IllegalArgumentException( + "Can't create PathIndex with '.priority' as key. Please use PriorityIndex instead!"); + } + this.indexPath = indexPath; + } + + @Override + public boolean isDefinedOn(Node snapshot) { + return !snapshot.getChild(this.indexPath).isEmpty(); + } + + @Override + public int compare(NamedNode a, NamedNode b) { + Node aChild = a.getNode().getChild(this.indexPath); + Node bChild = b.getNode().getChild(this.indexPath); + int indexCmp = aChild.compareTo(bChild); + if (indexCmp == 0) { + return a.getName().compareTo(b.getName()); + } else { + return indexCmp; + } + } + + @Override + public NamedNode makePost(ChildKey name, Node value) { + Node node = EmptyNode.Empty().updateChild(this.indexPath, value); + return new NamedNode(name, node); + } + + @Override + public NamedNode maxPost() { + Node node = EmptyNode.Empty().updateChild(this.indexPath, Node.MAX_NODE); + return new NamedNode(ChildKey.getMaxName(), node); + } + + @Override + public String getQueryDefinition() { + return this.indexPath.wireFormat(); + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + + PathIndex that = (PathIndex) o; + + if (!indexPath.equals(that.indexPath)) { + return false; + } + + return true; + } + + @Override + public int hashCode() { + return indexPath.hashCode(); + } +} diff --git a/src/main/java/com/google/firebase/database/snapshot/PriorityIndex.java b/src/main/java/com/google/firebase/database/snapshot/PriorityIndex.java new file mode 100644 index 000000000..64360a540 --- /dev/null +++ b/src/main/java/com/google/firebase/database/snapshot/PriorityIndex.java @@ -0,0 +1,57 @@ +package com.google.firebase.database.snapshot; + +public class PriorityIndex extends Index { + + private static final PriorityIndex INSTANCE = new PriorityIndex(); + + public static PriorityIndex getInstance() { + return INSTANCE; + } + + private PriorityIndex() { + // prevent creation + } + + @Override + public int compare(NamedNode a, NamedNode b) { + Node aPrio = a.getNode().getPriority(); + Node bPrio = b.getNode().getPriority(); + return NodeUtilities.nameAndPriorityCompare(a.getName(), aPrio, b.getName(), bPrio); + } + + @Override + public boolean isDefinedOn(Node a) { + return !a.getPriority().isEmpty(); + } + + @Override + public NamedNode makePost(ChildKey name, Node value) { + return new NamedNode(name, new StringNode("[PRIORITY-POST]", value)); + } + + @Override + public NamedNode maxPost() { + return makePost(ChildKey.getMaxName(), Node.MAX_NODE); + } + + @Override + public String getQueryDefinition() { + throw new IllegalArgumentException("Can't get query definition on priority index!"); + } + + @Override + public boolean equals(Object o) { + return o instanceof PriorityIndex; + } + + @Override + public int hashCode() { + // chosen by a fair dice roll. Guaranteed to be random + return 3155577; + } + + @Override + public String toString() { + return "PriorityIndex"; + } +} diff --git a/src/main/java/com/google/firebase/database/snapshot/PriorityUtilities.java b/src/main/java/com/google/firebase/database/snapshot/PriorityUtilities.java new file mode 100644 index 000000000..36fc890e6 --- /dev/null +++ b/src/main/java/com/google/firebase/database/snapshot/PriorityUtilities.java @@ -0,0 +1,35 @@ +package com.google.firebase.database.snapshot; + +import com.google.firebase.database.DatabaseException; + +/** + * User: greg Date: 5/16/13 Time: 5:02 PM + */ +public class PriorityUtilities { + + public static Node NullPriority() { + return EmptyNode.Empty(); + } + + public static boolean isValidPriority(Node priority) { + return priority.getPriority().isEmpty() + && (priority.isEmpty() + || priority instanceof DoubleNode + || priority instanceof StringNode + || priority instanceof DeferredValueNode); + } + + public static Node parsePriority(Object value) { + Node priority = NodeUtilities.NodeFromJSON(value); + if (priority instanceof LongNode) { + priority = + new DoubleNode( + Double.valueOf((Long) priority.getValue()), PriorityUtilities.NullPriority()); + } + if (!isValidPriority(priority)) { + throw new DatabaseException( + "Invalid Firebase Database priority (must be a string, double, ServerValue, or null)"); + } + return priority; + } +} diff --git a/src/main/java/com/google/firebase/database/snapshot/RangeMerge.java b/src/main/java/com/google/firebase/database/snapshot/RangeMerge.java new file mode 100644 index 000000000..7862815c8 --- /dev/null +++ b/src/main/java/com/google/firebase/database/snapshot/RangeMerge.java @@ -0,0 +1,118 @@ +package com.google.firebase.database.snapshot; + +import com.google.firebase.database.core.Path; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +/** + * Applies a merge of a snap for a given interval of paths. Each leaf in the current node which the + * relative path lies *after* optExclusiveStart and lies *before or at* optInclusiveEnd will be + * deleted. Each leaf in snap that lies in the interval will be added to the resulting node. Nods + * outside of the range are ignored. null for start and end are sentinel values that represent + * -infinity and infinity respectively (aka includes any path). Priorities of children nodes are + * treated as leaf children of that node. + */ +public class RangeMerge { + + private final Path optExclusiveStart; + private final Path optInclusiveEnd; + private final Node snap; + + public RangeMerge(Path optExclusiveStart, Path optInclusiveEnd, Node snap) { + this.optExclusiveStart = optExclusiveStart; + this.optInclusiveEnd = optInclusiveEnd; + this.snap = snap; + } + + public RangeMerge(com.google.firebase.database.connection.RangeMerge rangeMerge) { + List optStartPathList = rangeMerge.getOptExclusiveStart(); + this.optExclusiveStart = (optStartPathList != null) ? new Path(optStartPathList) : null; + List optEndPathList = rangeMerge.getOptInclusiveEnd(); + this.optInclusiveEnd = (optEndPathList != null) ? new Path(optEndPathList) : null; + this.snap = NodeUtilities.NodeFromJSON(rangeMerge.getSnap()); + } + + public Node applyTo(Node node) { + return updateRangeInNode(Path.getEmptyPath(), node, this.snap); + } + + Path getStart() { + return optExclusiveStart; + } + + Path getEnd() { + return optInclusiveEnd; + } + + private Node updateRangeInNode(Path currentPath, Node node, Node updateNode) { + int startComparison = + (optExclusiveStart == null) ? 1 : currentPath.compareTo(optExclusiveStart); + int endComparison = (optInclusiveEnd == null) ? -1 : currentPath.compareTo(optInclusiveEnd); + boolean startInNode = optExclusiveStart != null && currentPath.contains(optExclusiveStart); + boolean endInNode = optInclusiveEnd != null && currentPath.contains(optInclusiveEnd); + if (startComparison > 0 && endComparison < 0 && !endInNode) { + // child is completely contained + return updateNode; + } else if (startComparison > 0 && endInNode && updateNode.isLeafNode()) { + return updateNode; + } else if (startComparison > 0 && endComparison == 0) { + assert endInNode; + assert !updateNode.isLeafNode(); + if (node.isLeafNode()) { + // Update node was not a leaf node, so we can delete it + return EmptyNode.Empty(); + } else { + // Unaffected by range, ignore + return node; + } + } else if (startInNode || endInNode) { + // There is a partial update we need to do + // Collect all relevant children + Set allChildren = new HashSet<>(); + for (NamedNode child : node) { + allChildren.add(child.getName()); + } + for (NamedNode child : updateNode) { + allChildren.add(child.getName()); + } + List inOrder = new ArrayList<>(allChildren.size() + 1); + inOrder.addAll(allChildren); + // Add priority last, so the node is not empty when applying + if (!updateNode.getPriority().isEmpty() || !node.getPriority().isEmpty()) { + inOrder.add(ChildKey.getPriorityKey()); + } + Node newNode = node; + for (ChildKey key : inOrder) { + Node currentChild = node.getImmediateChild(key); + Node updatedChild = + updateRangeInNode( + currentPath.child(key), + node.getImmediateChild(key), + updateNode.getImmediateChild(key)); + // Only need to update if the node changed + if (updatedChild != currentChild) { + newNode = newNode.updateImmediateChild(key, updatedChild); + } + } + return newNode; + } else { + // Unaffected by this range + assert endComparison > 0 || startComparison <= 0; + return node; + } + } + + @Override + public String toString() { + return "RangeMerge{" + + "optExclusiveStart=" + + optExclusiveStart + + ", optInclusiveEnd=" + + optInclusiveEnd + + ", snap=" + + snap + + '}'; + } +} diff --git a/src/main/java/com/google/firebase/database/snapshot/StringNode.java b/src/main/java/com/google/firebase/database/snapshot/StringNode.java new file mode 100644 index 000000000..4af2e2cc5 --- /dev/null +++ b/src/main/java/com/google/firebase/database/snapshot/StringNode.java @@ -0,0 +1,63 @@ +package com.google.firebase.database.snapshot; + +import com.google.firebase.database.utilities.Utilities; + +/** + * User: greg Date: 5/17/13 Time: 2:40 PM + */ +public class StringNode extends LeafNode { + + private final String value; + + public StringNode(String value, Node priority) { + super(priority); + this.value = value; + } + + @Override + public Object getValue() { + return value; + } + + @Override + public String getHashRepresentation(HashVersion version) { + switch (version) { + case V1: + return getPriorityHash(version) + "string:" + value; + case V2: { + return getPriorityHash(version) + "string:" + Utilities.stringHashV2Representation(value); + } + default: + throw new IllegalArgumentException("Invalid hash version for string node: " + version); + } + } + + @Override + public StringNode updatePriority(Node priority) { + return new StringNode(value, priority); + } + + @Override + protected LeafType getLeafType() { + return LeafType.String; + } + + @Override + protected int compareLeafValues(StringNode other) { + return this.value.compareTo(other.value); + } + + @Override + public boolean equals(Object other) { + if (!(other instanceof StringNode)) { + return false; + } + StringNode otherStringNode = (StringNode) other; + return value.equals(otherStringNode.value) && priority.equals(otherStringNode.priority); + } + + @Override + public int hashCode() { + return this.value.hashCode() + this.priority.hashCode(); + } +} diff --git a/src/main/java/com/google/firebase/database/snapshot/ValueIndex.java b/src/main/java/com/google/firebase/database/snapshot/ValueIndex.java new file mode 100644 index 000000000..fe5229abe --- /dev/null +++ b/src/main/java/com/google/firebase/database/snapshot/ValueIndex.java @@ -0,0 +1,60 @@ +package com.google.firebase.database.snapshot; + +public class ValueIndex extends Index { + + private static final ValueIndex INSTANCE = new ValueIndex(); + + private ValueIndex() { + // prevent initialization + } + + public static ValueIndex getInstance() { + return INSTANCE; + } + + @Override + public boolean isDefinedOn(Node a) { + return true; + } + + @Override + public NamedNode makePost(ChildKey name, Node value) { + return new NamedNode(name, value); + } + + @Override + public NamedNode maxPost() { + return new NamedNode(ChildKey.getMaxName(), Node.MAX_NODE); + } + + @Override + public String getQueryDefinition() { + return ".value"; + } + + @Override + public int compare(NamedNode one, NamedNode two) { + int indexCmp = one.getNode().compareTo(two.getNode()); + if (indexCmp == 0) { + return one.getName().compareTo(two.getName()); + } else { + return indexCmp; + } + } + + @Override + public int hashCode() { + // chosen by fair dice roll + return 4; + } + + @Override + public boolean equals(Object o) { + return o instanceof ValueIndex; + } + + @Override + public String toString() { + return "ValueIndex"; + } +} diff --git a/src/main/java/com/google/firebase/database/tubesock/MessageBuilderFactory.java b/src/main/java/com/google/firebase/database/tubesock/MessageBuilderFactory.java new file mode 100644 index 000000000..76d8beeb7 --- /dev/null +++ b/src/main/java/com/google/firebase/database/tubesock/MessageBuilderFactory.java @@ -0,0 +1,192 @@ +package com.google.firebase.database.tubesock; + +import java.nio.ByteBuffer; +import java.nio.CharBuffer; +import java.nio.charset.CharacterCodingException; +import java.nio.charset.Charset; +import java.nio.charset.CharsetDecoder; +import java.nio.charset.CharsetEncoder; +import java.nio.charset.CoderResult; +import java.nio.charset.CodingErrorAction; +import java.util.ArrayList; +import java.util.List; + +/** + * Instances provide a builder for a full WebSocketMessage that could be split across multiple + * websocket frames. Depending on the opcode, the returned builders will buffer and assemble either + * bytes or a String. + */ +class MessageBuilderFactory { + + interface Builder { + + boolean appendBytes(byte[] bytes); + + WebSocketMessage toMessage(); + } + + static class BinaryBuilder implements Builder { + + private List pendingBytes; + private int pendingByteCount = 0; + + BinaryBuilder() { + pendingBytes = new ArrayList<>(); + } + + @Override + public boolean appendBytes(byte[] bytes) { + pendingBytes.add(bytes); + pendingByteCount += bytes.length; + return true; + } + + @Override + public WebSocketMessage toMessage() { + byte[] payload = new byte[pendingByteCount]; + int offset = 0; + for (int i = 0; i < pendingBytes.size(); ++i) { + byte[] segment = pendingBytes.get(i); + System.arraycopy(segment, 0, payload, offset, segment.length); + offset += segment.length; + } + return new WebSocketMessage(payload); + } + } + + static class TextBuilder implements Builder { + + private static ThreadLocal localDecoder = new ThreadLocal() { + @Override + protected CharsetDecoder initialValue() { + Charset utf8 = Charset.forName("UTF8"); + CharsetDecoder decoder = utf8.newDecoder(); + decoder.onMalformedInput(CodingErrorAction.REPORT); + decoder.onUnmappableCharacter(CodingErrorAction.REPORT); + return decoder; + } + }; + private static ThreadLocal localEncoder = new ThreadLocal() { + @Override + protected CharsetEncoder initialValue() { + Charset utf8 = Charset.forName("UTF8"); + CharsetEncoder encoder = utf8.newEncoder(); + encoder.onMalformedInput(CodingErrorAction.REPORT); + encoder.onUnmappableCharacter(CodingErrorAction.REPORT); + return encoder; + } + }; + + private StringBuilder builder; + private ByteBuffer carryOver; + + TextBuilder() { + builder = new StringBuilder(); + } + + @Override + public boolean appendBytes(byte[] bytes) { + // Uncomment if you want slower but more precise decoding. Useful if you're splitting multi-byte utf8 chars + // across websocket frames + //String nextFrame = decodeStringStreaming(bytes); + String nextFrame = decodeString(bytes); + if (nextFrame != null) { + builder.append(nextFrame); + return true; + } + return false; + } + + @Override + public WebSocketMessage toMessage() { + if (carryOver != null) { + return null; + } + return new WebSocketMessage(builder.toString()); + } + + /** + * Quicker but less precise utf8 decoding. Does not handle characters split across websocket + * frames. + * + * @param bytes Bytes representing a utf8 string + * @return The decoded string + */ + private String decodeString(byte[] bytes) { + try { + ByteBuffer input = ByteBuffer.wrap(bytes); + CharBuffer buf = localDecoder.get().decode(input); + String text = buf.toString(); + return text; + } catch (CharacterCodingException e) { + return null; + } + } + + /** + * Left in for reference. Less efficient, but potentially catches more errors. Behavior is + * largely dependent on how strict the JVM's utf8 decoder is. It is possible on some JVMs to + * decode a string that then throws an error when attempting to return it to bytes. + * + * @param bytes Bytes representing a utf8 string + * @return The decoded string + */ + private String decodeStringStreaming(byte[] bytes) { + try { + ByteBuffer input = getBuffer(bytes); + int bufSize = (int) (input.remaining() * localDecoder.get().averageCharsPerByte()); + CharBuffer output = CharBuffer.allocate(bufSize); + for (; ; ) { + CoderResult result = localDecoder.get().decode(input, output, false); + if (result.isError()) { + return null; + } + if (result.isUnderflow()) { + break; + } + if (result.isOverflow()) { + // We need more room in our output buffer + bufSize = 2 * bufSize + 1; + CharBuffer o = CharBuffer.allocate(bufSize); + output.flip(); + o.put(output); + output = o; + } + } + if (input.remaining() > 0) { + carryOver = input; + } + // Re-encode to work around bugs in UTF-8 decoder + CharBuffer test = CharBuffer.wrap(output); + localEncoder.get().encode(test); + output.flip(); + String text = output.toString(); + return text; + } catch (CharacterCodingException e) { + return null; + } + } + + private ByteBuffer getBuffer(byte[] bytes) { + if (carryOver != null) { + ByteBuffer buffer = ByteBuffer.allocate(bytes.length + carryOver.remaining()); + buffer.put(carryOver); + carryOver = null; + buffer.put(bytes); + buffer.flip(); + return buffer; + } else { + return ByteBuffer.wrap(bytes); + } + } + } + + static Builder builder(byte opcode) { + if (opcode == WebSocket.OPCODE_BINARY) { + return new BinaryBuilder(); + } else { + // Text + return new TextBuilder(); + } + } +} diff --git a/src/main/java/com/google/firebase/database/tubesock/ThreadInitializer.java b/src/main/java/com/google/firebase/database/tubesock/ThreadInitializer.java new file mode 100644 index 000000000..68e6a98f7 --- /dev/null +++ b/src/main/java/com/google/firebase/database/tubesock/ThreadInitializer.java @@ -0,0 +1,6 @@ +package com.google.firebase.database.tubesock; + +public interface ThreadInitializer { + + void setName(Thread t, String name); +} diff --git a/src/main/java/com/google/firebase/database/tubesock/WebSocket.java b/src/main/java/com/google/firebase/database/tubesock/WebSocket.java new file mode 100644 index 000000000..2487a3a73 --- /dev/null +++ b/src/main/java/com/google/firebase/database/tubesock/WebSocket.java @@ -0,0 +1,402 @@ +package com.google.firebase.database.tubesock; + +import java.io.DataInputStream; +import java.io.IOException; +import java.io.OutputStream; +import java.net.Socket; +import java.net.URI; +import java.net.UnknownHostException; +import java.nio.charset.Charset; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.Executors; +import java.util.concurrent.ThreadFactory; +import java.util.concurrent.atomic.AtomicInteger; +import javax.net.SocketFactory; +import javax.net.ssl.SSLParameters; +import javax.net.ssl.SSLSocket; +import javax.net.ssl.SSLSocketFactory; + +/** + * This is the main class used to create a websocket connection. Create a new instance, set an event + * handler, and then call connect(). Once the event handler's onOpen method has been called, call + * send() on the websocket to transmit data. + */ +public class WebSocket { + + private static final String THREAD_BASE_NAME = "TubeSock"; + private static final AtomicInteger clientCount = new AtomicInteger(0); + + private enum State { + NONE, + CONNECTING, + CONNECTED, + DISCONNECTING, + DISCONNECTED + } + + private static final Charset UTF8 = Charset.forName("UTF-8"); + + static final byte OPCODE_NONE = 0x0; + static final byte OPCODE_TEXT = 0x1; + static final byte OPCODE_BINARY = 0x2; + static final byte OPCODE_CLOSE = 0x8; + static final byte OPCODE_PING = 0x9; + static final byte OPCODE_PONG = 0xA; + + private volatile State state = State.NONE; + private volatile Socket socket = null; + + private WebSocketEventHandler eventHandler = null; + + private final URI url; + + private final WebSocketReceiver receiver; + private final WebSocketWriter writer; + private final WebSocketHandshake handshake; + private final int clientId = clientCount.incrementAndGet(); + + private final Thread innerThread; + private static ThreadFactory threadFactory = Executors.defaultThreadFactory(); + private static ThreadInitializer intializer = + new ThreadInitializer() { + @Override + public void setName(Thread t, String name) { + t.setName(name); + } + }; + + static ThreadFactory getThreadFactory() { + return threadFactory; + } + + static ThreadInitializer getIntializer() { + return intializer; + } + + public static void setThreadFactory(ThreadFactory threadFactory, ThreadInitializer intializer) { + WebSocket.threadFactory = threadFactory; + WebSocket.intializer = intializer; + } + + /** + * Create a websocket to connect to a given server + * + * @param url The URL of a websocket server + */ + public WebSocket(URI url) { + this(url, null); + } + + /** + * Create a websocket to connect to a given server. Include protocol in websocket handshake + * + * @param url The URL of a websocket server + * @param protocol The protocol to include in the handshake. If null, it will be omitted + */ + public WebSocket(URI url, String protocol) { + this(url, protocol, null); + } + + /** + * Create a websocket to connect to a given server. Include the given protocol in the handshake, + * as well as any extra HTTP headers specified. Useful if you would like to include a User-Agent + * or other header + * + * @param url The URL of a websocket server + * @param protocol The protocol to include in the handshake. If null, it will be omitted + * @param extraHeaders Any extra HTTP headers to be included with the initial request. Pass null + * if not extra headers are requested + */ + public WebSocket(URI url, String protocol, Map extraHeaders) { + innerThread = + getThreadFactory() + .newThread( + new Runnable() { + @Override + public void run() { + runReader(); + } + }); + this.url = url; + handshake = new WebSocketHandshake(url, protocol, extraHeaders); + receiver = new WebSocketReceiver(this); + writer = new WebSocketWriter(this, THREAD_BASE_NAME, clientId); + } + + /** + * Must be called before connect(). Set the handler for all websocket-related events. + * + * @param eventHandler The handler to be triggered with relevant events + */ + public void setEventHandler(WebSocketEventHandler eventHandler) { + this.eventHandler = eventHandler; + } + + WebSocketEventHandler getEventHandler() { + return this.eventHandler; + } + + /** + * Start up the socket. This is non-blocking, it will fire up the threads used by the library and + * then trigger the onOpen handler once the connection is established. + */ + public synchronized void connect() { + if (state != State.NONE) { + eventHandler.onError(new WebSocketException("connect() already called")); + close(); + return; + } + getIntializer().setName(getInnerThread(), THREAD_BASE_NAME + "Reader-" + clientId); + state = State.CONNECTING; + getInnerThread().start(); + } + + /** + * Send a TEXT message over the socket + * + * @param data The text payload to be sent + */ + public synchronized void send(String data) { + send(OPCODE_TEXT, data.getBytes(UTF8)); + } + + /** + * Send a BINARY message over the socket + * + * @param data The binary payload to be sent + */ + public synchronized void send(byte[] data) { + send(OPCODE_BINARY, data); + } + + synchronized void pong(byte[] data) { + send(OPCODE_PONG, data); + } + + private synchronized void send(byte opcode, byte[] data) { + if (state != State.CONNECTED) { + // We might have been disconnected on another thread, just report an error + eventHandler.onError(new WebSocketException("error while sending data: not connected")); + } else { + try { + writer.send(opcode, true, data); + } catch (IOException e) { + eventHandler.onError(new WebSocketException("Failed to send frame", e)); + close(); + } + } + } + + void handleReceiverError(WebSocketException e) { + eventHandler.onError(e); + if (state == State.CONNECTED) { + close(); + } + closeSocket(); + } + + /** + * Close down the socket. Will trigger the onClose handler if the socket has not been previously + * closed. + */ + public synchronized void close() { + switch (state) { + case NONE: + state = State.DISCONNECTED; + return; + case CONNECTING: + // don't wait for an established connection, just close the tcp socket + closeSocket(); + return; + case CONNECTED: + // This method also shuts down the writer + // the socket will be closed once the ack for the close was received + sendCloseHandshake(); + return; + case DISCONNECTING: + return; // no-op; + case DISCONNECTED: + return; // No-op + } + } + + void onCloseOpReceived() { + closeSocket(); + } + + private synchronized void closeSocket() { + if (state == State.DISCONNECTED) { + return; + } + receiver.stopit(); + writer.stopIt(); + if (socket != null) { + try { + socket.close(); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + state = State.DISCONNECTED; + + eventHandler.onClose(); + } + + private void sendCloseHandshake() { + try { + state = State.DISCONNECTING; + // Set the stop flag then queue up a message. This ensures that the writer thread + // will wake up, and since we set the stop flag, it will exit its run loop. + writer.stopIt(); + writer.send(OPCODE_CLOSE, true, new byte[0]); + } catch (IOException e) { + eventHandler.onError(new WebSocketException("Failed to send close frame", e)); + } + } + + private Socket createSocket() { + String scheme = url.getScheme(); + String host = url.getHost(); + int port = url.getPort(); + + Socket socket; + + if (scheme != null && scheme.equals("ws")) { + if (port == -1) { + port = 80; + } + try { + socket = new Socket(host, port); + } catch (UnknownHostException uhe) { + throw new WebSocketException("unknown host: " + host, uhe); + } catch (IOException ioe) { + throw new WebSocketException("error while creating socket to " + url, ioe); + } + } else if (scheme != null && scheme.equals("wss")) { + if (port == -1) { + port = 443; + } + try { + SocketFactory factory = SSLSocketFactory.getDefault(); + SSLSocket sslSocket = (SSLSocket) factory.createSocket(host, port); + + // Ensure proper hostname verification, per + // https://tersesystems.com/2014/03/23/fixing-hostname-verification/ + // TODO(mikelehen): This code is different than Android. We should refactor it + // into JvmPlatform. + SSLParameters sslParams = new SSLParameters(); + sslParams.setEndpointIdentificationAlgorithm("HTTPS"); + sslSocket.setSSLParameters(sslParams); + + socket = sslSocket; + } catch (UnknownHostException uhe) { + throw new WebSocketException("unknown host: " + host, uhe); + } catch (IOException ioe) { + throw new WebSocketException("error while creating secure socket to " + url, ioe); + } + } else { + throw new WebSocketException("unsupported protocol: " + scheme); + } + + return socket; + } + + /** + * Blocks until both threads exit. The actual close must be triggered separately. This is just a + * convenience method to make sure everything shuts down, if desired. + */ + public void blockClose() throws InterruptedException { + // If the thread is new, it will never run, since we closed the connection before we actually + // connected + if (writer.getInnerThread().getState() != Thread.State.NEW) { + writer.getInnerThread().join(); + } + getInnerThread().join(); + } + + private void runReader() { + try { + Socket socket = createSocket(); + synchronized (this) { + WebSocket.this.socket = socket; + if (WebSocket.this.state == WebSocket.State.DISCONNECTED) { + // The connection has been closed while creating the socket, close it immediately and + // return + try { + WebSocket.this.socket.close(); + } catch (IOException e) { + throw new RuntimeException(e); + } + WebSocket.this.socket = null; + return; + } + } + + DataInputStream input = new DataInputStream(socket.getInputStream()); + OutputStream output = socket.getOutputStream(); + + output.write(handshake.getHandshake()); + + boolean handshakeComplete = false; + int len = 1000; + byte[] buffer = new byte[len]; + int pos = 0; + ArrayList handshakeLines = new ArrayList<>(); + + while (!handshakeComplete) { + int b = input.read(); + if (b == -1) { + throw new WebSocketException("Connection closed before handshake was complete"); + } + buffer[pos] = (byte) b; + pos += 1; + + if (buffer[pos - 1] == 0x0A && buffer[pos - 2] == 0x0D) { + String line = new String(buffer, UTF8); + if (line.trim().equals("")) { + handshakeComplete = true; + } else { + handshakeLines.add(line.trim()); + } + + buffer = new byte[len]; + pos = 0; + } else if (pos == 1000) { + // This really shouldn't happen, handshake lines are short, but just to be safe... + String line = new String(buffer, UTF8); + throw new WebSocketException("Unexpected long line in handshake: " + line); + } + } + + handshake.verifyServerStatusLine(handshakeLines.get(0)); + handshakeLines.remove(0); + + HashMap headers = new HashMap<>(); + for (String line : handshakeLines) { + String[] keyValue = line.split(": ", 2); + headers.put(keyValue[0], keyValue[1]); + } + handshake.verifyServerHandshakeHeaders(headers); + + writer.setOutput(output); + receiver.setInput(input); + state = WebSocket.State.CONNECTED; + writer.getInnerThread().start(); + eventHandler.onOpen(); + receiver.run(); + } catch (WebSocketException wse) { + eventHandler.onError(wse); + } catch (IOException ioe) { + eventHandler.onError( + new WebSocketException("error while connecting: " + ioe.getMessage(), ioe)); + } finally { + close(); + } + } + + Thread getInnerThread() { + return innerThread; + } +} diff --git a/src/main/java/com/google/firebase/database/tubesock/WebSocketEventHandler.java b/src/main/java/com/google/firebase/database/tubesock/WebSocketEventHandler.java new file mode 100644 index 000000000..565550214 --- /dev/null +++ b/src/main/java/com/google/firebase/database/tubesock/WebSocketEventHandler.java @@ -0,0 +1,15 @@ +package com.google.firebase.database.tubesock; + + +public interface WebSocketEventHandler { + + void onOpen(); + + void onMessage(WebSocketMessage message); + + void onClose(); + + void onError(WebSocketException e); + + void onLogMessage(String msg); +} diff --git a/src/main/java/com/google/firebase/database/tubesock/WebSocketException.java b/src/main/java/com/google/firebase/database/tubesock/WebSocketException.java new file mode 100644 index 000000000..d33f82189 --- /dev/null +++ b/src/main/java/com/google/firebase/database/tubesock/WebSocketException.java @@ -0,0 +1,14 @@ +package com.google.firebase.database.tubesock; + +public class WebSocketException extends RuntimeException { + + private static final long serialVersionUID = 1L; + + public WebSocketException(String message) { + super(message); + } + + public WebSocketException(String message, Throwable t) { + super(message, t); + } +} diff --git a/src/main/java/com/google/firebase/database/tubesock/WebSocketHandshake.java b/src/main/java/com/google/firebase/database/tubesock/WebSocketHandshake.java new file mode 100644 index 000000000..40bc607e2 --- /dev/null +++ b/src/main/java/com/google/firebase/database/tubesock/WebSocketHandshake.java @@ -0,0 +1,112 @@ +package com.google.firebase.database.tubesock; + +import static java.nio.charset.StandardCharsets.UTF_8; + +import com.google.firebase.internal.Base64; +import java.net.URI; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.Locale; +import java.util.Map; + +class WebSocketHandshake { + + private static final String WEBSOCKET_VERSION = "13"; + + private URI url = null; + private String protocol = null; + private String nonce = null; + private Map extraHeaders = null; + + public WebSocketHandshake(URI url, String protocol, Map extraHeaders) { + this.url = url; + this.protocol = protocol; + this.extraHeaders = extraHeaders; + this.nonce = this.createNonce(); + } + + public byte[] getHandshake() { + String path = url.getPath(); + String query = url.getQuery(); + path += query == null ? "" : "?" + query; + String host = url.getHost(); + + if (url.getPort() != -1) { + host += ":" + url.getPort(); + } + + LinkedHashMap header = new LinkedHashMap<>(); + header.put("Host", host); + header.put("Upgrade", "websocket"); + header.put("Connection", "Upgrade"); + header.put("Sec-WebSocket-Version", WEBSOCKET_VERSION); + header.put("Sec-WebSocket-Key", this.nonce); + + if (this.protocol != null) { + header.put("Sec-WebSocket-Protocol", this.protocol); + } + + if (this.extraHeaders != null) { + for (String fieldName : this.extraHeaders.keySet()) { + // Only checks for Field names with the exact same text, + // but according to RFC 2616 (HTTP) field names are case-insensitive. + if (!header.containsKey(fieldName)) { + header.put(fieldName, this.extraHeaders.get(fieldName)); + } + } + } + + String handshake = "GET " + path + " HTTP/1.1\r\n"; + handshake += this.generateHeader(header); + handshake += "\r\n"; + + byte[] handshakeBytes = new byte[handshake.getBytes(UTF_8).length]; + System.arraycopy( + handshake.getBytes(UTF_8), 0, handshakeBytes, 0, handshake.getBytes(UTF_8).length); + + return handshakeBytes; + } + + private String generateHeader(LinkedHashMap headers) { + String header = new String(); + for (String fieldName : headers.keySet()) { + header += fieldName + ": " + headers.get(fieldName) + "\r\n"; + } + return header; + } + + private String createNonce() { + byte[] nonce = new byte[16]; + for (int i = 0; i < 16; i++) { + nonce[i] = (byte) rand(0, 255); + } + return Base64.encodeToString(nonce, Base64.NO_WRAP); + } + + public void verifyServerStatusLine(String statusLine) { + int statusCode = Integer.valueOf(statusLine.substring(9, 12)); + + if (statusCode == 407) { + throw new WebSocketException("connection failed: proxy authentication not supported"); + } else if (statusCode == 404) { + throw new WebSocketException("connection failed: 404 not found"); + } else if (statusCode != 101) { + throw new WebSocketException("connection failed: unknown status code " + statusCode); + } + } + + public void verifyServerHandshakeHeaders(HashMap headers) { + if (!headers.get("Upgrade").toLowerCase(Locale.US).equals("websocket")) { + throw new WebSocketException( + "connection failed: missing header field in server handshake: Upgrade"); + } else if (!headers.get("Connection").toLowerCase(Locale.US).equals("upgrade")) { + throw new WebSocketException( + "connection failed: missing header field in server handshake: Connection"); + } + } + + private int rand(int min, int max) { + int rand = (int) (Math.random() * max + min); + return rand; + } +} diff --git a/src/main/java/com/google/firebase/database/tubesock/WebSocketMessage.java b/src/main/java/com/google/firebase/database/tubesock/WebSocketMessage.java new file mode 100644 index 000000000..85e02fcc5 --- /dev/null +++ b/src/main/java/com/google/firebase/database/tubesock/WebSocketMessage.java @@ -0,0 +1,34 @@ +package com.google.firebase.database.tubesock; + +public class WebSocketMessage { + + private byte[] byteMessage; + private String stringMessage; + private byte opcode; + + public WebSocketMessage(byte[] message) { + this.byteMessage = message; + this.opcode = WebSocket.OPCODE_BINARY; + } + + public WebSocketMessage(String message) { + this.stringMessage = message; + this.opcode = WebSocket.OPCODE_TEXT; + } + + public boolean isText() { + return opcode == WebSocket.OPCODE_TEXT; + } + + public boolean isBinary() { + return opcode == WebSocket.OPCODE_BINARY; + } + + public byte[] getBytes() { + return byteMessage; + } + + public String getText() { + return stringMessage; + } +} diff --git a/src/main/java/com/google/firebase/database/tubesock/WebSocketReceiver.java b/src/main/java/com/google/firebase/database/tubesock/WebSocketReceiver.java new file mode 100644 index 000000000..06abebef5 --- /dev/null +++ b/src/main/java/com/google/firebase/database/tubesock/WebSocketReceiver.java @@ -0,0 +1,159 @@ +package com.google.firebase.database.tubesock; + +import java.io.DataInputStream; +import java.io.IOException; +import java.net.SocketTimeoutException; + +/** + * This class encapsulates the receiving and decoding of websocket frames. It is run from the thread + * started by the websocket class. It does some best-effort error detection for violations of the + * websocket spec. + */ +class WebSocketReceiver { + + private DataInputStream input = null; + private WebSocket websocket = null; + private WebSocketEventHandler eventHandler = null; + private byte[] inputHeader = new byte[112]; + private MessageBuilderFactory.Builder pendingBuilder; + + private volatile boolean stop = false; + + + WebSocketReceiver(WebSocket websocket) { + this.websocket = websocket; + } + + void setInput(DataInputStream input) { + this.input = input; + } + + void run() { + this.eventHandler = websocket.getEventHandler(); + while (!stop) { + try { + int offset = 0; + offset += read(inputHeader, offset, 1); + boolean fin = (inputHeader[0] & 0x80) != 0; + boolean rsv = (inputHeader[0] & 0x70) != 0; + if (rsv) { + throw new WebSocketException("Invalid frame received"); + } else { + byte opcode = (byte) (inputHeader[0] & 0xf); + offset += read(inputHeader, offset, 1); + byte length = inputHeader[1]; + long payload_length = 0; + if (length < 126) { + payload_length = length; + } else if (length == 126) { + offset += read(inputHeader, offset, 2); + payload_length = ((0xff & inputHeader[2]) << 8) | (0xff & inputHeader[3]); + } else if (length == 127) { + // Does work up to MAX_VALUE of long (2^63-1) after that minus values are returned. + // However frames with such a high payload length are vastly unrealistic. + // TODO: add Limit for WebSocket Payload Length. + offset += read(inputHeader, offset, 8); + // Parse the bytes we just read + payload_length = parseLong(inputHeader, offset - 8); + } + + byte[] payload = new byte[(int) payload_length]; + read(payload, 0, (int) payload_length); + if (opcode == WebSocket.OPCODE_CLOSE) { + websocket.onCloseOpReceived(); + } else if (opcode == WebSocket.OPCODE_PONG) { + // NOTE: as a client, we don't expect PONGs. No-op + } else if (opcode == WebSocket.OPCODE_TEXT || + opcode == WebSocket.OPCODE_BINARY || + opcode == WebSocket.OPCODE_PING || + opcode == WebSocket.OPCODE_NONE) { + // It's some form of application data. Decode the payload + appendBytes(fin, opcode, payload); + } else { + // Unsupported opcode + throw new WebSocketException("Unsupported opcode: " + opcode); + } + } + } catch (SocketTimeoutException sto) { + continue; + } catch (IOException ioe) { + handleError(new WebSocketException("IO Error", ioe)); + } catch (WebSocketException e) { + handleError(e); + } + } + } + + private void appendBytes(boolean fin, byte opcode, byte[] data) { + // A ping can show up in the middle of another fragmented message + if (opcode == WebSocket.OPCODE_PING) { + if (fin) { + handlePing(data); + } else { + throw new WebSocketException("PING must not fragment across frames"); + } + } else { + if (pendingBuilder != null && opcode != WebSocket.OPCODE_NONE) { + throw new WebSocketException("Failed to continue outstanding frame"); + } else if (pendingBuilder == null && opcode == WebSocket.OPCODE_NONE) { + // Trying to continue something, but there's nothing to continue + throw new WebSocketException("Received continuing frame, but there's nothing to continue"); + } else { + if (pendingBuilder == null) { + // We aren't continuing another message + pendingBuilder = MessageBuilderFactory.builder(opcode); + } + if (!pendingBuilder.appendBytes(data)) { + throw new WebSocketException("Failed to decode frame"); + } else if (fin) { + WebSocketMessage message = pendingBuilder.toMessage(); + pendingBuilder = null; + // The message assembly could still fail + if (message == null) { + throw new WebSocketException("Failed to decode whole message"); + } else { + eventHandler.onMessage(message); + } + } + } + } + } + + private void handlePing(byte[] payload) { + if (payload.length <= 125) { + websocket.pong(payload); + } else { + throw new WebSocketException("PING frame too long"); + } + } + + private long parseLong(byte[] buffer, int offset) { + // Copied from DataInputStream#readLong + return (((long) buffer[offset + 0] << 56) + + ((long) (buffer[offset + 1] & 255) << 48) + + ((long) (buffer[offset + 2] & 255) << 40) + + ((long) (buffer[offset + 3] & 255) << 32) + + ((long) (buffer[offset + 4] & 255) << 24) + + ((buffer[offset + 5] & 255) << 16) + + ((buffer[offset + 6] & 255) << 8) + + ((buffer[offset + 7] & 255) << 0)); + } + + private int read(byte[] buffer, int offset, int length) throws IOException { + input.readFully(buffer, offset, length); + return length; + } + + void stopit() { + stop = true; + } + + boolean isRunning() { + return !stop; + } + + private void handleError(WebSocketException e) { + stopit(); + websocket.handleReceiverError(e); + } +} diff --git a/src/main/java/com/google/firebase/database/tubesock/WebSocketWriter.java b/src/main/java/com/google/firebase/database/tubesock/WebSocketWriter.java new file mode 100644 index 000000000..99220adab --- /dev/null +++ b/src/main/java/com/google/firebase/database/tubesock/WebSocketWriter.java @@ -0,0 +1,152 @@ +package com.google.firebase.database.tubesock; + +import java.io.IOException; +import java.io.OutputStream; +import java.nio.ByteBuffer; +import java.nio.channels.Channels; +import java.nio.channels.WritableByteChannel; +import java.util.Random; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.LinkedBlockingQueue; + +/** + * This class handles blocking write operations to the websocket. Given an opcode and some bytes, it + * frames a message and sends it over the wire. The actual sending happens in a separate thread. + */ +class WebSocketWriter { + + private BlockingQueue pendingBuffers; + private final Random random = new Random(); + private volatile boolean stop = false; + private boolean closeSent = false; + private WebSocket websocket; + private WritableByteChannel channel; + private final Thread innerThread; + + WebSocketWriter(WebSocket websocket, String threadBaseName, int clientId) { + innerThread = WebSocket.getThreadFactory().newThread(new Runnable() { + @Override + public void run() { + runWriter(); + } + }); + + WebSocket.getIntializer().setName(getInnerThread(), threadBaseName + "Writer-" + clientId); + this.websocket = websocket; + pendingBuffers = new LinkedBlockingQueue<>(); + } + + void setOutput(OutputStream output) { + channel = Channels.newChannel(output); + } + + private ByteBuffer frameInBuffer(byte opcode, boolean masking, byte[] data) throws IOException { + int headerLength = 2; // This is just an assumed headerLength, as we use a ByteArrayOutputStream + if (masking) { + headerLength += 4; + } + int length = data.length; + if (length < 126) { + // nothing add to header length + } else if (length <= 65535) { + headerLength += 2; + } else { + headerLength += 8; + } + ByteBuffer frame = ByteBuffer.allocate(data.length + headerLength); + + byte fin = (byte) 0x80; + byte startByte = (byte) (fin | opcode); + frame.put(startByte); + + int length_field; + + if (length < 126) { + if (masking) { + length = 0x80 | length; + } + frame.put((byte) length); + } else if (length <= 65535) { + length_field = 126; + if (masking) { + length_field = 0x80 | length_field; + } + frame.put((byte) length_field); + // We check the size above, so we know we aren't losing anything with the cast + frame.putShort((short) length); + } else { + length_field = 127; + if (masking) { + length_field = 0x80 | length_field; + } + frame.put((byte) length_field); + // Since an integer occupies just 4 bytes we fill the 4 leading length bytes with zero + frame.putInt(0); + frame.putInt(length); + } + + byte[] mask; + if (masking) { + mask = generateMask(); + frame.put(mask); + + for (int i = 0; i < data.length; i++) { + frame.put((byte) (data[i] ^ mask[i % 4])); + } + } + + frame.flip(); + return frame; + } + + private byte[] generateMask() { + final byte[] mask = new byte[4]; + random.nextBytes(mask); + return mask; + } + + synchronized void send(byte opcode, boolean masking, byte[] data) throws IOException { + ByteBuffer frame = frameInBuffer(opcode, masking, data); + if (stop && (closeSent || opcode != WebSocket.OPCODE_CLOSE)) { + throw new WebSocketException("Shouldn't be sending"); + } + if (opcode == WebSocket.OPCODE_CLOSE) { + closeSent = true; + } + pendingBuffers.add(frame); + } + + private void writeMessage() throws InterruptedException, IOException { + ByteBuffer msg = pendingBuffers.take(); + channel.write(msg); + } + + void stopIt() { + stop = true; + } + + private void handleError(WebSocketException e) { + websocket.handleReceiverError(e); + } + + private void runWriter() { + try { + while (!stop && !Thread.interrupted()) { + writeMessage(); + } + // We're stopping, clear any remaining messages + for (int i = 0; i < pendingBuffers.size(); ++i) { + writeMessage(); + } + } catch (IOException e) { + handleError(new WebSocketException("IO Exception", e)); + } catch (InterruptedException e) { + // this thread is regularly terminated via an interrupt + //e.printStackTrace(); + } + } + + Thread getInnerThread() { + return innerThread; + } +} diff --git a/src/main/java/com/google/firebase/database/util/AndroidSupport.java b/src/main/java/com/google/firebase/database/util/AndroidSupport.java new file mode 100644 index 000000000..5751d7315 --- /dev/null +++ b/src/main/java/com/google/firebase/database/util/AndroidSupport.java @@ -0,0 +1,19 @@ +package com.google.firebase.database.util; + +public class AndroidSupport { + + private static final boolean IS_ANDROID = checkAndroid(); + + private static boolean checkAndroid() { + try { + Class contextClass = Class.forName("android.app.Activity"); + return true; + } catch (ClassNotFoundException e) { + return false; + } + } + + public static boolean isAndroid() { + return IS_ANDROID; + } +} diff --git a/src/main/java/com/google/firebase/database/util/GAuthToken.java b/src/main/java/com/google/firebase/database/util/GAuthToken.java new file mode 100644 index 000000000..b9cdff457 --- /dev/null +++ b/src/main/java/com/google/firebase/database/util/GAuthToken.java @@ -0,0 +1,67 @@ +package com.google.firebase.database.util; + +import java.io.IOException; +import java.util.HashMap; +import java.util.Map; + +/** + * Represents a "gauth" token used by the Server SDK, which can contain a token and optionally a + * auth payload. + * + * HACK: Rather than plumb GAuthToken through our internals we serialize it to/from a string + * (using JSON) and pass it through our normal plumbing that expects token to be a String. + */ +public class GAuthToken { + + private final String token; + private final Map auth; + + // Normal tokens will be JWTs or possibly Firebase Secrets, neither of which will contain "|" + // so this should be a safe prefix. + private static final String TOKEN_PREFIX = "gauth|"; + + private static final String AUTH_KEY = "auth"; + private static final String TOKEN_KEY = "token"; + + public GAuthToken(String token, Map auth) { + this.token = token; + this.auth = auth; + } + + public static GAuthToken tryParseFromString(String rawToken) { + if (!rawToken.startsWith(TOKEN_PREFIX)) { + return null; + } + + String gauthToken = rawToken.substring(TOKEN_PREFIX.length()); + try { + Map tokenMap = JsonMapper.parseJson(gauthToken); + String token = (String) tokenMap.get(TOKEN_KEY); + @SuppressWarnings("unchecked") + Map auth = (Map) tokenMap.get(AUTH_KEY); + return new GAuthToken(token, auth); + } catch (IOException e) { + throw new RuntimeException("Failed to parse gauth token", e); + } + } + + public String serializeToString() { + Map tokenMap = new HashMap<>(); + tokenMap.put(TOKEN_KEY, token); + tokenMap.put(AUTH_KEY, auth); + try { + String json = JsonMapper.serializeJson(tokenMap); + return TOKEN_PREFIX + json; + } catch (IOException e) { + throw new RuntimeException("Failed to serialize gauth token", e); + } + } + + public String getToken() { + return this.token; + } + + public Map getAuth() { + return this.auth; + } +} diff --git a/src/main/java/com/google/firebase/database/util/JsonMapper.java b/src/main/java/com/google/firebase/database/util/JsonMapper.java new file mode 100644 index 000000000..3394e3d0d --- /dev/null +++ b/src/main/java/com/google/firebase/database/util/JsonMapper.java @@ -0,0 +1,121 @@ +package com.google.firebase.database.util; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashMap; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; +import org.json.JSONStringer; +import org.json.JSONTokener; + +/** + * Helper class to convert from/to JSON strings. TODO(dimond): This class should ideally not live in + * firebase-database-connection, but it's required by both firebase-database and + * firebase-database-connection, so leave it here for now. + */ +public class JsonMapper { + + public static String serializeJson(Map object) throws IOException { + return serializeJsonValue(object); + } + + @SuppressWarnings("unchecked") + public static String serializeJsonValue(Object object) throws IOException { + if (object == null) { + return "null"; + } else if (object instanceof String) { + return JSONObject.quote((String) object); + } else if (object instanceof Number) { + try { + return JSONObject.numberToString((Number) object); + } catch (JSONException e) { + throw new IOException("Could not serialize number", e); + } + } else if (object instanceof Boolean) { + return ((Boolean) object) ? "true" : "false"; + } else { + try { + JSONStringer stringer = new JSONStringer(); + serializeJsonValue(object, stringer); + return stringer.toString(); + } catch (JSONException e) { + throw new IOException("Failed to serialize JSON", e); + } + } + } + + private static void serializeJsonValue(Object object, JSONStringer stringer) + throws IOException, JSONException { + if (object instanceof Map) { + stringer.object(); + @SuppressWarnings("unchecked") + Map map = (Map) object; + for (Map.Entry entry : map.entrySet()) { + stringer.key(entry.getKey()); + serializeJsonValue(entry.getValue(), stringer); + } + stringer.endObject(); + } else if (object instanceof Collection) { + Collection collection = (Collection) object; + stringer.array(); + for (Object entry : collection) { + serializeJsonValue(entry, stringer); + } + stringer.endArray(); + } else { + stringer.value(object); + } + } + + public static Map parseJson(String json) throws IOException { + try { + return unwrapJsonObject(new JSONObject(json)); + } catch (JSONException e) { + throw new IOException(e); + } + } + + public static Object parseJsonValue(String json) throws IOException { + try { + return unwrapJson(new JSONTokener(json).nextValue()); + } catch (JSONException e) { + throw new IOException(e); + } + } + + @SuppressWarnings("unchecked") + private static Map unwrapJsonObject(JSONObject jsonObject) throws JSONException { + Map map = new HashMap<>(jsonObject.length()); + Iterator keys = jsonObject.keys(); + while (keys.hasNext()) { + String key = keys.next(); + map.put(key, unwrapJson(jsonObject.get(key))); + } + return map; + } + + private static List unwrapJsonArray(JSONArray jsonArray) throws JSONException { + List list = new ArrayList<>(jsonArray.length()); + for (int i = 0; i < jsonArray.length(); i++) { + list.add(unwrapJson(jsonArray.get(i))); + } + return list; + } + + private static Object unwrapJson(Object o) throws JSONException { + if (o instanceof JSONObject) { + return unwrapJsonObject((JSONObject) o); + } else if (o instanceof JSONArray) { + return unwrapJsonArray((JSONArray) o); + } else if (o.equals(JSONObject.NULL)) { + return null; + } else { + return o; + } + } +} diff --git a/src/main/java/com/google/firebase/database/utilities/Clock.java b/src/main/java/com/google/firebase/database/utilities/Clock.java new file mode 100644 index 000000000..ea2afd118 --- /dev/null +++ b/src/main/java/com/google/firebase/database/utilities/Clock.java @@ -0,0 +1,7 @@ +package com.google.firebase.database.utilities; + +// Abstract clock that can be replaced in unit tests. +public interface Clock { + + long millis(); +} diff --git a/src/main/java/com/google/firebase/database/utilities/DefaultClock.java b/src/main/java/com/google/firebase/database/utilities/DefaultClock.java new file mode 100644 index 000000000..390903be2 --- /dev/null +++ b/src/main/java/com/google/firebase/database/utilities/DefaultClock.java @@ -0,0 +1,9 @@ +package com.google.firebase.database.utilities; + +public class DefaultClock implements Clock { + + @Override + public long millis() { + return System.currentTimeMillis(); + } +} diff --git a/src/main/java/com/google/firebase/database/utilities/DefaultRunLoop.java b/src/main/java/com/google/firebase/database/utilities/DefaultRunLoop.java new file mode 100644 index 000000000..3d1af9dfd --- /dev/null +++ b/src/main/java/com/google/firebase/database/utilities/DefaultRunLoop.java @@ -0,0 +1,107 @@ +package com.google.firebase.database.utilities; + +import com.google.firebase.database.DatabaseException; +import com.google.firebase.database.FirebaseDatabase; +import com.google.firebase.database.annotations.Nullable; +import com.google.firebase.database.core.Context; +import com.google.firebase.database.core.RepoManager; +import com.google.firebase.database.core.RunLoop; +import com.google.firebase.internal.RevivingScheduledExecutor; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.ScheduledThreadPoolExecutor; +import java.util.concurrent.ThreadFactory; +import java.util.concurrent.TimeUnit; + +public abstract class DefaultRunLoop implements RunLoop { + + public abstract void handleException(Throwable e); + + private ScheduledThreadPoolExecutor executor; + + /** + * Creates a DefaultRunLoop that does not periodically restart its threads. + */ + public DefaultRunLoop() { + this(Executors.defaultThreadFactory(), false, null); + } + + /** + * Creates a DefaultRunLoop that optionally restarts its threads periodically. If 'context' is + * provided, these restarts will automatically interrupt and resume all Repo connections. + */ + public DefaultRunLoop( + final ThreadFactory threadFactory, + final boolean periodicRestart, + @Nullable final Context context) { + executor = + new RevivingScheduledExecutor(threadFactory, "FirebaseDatabaseWorker", periodicRestart) { + @Override + protected void handleException(Throwable t) { + DefaultRunLoop.this.handleException(t); + } + + @Override + protected void beforeRestart() { + if (context != null) { + RepoManager.interrupt(context); + } + } + + @Override + protected void afterRestart() { + if (context != null) { + RepoManager.resume(context); + } + } + }; + + // Core threads don't time out, this only takes effect when we drop the number of required + // core threads + executor.setKeepAliveTime(3, TimeUnit.SECONDS); + } + + public ScheduledExecutorService getExecutorService() { + return this.executor; + } + + @Override + public void scheduleNow(final Runnable runnable) { + executor.execute(runnable); + } + + @Override + @SuppressWarnings("rawtypes") + public ScheduledFuture schedule(final Runnable runnable, long milliseconds) { + return executor.schedule(runnable, milliseconds, TimeUnit.MILLISECONDS); + } + + @Override + public void shutdown() { + executor.setCorePoolSize(0); + } + + @Override + public void restart() { + executor.setCorePoolSize(1); + } + + public static String messageForException(Throwable t) { + if (t instanceof OutOfMemoryError) { + return "Firebase Database encountered an OutOfMemoryError. You may need to reduce the" + + " amount of data you are syncing to the client (e.g. by using queries or syncing" + + " a deeper path). See " + + "https://firebase.google.com/docs/database/ios/structure-data#best_practices_for_data_structure" + + " and " + + "https://firebase.google.com/docs/database/android/retrieve-data#filtering_data"; + } else if (t instanceof DatabaseException) { + // Exception should be self-explanatory and they shouldn't contact support. + return ""; + } else { + return "Uncaught exception in Firebase Database runloop (" + + FirebaseDatabase.getSdkVersion() + + "). Please report to firebase-database-client@google.com"; + } + } +} diff --git a/src/main/java/com/google/firebase/database/utilities/NodeSizeEstimator.java b/src/main/java/com/google/firebase/database/utilities/NodeSizeEstimator.java new file mode 100644 index 000000000..9f7dab2fb --- /dev/null +++ b/src/main/java/com/google/firebase/database/utilities/NodeSizeEstimator.java @@ -0,0 +1,79 @@ +package com.google.firebase.database.utilities; + +import com.google.firebase.database.snapshot.BooleanNode; +import com.google.firebase.database.snapshot.ChildrenNode; +import com.google.firebase.database.snapshot.DoubleNode; +import com.google.firebase.database.snapshot.LeafNode; +import com.google.firebase.database.snapshot.LongNode; +import com.google.firebase.database.snapshot.NamedNode; +import com.google.firebase.database.snapshot.Node; +import com.google.firebase.database.snapshot.StringNode; + +public class NodeSizeEstimator { + + /** + * Account for extra overhead due to the extra JSON object and the ".value" and ".priority" keys, + * colons, and comma + */ + private static final int LEAF_PRIORITY_OVERHEAD = 2 + 8 + 11 + 2 + 1; + + private static long estimateLeafNodeSize(LeafNode node) { + // These values are somewhat arbitrary, but we don't need an exact value so prefer performance + // over exact value + long valueSize; + if (node instanceof DoubleNode) { + valueSize = 8; // estimate each float with 8 bytes + } else if (node instanceof LongNode) { + valueSize = 8; + } else if (node instanceof BooleanNode) { + valueSize = 4; // true or false need roughly 4 bytes + } else if (node instanceof StringNode) { + valueSize = 2 + ((String) node.getValue()).length(); // add 2 for quotes + } else { + throw new IllegalArgumentException("Unknown leaf node type: " + node.getClass()); + } + if (node.getPriority().isEmpty()) { + return valueSize; + } else { + return LEAF_PRIORITY_OVERHEAD + + valueSize + + estimateLeafNodeSize((LeafNode) node.getPriority()); + } + } + + public static long estimateSerializedNodeSize(Node node) { + if (node.isEmpty()) { + return 4; // null keyword + } else if (node.isLeafNode()) { + return estimateLeafNodeSize((LeafNode) node); + } else { + assert node instanceof ChildrenNode : "Unexpected node type: " + node.getClass(); + long sum = 1; // opening brackets + for (NamedNode entry : node) { + sum += entry.getName().asString().length(); // key + sum += 4; // quotes around key and colon and (comma or closing bracket) + sum += estimateSerializedNodeSize(entry.getNode()); + } + if (!node.getPriority().isEmpty()) { + sum += 12; // "overhead for ".priority", key and colon and comma + sum += estimateLeafNodeSize((LeafNode) node.getPriority()); + } + return sum; + } + } + + public static int nodeCount(Node node) { + if (node.isEmpty()) { + return 0; + } else if (node.isLeafNode()) { + return 1; + } else { + assert node instanceof ChildrenNode : "Unexpected node type: " + node.getClass(); + int sum = 0; + for (NamedNode entry : node) { + sum += nodeCount(entry.getNode()); + } + return sum; + } + } +} diff --git a/src/main/java/com/google/firebase/database/utilities/OffsetClock.java b/src/main/java/com/google/firebase/database/utilities/OffsetClock.java new file mode 100644 index 000000000..bb524ecfd --- /dev/null +++ b/src/main/java/com/google/firebase/database/utilities/OffsetClock.java @@ -0,0 +1,21 @@ +package com.google.firebase.database.utilities; + +public class OffsetClock implements Clock { + + private final Clock baseClock; + private long offset = 0; + + public OffsetClock(Clock baseClock, long offset) { + this.baseClock = baseClock; + this.offset = offset; + } + + public void setOffset(long offset) { + this.offset = offset; + } + + @Override + public long millis() { + return baseClock.millis() + offset; + } +} diff --git a/src/main/java/com/google/firebase/database/utilities/Pair.java b/src/main/java/com/google/firebase/database/utilities/Pair.java new file mode 100644 index 000000000..7602cbb45 --- /dev/null +++ b/src/main/java/com/google/firebase/database/utilities/Pair.java @@ -0,0 +1,54 @@ +package com.google.firebase.database.utilities; + +public class Pair { + + private final T first; + private final U second; + + public Pair(T first, U second) { + this.first = first; + this.second = second; + } + + public T getFirst() { + return first; + } + + public U getSecond() { + return second; + } + + @Override + @SuppressWarnings("rawtypes") + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + + Pair pair = (Pair) o; + + if (first != null ? !first.equals(pair.first) : pair.first != null) { + return false; + } + if (second != null ? !second.equals(pair.second) : pair.second != null) { + return false; + } + + return true; + } + + @Override + public int hashCode() { + int result = first != null ? first.hashCode() : 0; + result = 31 * result + (second != null ? second.hashCode() : 0); + return result; + } + + @Override + public String toString() { + return "Pair(" + first + "," + second + ")"; + } +} diff --git a/src/main/java/com/google/firebase/database/utilities/ParsedUrl.java b/src/main/java/com/google/firebase/database/utilities/ParsedUrl.java new file mode 100644 index 000000000..a04abf6c5 --- /dev/null +++ b/src/main/java/com/google/firebase/database/utilities/ParsedUrl.java @@ -0,0 +1,13 @@ +package com.google.firebase.database.utilities; + +import com.google.firebase.database.core.Path; +import com.google.firebase.database.core.RepoInfo; + +/** + * User: greg Date: 5/15/13 Time: 1:18 PM + */ +public class ParsedUrl { + + public RepoInfo repoInfo; + public Path path; +} diff --git a/src/main/java/com/google/firebase/database/utilities/PushIdGenerator.java b/src/main/java/com/google/firebase/database/utilities/PushIdGenerator.java new file mode 100644 index 000000000..28c935a5f --- /dev/null +++ b/src/main/java/com/google/firebase/database/utilities/PushIdGenerator.java @@ -0,0 +1,56 @@ +package com.google.firebase.database.utilities; + +import java.util.Random; + +/** + * User: greg Date: 5/23/13 Time: 1:27 PM + */ +public class PushIdGenerator { + + private static final String PUSH_CHARS = + "-0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ_abcdefghijklmnopqrstuvwxyz"; + + private static final Random randGen = new Random(); + + private static long lastPushTime = 0L; + + private static final int[] lastRandChars = new int[12]; + + public static synchronized String generatePushChildName(long now) { + boolean duplicateTime = (now == lastPushTime); + lastPushTime = now; + + char[] timeStampChars = new char[8]; + StringBuilder result = new StringBuilder(20); + for (int i = 7; i >= 0; i--) { + timeStampChars[i] = PUSH_CHARS.charAt((int) (now % 64)); + now = now / 64; + } + assert (now == 0); + + result.append(timeStampChars); + + if (!duplicateTime) { + for (int i = 0; i < 12; i++) { + lastRandChars[i] = randGen.nextInt(64); + } + } else { + incrementArray(); + } + for (int i = 0; i < 12; i++) { + result.append(PUSH_CHARS.charAt(lastRandChars[i])); + } + assert (result.length() == 20); + return result.toString(); + } + + private static void incrementArray() { + for (int i = 11; i >= 0; i--) { + if (lastRandChars[i] != 63) { + lastRandChars[i] = lastRandChars[i] + 1; + return; + } + lastRandChars[i] = 0; + } + } +} diff --git a/src/main/java/com/google/firebase/database/utilities/Utilities.java b/src/main/java/com/google/firebase/database/utilities/Utilities.java new file mode 100644 index 000000000..d20ec3c61 --- /dev/null +++ b/src/main/java/com/google/firebase/database/utilities/Utilities.java @@ -0,0 +1,244 @@ +package com.google.firebase.database.utilities; + +import com.google.firebase.database.DatabaseError; +import com.google.firebase.database.DatabaseException; +import com.google.firebase.database.DatabaseReference; +import com.google.firebase.database.core.Path; +import com.google.firebase.database.core.RepoInfo; +import com.google.firebase.internal.Base64; +import com.google.firebase.tasks.Task; +import com.google.firebase.tasks.TaskCompletionSource; +import java.io.UnsupportedEncodingException; +import java.net.URI; +import java.net.URISyntaxException; +import java.net.URLEncoder; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.ArrayList; +import java.util.Map; + +public class Utilities { + + private static final char[] HEX_CHARACTERS = "0123456789abcdef".toCharArray(); + + public static ParsedUrl parseUrl(String url) throws DatabaseException { + String original = url; + try { + int schemeOffset = original.indexOf("//"); + if (schemeOffset == -1) { + throw new URISyntaxException(original, "Invalid scheme specified"); + } + int pathOffset = original.substring(schemeOffset + 2).indexOf("/"); + if (pathOffset != -1) { + pathOffset += schemeOffset + 2; + String[] pathSegments = original.substring(pathOffset).split("/"); + StringBuilder builder = new StringBuilder(); + for (int i = 0; i < pathSegments.length; ++i) { + if (!pathSegments[i].equals("")) { + builder.append("/"); + builder.append(URLEncoder.encode(pathSegments[i], "UTF-8")); + } + } + original = original.substring(0, pathOffset) + builder.toString(); + } + + URI uri = new URI(original); + // URLEncoding a space turns it into a '+', which is different + // from our expected behavior. Do a manual replace to fix it. + String pathString = uri.getPath().replace("+", " "); + Validation.validateRootPathString(pathString); + Path path = new Path(pathString); + String scheme = uri.getScheme(); + + RepoInfo repoInfo = new RepoInfo(); + repoInfo.host = uri.getHost().toLowerCase(); + + int port = uri.getPort(); + if (port != -1) { + repoInfo.secure = scheme.equals("https"); + repoInfo.host += ":" + port; + } else { + repoInfo.secure = true; + } + String[] parts = repoInfo.host.split("\\."); + + repoInfo.namespace = parts[0].toLowerCase(); + repoInfo.internalHost = repoInfo.host; + ParsedUrl parsedUrl = new ParsedUrl(); + parsedUrl.path = path; + parsedUrl.repoInfo = repoInfo; + return parsedUrl; + + } catch (URISyntaxException e) { + throw new DatabaseException("Invalid Firebase Database url specified", e); + } catch (UnsupportedEncodingException e) { + throw new DatabaseException("Failed to URLEncode the path", e); + } + } + + public static String[] splitIntoFrames(String src, int maxFrameSize) { + if (src.length() <= maxFrameSize) { + return new String[]{src}; + } else { + ArrayList segs = new ArrayList<>(); + for (int i = 0; i < src.length(); i += maxFrameSize) { + int end = Math.min(i + maxFrameSize, src.length()); + String seg = src.substring(i, end); + segs.add(seg); + } + return segs.toArray(new String[segs.size()]); + } + } + + public static String sha1HexDigest(String input) { + try { + MessageDigest md = MessageDigest.getInstance("SHA-1"); + md.update(input.getBytes("UTF-8")); + byte[] bytes = md.digest(); + return Base64.encodeToString(bytes, Base64.NO_WRAP); + } catch (NoSuchAlgorithmException e) { + throw new RuntimeException("Missing SHA-1 MessageDigest provider.", e); + } catch (UnsupportedEncodingException e) { + throw new RuntimeException("UTF-8 encoding is required for Firebase Database to run!"); + } + } + + public static String stringHashV2Representation(String value) { + String escaped = value; + if (value.indexOf('\\') != -1) { + escaped = escaped.replace("\\", "\\\\"); + } + if (value.indexOf('"') != -1) { + escaped = escaped.replace("\"", "\\\""); + } + return '"' + escaped + '"'; + } + + public static String doubleToHashString(double value) { + StringBuilder sb = new StringBuilder(16); + long bits = Double.doubleToLongBits(value); + // We use big-endian to encode the bytes + for (int i = 7; i >= 0; i--) { + int byteValue = (int) ((bits >>> (8 * i)) & 0xff); + int high = ((byteValue >> 4) & 0xf); + int low = (byteValue & 0xf); + sb.append(HEX_CHARACTERS[high]); + sb.append(HEX_CHARACTERS[low]); + } + return sb.toString(); + } + + // NOTE: We could use Ints.tryParse from guava, but I don't feel like pulling in guava (~2mb) for + // that small purpose. + public static Integer tryParseInt(String num) { + if (num.length() > 11 || num.length() == 0) { + return null; + } + int i = 0; + boolean negative = false; + if (num.charAt(0) == '-') { + if (num.length() == 1) { + return null; + } + negative = true; + i = 1; + } + // long to prevent overflow + long number = 0; + while (i < num.length()) { + char c = num.charAt(i); + if (c < '0' || c > '9') { + return null; + } + number = number * 10 + (c - '0'); + i++; + } + if (negative) { + if (-number < Integer.MIN_VALUE) { + return null; + } else { + return (int) (-number); + } + } else { + if (number > Integer.MAX_VALUE) { + return null; + } + return (int) number; + } + } + + public static int compareInts(int i, int j) { + if (i < j) { + return -1; + } else if (i == j) { + return 0; + } else { + return 1; + } + } + + public static int compareLongs(long i, long j) { + if (i < j) { + return -1; + } else if (i == j) { + return 0; + } else { + return 1; + } + } + + @SuppressWarnings("unchecked") + public static C castOrNull(Object o, Class clazz) { + if (clazz.isAssignableFrom(o.getClass())) { + return (C) o; + } else { + return null; + } + } + + @SuppressWarnings("rawtypes") + public static C getOrNull(Object o, String key, Class clazz) { + if (o == null) { + return null; + } + Map map = castOrNull(o, Map.class); + Object result = map.get(key); + if (result != null) { + return castOrNull(result, clazz); + } else { + return null; + } + } + + public static void hardAssert(boolean condition) { + hardAssert(condition, ""); + } + + public static void hardAssert(boolean condition, String message) { + if (!condition) { + throw new AssertionError("hardAssert failed: " + message); + } + } + + public static Pair, DatabaseReference.CompletionListener> wrapOnComplete( + DatabaseReference.CompletionListener optListener) { + if (optListener == null) { + final TaskCompletionSource source = new TaskCompletionSource<>(); + DatabaseReference.CompletionListener listener = + new DatabaseReference.CompletionListener() { + @Override + public void onComplete(DatabaseError error, DatabaseReference ref) { + if (error != null) { + source.setException(error.toException()); + } else { + source.setResult(null); + } + } + }; + return new Pair<>(source.getTask(), listener); + } else { + // If a listener is supplied we do not want to create a Task + return new Pair<>(null, optListener); + } + } +} diff --git a/src/main/java/com/google/firebase/database/utilities/Validation.java b/src/main/java/com/google/firebase/database/utilities/Validation.java new file mode 100644 index 000000000..7b4ce7e5d --- /dev/null +++ b/src/main/java/com/google/firebase/database/utilities/Validation.java @@ -0,0 +1,150 @@ +package com.google.firebase.database.utilities; + +import static com.google.firebase.database.utilities.Utilities.hardAssert; + +import com.google.firebase.database.DatabaseException; +import com.google.firebase.database.core.Path; +import com.google.firebase.database.core.ServerValues; +import com.google.firebase.database.core.ValidationPath; +import com.google.firebase.database.snapshot.ChildKey; +import com.google.firebase.database.snapshot.Node; +import com.google.firebase.database.snapshot.NodeUtilities; +import com.google.firebase.database.snapshot.PriorityUtilities; +import java.util.List; +import java.util.Map; +import java.util.SortedMap; +import java.util.TreeMap; +import java.util.regex.Pattern; + +/** + * User: greg Date: 5/29/13 Time: 11:08 AM + */ +public class Validation { + + private static final Pattern INVALID_PATH_REGEX = Pattern.compile("[\\[\\]\\.#$]"); + private static final Pattern INVALID_KEY_REGEX = + Pattern.compile("[\\[\\]\\.#\\$\\/\\u0000-\\u001F\\u007F]"); + + private static boolean isValidPathString(String pathString) { + return !INVALID_PATH_REGEX.matcher(pathString).find(); + } + + public static void validatePathString(String pathString) throws DatabaseException { + if (!isValidPathString(pathString)) { + throw new DatabaseException( + "Invalid Firebase Database path: " + + pathString + + ". Firebase Database paths must not contain '.', '#', '$', '[', or ']'"); + } + } + + public static void validateRootPathString(String pathString) throws DatabaseException { + if (pathString.startsWith(".info")) { + validatePathString(pathString.substring(5)); + } else if (pathString.startsWith("/.info")) { + validatePathString(pathString.substring(6)); + } else { + validatePathString(pathString); + } + } + + private static boolean isWritableKey(String key) { + return key != null + && key.length() > 0 + && (key.equals(".value") + || key.equals(".priority") + || (!key.startsWith(".") && !INVALID_KEY_REGEX.matcher(key).find())); + } + + private static boolean isValidKey(String key) { + return key.equals(".info") || !INVALID_KEY_REGEX.matcher(key).find(); + } + + public static void validateNullableKey(String key) throws DatabaseException { + if (!(key == null || isValidKey(key))) { + throw new DatabaseException( + "Invalid key: " + key + ". Keys must not contain '/', '.', '#', '$', '[', or ']'"); + } + } + + private static boolean isWritablePath(Path path) { + // Getting a path with invalid keys will throw earlier in the process, so we should just + // check the first token + ChildKey front = path.getFront(); + return front == null || !front.asString().startsWith("."); + } + + @SuppressWarnings("unchecked") + public static void validateWritableObject(Object object) { + if (object instanceof Map) { + Map map = (Map) object; + if (map.containsKey(ServerValues.NAME_SUBKEY_SERVERVALUE)) { + // This will be short-circuited by conversion and we consider it valid + return; + } + for (Map.Entry entry : map.entrySet()) { + validateWritableKey(entry.getKey()); + validateWritableObject(entry.getValue()); + } + } else if (object instanceof List) { + List list = (List) object; + for (Object child : list) { + validateWritableObject(child); + } + } else { + // It's a primitive, should be fine + } + } + + public static void validateWritableKey(String key) throws DatabaseException { + if (!isWritableKey(key)) { + throw new DatabaseException( + "Invalid key: " + key + ". Keys must not contain '/', '.', '#', '$', '[', or ']'"); + } + } + + public static void validateWritablePath(Path path) throws DatabaseException { + if (!isWritablePath(path)) { + throw new DatabaseException("Invalid write location: " + path.toString()); + } + } + + public static Map parseAndValidateUpdate(Path path, Map update) + throws DatabaseException { + final SortedMap parsedUpdate = new TreeMap<>(); + for (Map.Entry entry : update.entrySet()) { + Path updatePath = new Path(entry.getKey()); + Object newValue = entry.getValue(); + ValidationPath.validateWithObject(path.child(updatePath), newValue); + String childName = !updatePath.isEmpty() ? updatePath.getBack().asString() : ""; + if (childName.equals(ServerValues.NAME_SUBKEY_SERVERVALUE) || childName.equals(".value")) { + throw new DatabaseException( + "Path '" + updatePath + "' contains disallowed child name: " + childName); + } + if (childName.equals(".priority")) { + if (!PriorityUtilities.isValidPriority(NodeUtilities.NodeFromJSON(newValue))) { + throw new DatabaseException( + "Path '" + + updatePath + + "' contains invalid priority " + + "(must be a string, double, ServerValue, or null)."); + } + } + Validation.validateWritableObject(newValue); + parsedUpdate.put(updatePath, NodeUtilities.NodeFromJSON(newValue)); + } + // Check that update keys are not ancestors of each other. + Path prevPath = null; + for (Path curPath : parsedUpdate.keySet()) { + // We rely on the property that sorting guarantees that ancestors come right before + // descendants. + hardAssert(prevPath == null || prevPath.compareTo(curPath) < 0); + if (prevPath != null && prevPath.contains(curPath)) { + throw new DatabaseException( + "Path '" + prevPath + "' is an ancestor of '" + curPath + "' in an update."); + } + prevPath = curPath; + } + return parsedUpdate; + } +} diff --git a/src/main/java/com/google/firebase/database/utilities/encoding/CustomClassMapper.java b/src/main/java/com/google/firebase/database/utilities/encoding/CustomClassMapper.java new file mode 100644 index 000000000..62d2e3a82 --- /dev/null +++ b/src/main/java/com/google/firebase/database/utilities/encoding/CustomClassMapper.java @@ -0,0 +1,791 @@ +package com.google.firebase.database.utilities.encoding; + +import static com.google.firebase.database.utilities.Utilities.hardAssert; + +import com.google.firebase.database.DatabaseException; +import com.google.firebase.database.Exclude; +import com.google.firebase.database.GenericTypeIndicator; +import com.google.firebase.database.IgnoreExtraProperties; +import com.google.firebase.database.PropertyName; +import com.google.firebase.database.ThrowOnExtraProperties; +import com.google.firebase.internal.Log; +import java.lang.reflect.AccessibleObject; +import java.lang.reflect.Constructor; +import java.lang.reflect.Field; +import java.lang.reflect.GenericArrayType; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.lang.reflect.Modifier; +import java.lang.reflect.ParameterizedType; +import java.lang.reflect.Type; +import java.lang.reflect.TypeVariable; +import java.lang.reflect.WildcardType; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; + +/** + * Helper class to convert to/from custom POJO classes and plain Java types. + */ +public class CustomClassMapper { + + private static final String LOG_TAG = "ClassMapper"; + + private static final ConcurrentMap, BeanMapper> mappers = new ConcurrentHashMap<>(); + + /** + * Converts a Java representation of JSON data to standard library Java data types: Map, Array, + * String, Double, Integer and Boolean. POJOs are converted to Java Maps. + * + * @param object The representation of the JSON data + * @return JSON representation containing only standard library Java types + */ + public static Object convertToPlainJavaTypes(Object object) { + return serialize(object); + } + + @SuppressWarnings("unchecked") + public static Map convertToPlainJavaTypes(Map update) { + Object converted = serialize(update); + hardAssert(converted instanceof Map); + return (Map) converted; + } + + /** + * Converts a standard library Java representation of JSON data to an object of the provided + * class. + * + * @param object The representation of the JSON data + * @param clazz The class of the object to convert to + * @return The POJO object. + */ + public static T convertToCustomClass(Object object, Class clazz) { + return deserializeToClass(object, clazz); + } + + /** + * Converts a standard library Java representation of JSON data to an object of the class provided + * through the GenericTypeIndicator + * + * @param object The representation of the JSON data + * @param typeIndicator The indicator providing class of the object to convert to + * @return The POJO object. + */ + public static T convertToCustomClass(Object object, GenericTypeIndicator typeIndicator) { + Class clazz = typeIndicator.getClass(); + Type genericTypeIndicatorType = clazz.getGenericSuperclass(); + if (genericTypeIndicatorType instanceof ParameterizedType) { + ParameterizedType parameterizedType = (ParameterizedType) genericTypeIndicatorType; + if (!parameterizedType.getRawType().equals(GenericTypeIndicator.class)) { + throw new DatabaseException( + "Not a direct subclass of GenericTypeIndicator: " + genericTypeIndicatorType); + } + // We are guaranteed to have exactly one type parameter + Type type = parameterizedType.getActualTypeArguments()[0]; + return deserializeToType(object, type); + } else { + throw new DatabaseException( + "Not a direct subclass of GenericTypeIndicator: " + genericTypeIndicatorType); + } + } + + @SuppressWarnings("unchecked") + private static Object serialize(T o) { + if (o == null) { + return null; + } else if (o instanceof Number) { + if (o instanceof Float) { + return ((Float) o).doubleValue(); + } else if (o instanceof Short) { + throw new DatabaseException("Shorts are not supported, please use int or long"); + } else if (o instanceof Byte) { + throw new DatabaseException("Bytes are not supported, please use int or long"); + } else { + // Long, Integer, Double + return o; + } + } else if (o instanceof String) { + return o; + } else if (o instanceof Boolean) { + return o; + } else if (o instanceof Character) { + throw new DatabaseException("Characters are not supported, please strings"); + } else if (o instanceof Map) { + Map result = new HashMap<>(); + for (Map.Entry entry : ((Map) o).entrySet()) { + Object key = entry.getKey(); + if (key instanceof String) { + String keyString = (String) key; + result.put(keyString, serialize(entry.getValue())); + } else { + throw new DatabaseException("Maps with non-string keys are not supported"); + } + } + return result; + } else if (o instanceof Collection) { + if (o instanceof List) { + List list = (List) o; + List result = new ArrayList<>(list.size()); + for (Object object : list) { + result.add(serialize(object)); + } + return result; + } else { + throw new DatabaseException( + "Serializing Collections is not supported, " + "please use Lists instead"); + } + } else if (o.getClass().isArray()) { + throw new DatabaseException( + "Serializing Arrays is not supported, please use Lists " + "instead"); + } else if (o instanceof Enum) { + return ((Enum) o).name(); + } else { + Class clazz = (Class) o.getClass(); + BeanMapper mapper = loadOrCreateBeanMapperForClass(clazz); + return mapper.serialize(o); + } + } + + @SuppressWarnings({"unchecked", "TypeParameterUnusedInFormals"}) + private static T deserializeToType(Object o, Type type) { + if (o == null) { + return null; + } else if (type instanceof ParameterizedType) { + return deserializeToParameterizedType(o, (ParameterizedType) type); + } else if (type instanceof Class) { + return deserializeToClass(o, (Class) type); + } else if (type instanceof WildcardType) { + throw new DatabaseException("Generic wildcard types are not supported"); + } else if (type instanceof GenericArrayType) { + throw new DatabaseException( + "Generic Arrays are not supported, please use Lists " + "instead"); + } else { + throw new IllegalStateException("Unknown type encountered: " + type); + } + } + + @SuppressWarnings("unchecked") + private static T deserializeToClass(Object o, Class clazz) { + if (o == null) { + return null; + } else if (clazz.isPrimitive() + || Number.class.isAssignableFrom(clazz) + || Boolean.class.isAssignableFrom(clazz) + || Character.class.isAssignableFrom(clazz)) { + return deserializeToPrimitive(o, clazz); + } else if (String.class.isAssignableFrom(clazz)) { + return (T) convertString(o); + } else if (clazz.isArray()) { + throw new DatabaseException( + "Converting to Arrays is not supported, please use Lists" + "instead"); + } else if (clazz.getTypeParameters().length > 0) { + throw new DatabaseException( + "Class " + + clazz.getName() + + " has generic type " + + "parameters, please use GenericTypeIndicator instead"); + } else if (clazz.equals(Object.class)) { + return (T) o; + } else if (clazz.isEnum()) { + return deserializeToEnum(o, clazz); + } else { + return convertBean(o, clazz); + } + } + + @SuppressWarnings({"unchecked", "TypeParameterUnusedInFormals"}) + private static T deserializeToParameterizedType(Object o, ParameterizedType type) { + // getRawType should always return a Class + Class rawType = (Class) type.getRawType(); + if (List.class.isAssignableFrom(rawType)) { + Type genericType = type.getActualTypeArguments()[0]; + if (o instanceof List) { + List list = (List) o; + List result = new ArrayList<>(list.size()); + for (Object object : list) { + result.add(deserializeToType(object, genericType)); + } + return (T) result; + } else { + throw new DatabaseException( + "Expected a List while deserializing, but got a " + o.getClass()); + } + } else if (Map.class.isAssignableFrom(rawType)) { + Type keyType = type.getActualTypeArguments()[0]; + Type valueType = type.getActualTypeArguments()[1]; + if (!keyType.equals(String.class)) { + throw new DatabaseException( + "Only Maps with string keys are supported, " + + "but found Map with key type " + + keyType); + } + Map map = expectMap(o); + HashMap result = new HashMap<>(); + for (Map.Entry entry : map.entrySet()) { + result.put(entry.getKey(), deserializeToType(entry.getValue(), valueType)); + } + return (T) result; + } else if (Collection.class.isAssignableFrom(rawType)) { + throw new DatabaseException("Collections are not supported, please use Lists instead"); + } else { + Map map = expectMap(o); + BeanMapper mapper = (BeanMapper) loadOrCreateBeanMapperForClass(rawType); + HashMap>, Type> typeMapping = new HashMap<>(); + TypeVariable>[] typeVariables = mapper.clazz.getTypeParameters(); + Type[] types = type.getActualTypeArguments(); + if (types.length != typeVariables.length) { + throw new IllegalStateException( + "Mismatched lengths for type variables and " + "actual types"); + } + for (int i = 0; i < typeVariables.length; i++) { + typeMapping.put(typeVariables[i], types[i]); + } + return mapper.deserialize(map, typeMapping); + } + } + + @SuppressWarnings("unchecked") + private static T deserializeToPrimitive(Object o, Class clazz) { + if (Integer.class.isAssignableFrom(clazz) || int.class.isAssignableFrom(clazz)) { + return (T) convertInteger(o); + } else if (Boolean.class.isAssignableFrom(clazz) || boolean.class.isAssignableFrom(clazz)) { + return (T) convertBoolean(o); + } else if (Double.class.isAssignableFrom(clazz) || double.class.isAssignableFrom(clazz)) { + return (T) convertDouble(o); + } else if (Long.class.isAssignableFrom(clazz) || long.class.isAssignableFrom(clazz)) { + return (T) convertLong(o); + } else if (Float.class.isAssignableFrom(clazz) || float.class.isAssignableFrom(clazz)) { + return (T) (Float) convertDouble(o).floatValue(); + } else if (Short.class.isAssignableFrom(clazz) || short.class.isAssignableFrom(clazz)) { + throw new DatabaseException("Deserializing to shorts is not supported"); + } else if (Byte.class.isAssignableFrom(clazz) || byte.class.isAssignableFrom(clazz)) { + throw new DatabaseException("Deserializing to bytes is not supported"); + } else if (Character.class.isAssignableFrom(clazz) || char.class.isAssignableFrom(clazz)) { + throw new DatabaseException("Deserializing to char is not supported"); + } else { + throw new IllegalArgumentException("Unknown primitive type: " + clazz); + } + } + + @SuppressWarnings("unchecked") + private static T deserializeToEnum(Object object, Class clazz) { + if (object instanceof String) { + String value = (String) object; + // We cast to Class without generics here since we can't prove the bound + // T extends Enum statically + try { + return (T) Enum.valueOf((Class) clazz, value); + } catch (IllegalArgumentException e) { + throw new DatabaseException( + "Could not find enum value of " + clazz.getName() + " for value \"" + value + "\""); + } + } else { + throw new DatabaseException( + "Expected a String while deserializing to enum " + + clazz + + " but got a " + + object.getClass()); + } + } + + @SuppressWarnings("unchecked") + private static BeanMapper loadOrCreateBeanMapperForClass(Class clazz) { + BeanMapper mapper = (BeanMapper) mappers.get(clazz); + if (mapper == null) { + mapper = new BeanMapper<>(clazz); + // Inserting without checking is fine because mappers are "pure" and it's okay + // if we create and use multiple by different threads temporarily + mappers.put(clazz, mapper); + } + return mapper; + } + + @SuppressWarnings("unchecked") + private static Map expectMap(Object object) { + if (object instanceof Map) { + // TODO(dimond): runtime validation of keys? + return (Map) object; + } else { + throw new DatabaseException( + "Expected a Map while deserializing, but got a " + object.getClass()); + } + } + + private static Integer convertInteger(Object o) { + if (o instanceof Integer) { + return (Integer) o; + } else if (o instanceof Long || o instanceof Double) { + double value = ((Number) o).doubleValue(); + if (value >= Integer.MIN_VALUE && value <= Integer.MAX_VALUE) { + return ((Number) o).intValue(); + } else { + throw new DatabaseException( + "Numeric value out of 32-bit integer range: " + + value + + ". Did you mean to use a long or double instead of an int?"); + } + } else { + throw new DatabaseException( + "Failed to convert a value of type " + o.getClass().getName() + " to int"); + } + } + + private static Long convertLong(Object o) { + if (o instanceof Integer) { + return ((Integer) o).longValue(); + } else if (o instanceof Long) { + return (Long) o; + } else if (o instanceof Double) { + Double value = (Double) o; + if (value >= Long.MIN_VALUE && value <= Long.MAX_VALUE) { + return value.longValue(); + } else { + throw new DatabaseException( + "Numeric value out of 64-bit long range: " + + value + + ". Did you mean to use a double instead of a long?"); + } + } else { + throw new DatabaseException( + "Failed to convert a value of type " + o.getClass().getName() + " to long"); + } + } + + private static Double convertDouble(Object o) { + if (o instanceof Integer) { + return ((Integer) o).doubleValue(); + } else if (o instanceof Long) { + Long value = (Long) o; + Double doubleValue = ((Long) o).doubleValue(); + if (doubleValue.longValue() == value) { + return doubleValue; + } else { + throw new DatabaseException( + "Loss of precision while converting number to " + + "double: " + + o + + ". Did you mean to use a 64-bit long instead?"); + } + } else if (o instanceof Double) { + return (Double) o; + } else { + throw new DatabaseException( + "Failed to convert a value of type " + o.getClass().getName() + " to double"); + } + } + + private static Boolean convertBoolean(Object o) { + if (o instanceof Boolean) { + return (Boolean) o; + } else { + throw new DatabaseException( + "Failed to convert value of type " + o.getClass().getName() + " to boolean"); + } + } + + private static String convertString(Object o) { + if (o instanceof String) { + return (String) o; + } else { + throw new DatabaseException( + "Failed to convert value of type " + o.getClass().getName() + " to String"); + } + } + + private static T convertBean(Object o, Class clazz) { + BeanMapper mapper = loadOrCreateBeanMapperForClass(clazz); + if (o instanceof Map) { + return mapper.deserialize(expectMap(o)); + } else { + throw new DatabaseException( + "Can't convert object of type " + o.getClass().getName() + " to type " + clazz.getName()); + } + } + + private static class BeanMapper { + + private final Class clazz; + private final Constructor constructor; + private final boolean throwOnUnknownProperties; + private final boolean warnOnUnknownProperties; + // Case insensitive mapping of properties to their case sensitive versions + private final Map properties; + + private final Map getters; + private final Map setters; + private final Map fields; + + public BeanMapper(Class clazz) { + this.clazz = clazz; + this.throwOnUnknownProperties = clazz.isAnnotationPresent(ThrowOnExtraProperties.class); + this.warnOnUnknownProperties = !clazz.isAnnotationPresent(IgnoreExtraProperties.class); + this.properties = new HashMap<>(); + + this.setters = new HashMap<>(); + this.getters = new HashMap<>(); + this.fields = new HashMap<>(); + + Constructor constructor = null; + try { + constructor = clazz.getDeclaredConstructor(); + constructor.setAccessible(true); + } catch (NoSuchMethodException e) { + // We will only fail at deserialization time if no constructor is present + constructor = null; + } + this.constructor = constructor; + // Add any public getters to properties (including isXyz()) + for (Method method : clazz.getMethods()) { + if (shouldIncludeGetter(method)) { + String propertyName = propertyName(method); + addProperty(propertyName); + method.setAccessible(true); + if (getters.containsKey(propertyName)) { + throw new DatabaseException("Found conflicting getters for name: " + method.getName()); + } + getters.put(propertyName, method); + } + } + + // Add any public fields to properties + for (Field field : clazz.getFields()) { + if (shouldIncludeField(field)) { + String propertyName = propertyName(field); + + addProperty(propertyName); + } + } + + // We can use private setters and fields for known (public) properties/getters. Since + // getMethods/getFields only returns public methods/fields we need to traverse the + // class hierarchy to find the appropriate setter or field. + Class currentClass = clazz; + do { + // Add any setters + for (Method method : currentClass.getDeclaredMethods()) { + if (shouldIncludeSetter(method)) { + String propertyName = propertyName(method); + String existingPropertyName = properties.get(propertyName.toLowerCase()); + if (existingPropertyName != null) { + if (!existingPropertyName.equals(propertyName)) { + throw new DatabaseException( + "Found setter with invalid " + "case-sensitive name: " + method.getName()); + } else { + Method existingSetter = setters.get(propertyName); + if (existingSetter == null) { + method.setAccessible(true); + setters.put(propertyName, method); + } else if (!isSetterOverride(method, existingSetter)) { + // We require that setters with conflicting property names are + // overrides from a base class + throw new DatabaseException( + "Found a conflicting setters " + + "with name: " + + method.getName() + + " (conflicts with " + + existingSetter.getName() + + " defined on " + + existingSetter.getDeclaringClass().getName() + + ")"); + } + } + } + } + } + + for (Field field : currentClass.getDeclaredFields()) { + String propertyName = propertyName(field); + + // Case sensitivity is checked at deserialization time + // Fields are only added if they don't exist on a subclass + if (properties.containsKey(propertyName.toLowerCase()) + && !fields.containsKey(propertyName)) { + field.setAccessible(true); + fields.put(propertyName, field); + } + } + + // Traverse class hierarchy until we reach java.lang.Object which contains a bunch + // of fields/getters we don't want to serialize + currentClass = currentClass.getSuperclass(); + } while (currentClass != null && !currentClass.equals(Object.class)); + + if (properties.isEmpty()) { + throw new DatabaseException("No properties to serialize found on class " + clazz.getName()); + } + } + + private void addProperty(String property) { + String oldValue = this.properties.put(property.toLowerCase(), property); + if (oldValue != null && !property.equals(oldValue)) { + throw new DatabaseException( + "Found two getters or fields with conflicting case " + + "sensitivity for property: " + + property.toLowerCase()); + } + } + + public T deserialize(Map values) { + return deserialize(values, Collections.>, Type>emptyMap()); + } + + public T deserialize(Map values, Map>, Type> types) { + if (this.constructor == null) { + throw new DatabaseException( + "Class " + this.clazz.getName() + " is missing a " + "constructor with no arguments"); + } + T instance; + try { + instance = this.constructor.newInstance(); + } catch (InstantiationException e) { + throw new RuntimeException(e); + } catch (IllegalAccessException e) { + throw new RuntimeException(e); + } catch (InvocationTargetException e) { + throw new RuntimeException(e); + } + for (Map.Entry entry : values.entrySet()) { + String propertyName = entry.getKey(); + if (this.setters.containsKey(propertyName)) { + Method setter = this.setters.get(propertyName); + Type[] params = setter.getGenericParameterTypes(); + if (params.length != 1) { + throw new IllegalStateException("Setter does not have exactly one " + "parameter"); + } + Type resolvedType = resolveType(params[0], types); + Object value = CustomClassMapper.deserializeToType(entry.getValue(), resolvedType); + try { + setter.invoke(instance, value); + } catch (IllegalAccessException e) { + throw new RuntimeException(e); + } catch (InvocationTargetException e) { + throw new RuntimeException(e); + } + } else if (this.fields.containsKey(propertyName)) { + Field field = this.fields.get(propertyName); + Type resolvedType = resolveType(field.getGenericType(), types); + Object value = CustomClassMapper.deserializeToType(entry.getValue(), resolvedType); + try { + field.set(instance, value); + } catch (IllegalAccessException e) { + throw new RuntimeException(e); + } + } else { + String message = + "No setter/field for " + + propertyName + + " found " + + "on class " + + this.clazz.getName(); + if (this.properties.containsKey(propertyName.toLowerCase())) { + message += " (fields/setters are case sensitive!)"; + } + if (this.throwOnUnknownProperties) { + throw new DatabaseException(message); + } else if (this.warnOnUnknownProperties) { + // TODO(dimond): replace Android logging with "our" logging + Log.w(LOG_TAG, message); + } + } + } + return instance; + } + + private Type resolveType(Type type, Map>, Type> types) { + if (type instanceof TypeVariable) { + Type resolvedType = types.get(type); + if (resolvedType == null) { + throw new IllegalStateException("Could not resolve type " + type); + } else { + return resolvedType; + } + } else { + return type; + } + } + + public Map serialize(T object) { + if (!clazz.isAssignableFrom(object.getClass())) { + throw new IllegalArgumentException( + "Can't serialize object of class " + + object.getClass() + + " with BeanMapper for class " + + clazz); + } + Map result = new HashMap<>(); + for (String property : this.properties.values()) { + Object propertyValue; + if (this.getters.containsKey(property)) { + Method getter = this.getters.get(property); + try { + propertyValue = getter.invoke(object); + } catch (IllegalAccessException e) { + throw new RuntimeException(e); + } catch (InvocationTargetException e) { + throw new RuntimeException(e); + } + } else { + // Must be a field + Field field = this.fields.get(property); + if (field == null) { + throw new IllegalStateException("Bean property without field or getter:" + property); + } + try { + propertyValue = field.get(object); + } catch (IllegalAccessException e) { + throw new RuntimeException(e); + } + } + Object serializedValue = CustomClassMapper.serialize(propertyValue); + result.put(property, serializedValue); + } + return result; + } + + private static boolean shouldIncludeGetter(Method method) { + if (!method.getName().startsWith("get") && !method.getName().startsWith("is")) { + return false; + } + // Exclude methods from Object.class + if (method.getDeclaringClass().equals(Object.class)) { + return false; + } + // Non-public methods + if (!Modifier.isPublic(method.getModifiers())) { + return false; + } + // Static methods + if (Modifier.isStatic(method.getModifiers())) { + return false; + } + // No return type + if (method.getReturnType().equals(Void.TYPE)) { + return false; + } + // Non-zero parameters + if (method.getParameterTypes().length != 0) { + return false; + } + // Excluded methods + if (method.isAnnotationPresent(Exclude.class)) { + return false; + } + return true; + } + + private static boolean shouldIncludeSetter(Method method) { + if (!method.getName().startsWith("set")) { + return false; + } + // Exclude methods from Object.class + if (method.getDeclaringClass().equals(Object.class)) { + return false; + } + // Static methods + if (Modifier.isStatic(method.getModifiers())) { + return false; + } + // Has a return type + if (!method.getReturnType().equals(Void.TYPE)) { + return false; + } + // Methods without exactly one parameters + if (method.getParameterTypes().length != 1) { + return false; + } + // Excluded methods + if (method.isAnnotationPresent(Exclude.class)) { + return false; + } + return true; + } + + private static boolean shouldIncludeField(Field field) { + // Exclude methods from Object.class + if (field.getDeclaringClass().equals(Object.class)) { + return false; + } + // Non-public fields + if (!Modifier.isPublic(field.getModifiers())) { + return false; + } + // Static fields + if (Modifier.isStatic(field.getModifiers())) { + return false; + } + // Transient fields + if (Modifier.isTransient(field.getModifiers())) { + return false; + } + // Excluded fields + if (field.isAnnotationPresent(Exclude.class)) { + return false; + } + return true; + } + + private static boolean isSetterOverride(Method base, Method override) { + // We expect an overridden setter here + hardAssert( + base.getDeclaringClass().isAssignableFrom(override.getDeclaringClass()), + "Expected override from a base class"); + hardAssert(base.getReturnType().equals(Void.TYPE), "Expected void return type"); + hardAssert(override.getReturnType().equals(Void.TYPE), "Expected void return type"); + + Type[] baseParameterTypes = base.getParameterTypes(); + Type[] overrideParameterTypes = override.getParameterTypes(); + hardAssert(baseParameterTypes.length == 1, "Expected exactly one parameter"); + hardAssert(overrideParameterTypes.length == 1, "Expected exactly one parameter"); + + return base.getName().equals(override.getName()) + && baseParameterTypes[0].equals(overrideParameterTypes[0]); + } + + private static String propertyName(Field field) { + String annotatedName = annotatedName(field); + return annotatedName != null ? annotatedName : field.getName(); + } + + private static String propertyName(Method method) { + String annotatedName = annotatedName(method); + return annotatedName != null ? annotatedName : serializedName(method.getName()); + } + + private static String annotatedName(AccessibleObject obj) { + if (obj.isAnnotationPresent(PropertyName.class)) { + PropertyName annotation = obj.getAnnotation(PropertyName.class); + return annotation.value(); + } + + return null; + } + + private static String serializedName(String methodName) { + String[] prefixes = new String[]{"get", "set", "is"}; + String methodPrefix = null; + for (String prefix : prefixes) { + if (methodName.startsWith(prefix)) { + methodPrefix = prefix; + } + } + if (methodPrefix == null) { + throw new IllegalArgumentException("Unknown Bean prefix for method: " + methodName); + } + String strippedName = methodName.substring(methodPrefix.length()); + + // Make sure the first word or upper-case prefix is converted to lower-case + char[] chars = strippedName.toCharArray(); + int pos = 0; + while (pos < chars.length && Character.isUpperCase(chars[pos])) { + chars[pos] = Character.toLowerCase(chars[pos]); + pos++; + } + return new String(chars); + } + } +} diff --git a/src/main/java/com/google/firebase/database/utilities/tuple/NameAndPriority.java b/src/main/java/com/google/firebase/database/utilities/tuple/NameAndPriority.java new file mode 100644 index 000000000..c1c20c4c8 --- /dev/null +++ b/src/main/java/com/google/firebase/database/utilities/tuple/NameAndPriority.java @@ -0,0 +1,33 @@ +package com.google.firebase.database.utilities.tuple; + +import com.google.firebase.database.snapshot.ChildKey; +import com.google.firebase.database.snapshot.Node; +import com.google.firebase.database.snapshot.NodeUtilities; + +/** + * User: greg Date: 5/17/13 Time: 3:19 PM + */ +public class NameAndPriority implements Comparable { + + private ChildKey name; + + private Node priority; + + public NameAndPriority(ChildKey name, Node priority) { + this.name = name; + this.priority = priority; + } + + public ChildKey getName() { + return name; + } + + public Node getPriority() { + return priority; + } + + @Override + public int compareTo(NameAndPriority o) { + return NodeUtilities.nameAndPriorityCompare(this.name, this.priority, o.name, o.priority); + } +} diff --git a/src/main/java/com/google/firebase/database/utilities/tuple/NodeAndPath.java b/src/main/java/com/google/firebase/database/utilities/tuple/NodeAndPath.java new file mode 100644 index 000000000..fa3541a4d --- /dev/null +++ b/src/main/java/com/google/firebase/database/utilities/tuple/NodeAndPath.java @@ -0,0 +1,34 @@ +package com.google.firebase.database.utilities.tuple; + +import com.google.firebase.database.core.Path; +import com.google.firebase.database.snapshot.Node; + +/** + * User: greg Date: 5/22/13 Time: 8:35 AM + */ +public class NodeAndPath { + + private Node node; + private Path path; + + public NodeAndPath(Node node, Path path) { + this.node = node; + this.path = path; + } + + public Node getNode() { + return node; + } + + public void setNode(Node node) { + this.node = node; + } + + public Path getPath() { + return path; + } + + public void setPath(Path path) { + this.path = path; + } +} diff --git a/src/main/java/com/google/firebase/database/utilities/tuple/PathAndId.java b/src/main/java/com/google/firebase/database/utilities/tuple/PathAndId.java new file mode 100644 index 000000000..c012e61cf --- /dev/null +++ b/src/main/java/com/google/firebase/database/utilities/tuple/PathAndId.java @@ -0,0 +1,25 @@ +package com.google.firebase.database.utilities.tuple; + +import com.google.firebase.database.core.Path; + +/** + * User: greg Date: 5/22/13 Time: 12:21 PM + */ +public class PathAndId { + + private Path path; + private long id; + + public PathAndId(Path path, long id) { + this.path = path; + this.id = id; + } + + public Path getPath() { + return path; + } + + public long getId() { + return id; + } +} diff --git a/src/main/java/com/google/firebase/internal/AuthStateListener.java b/src/main/java/com/google/firebase/internal/AuthStateListener.java new file mode 100644 index 000000000..c2bbb1cba --- /dev/null +++ b/src/main/java/com/google/firebase/internal/AuthStateListener.java @@ -0,0 +1,13 @@ +package com.google.firebase.internal; + +/** + * An event listener for receiving authentication state change events (i.e. token renewals). + */ +public interface AuthStateListener { + + /** + * Gets called when FirebaseApp fetches a new access token. The GetTokenResult encapsulates the + * newly fetched token. + */ + void onAuthStateChanged(GetTokenResult tokenResult); +} diff --git a/src/main/java/com/google/firebase/internal/Base64.java b/src/main/java/com/google/firebase/internal/Base64.java new file mode 100644 index 000000000..1b835ba5e --- /dev/null +++ b/src/main/java/com/google/firebase/internal/Base64.java @@ -0,0 +1,750 @@ +/* + * Copyright (C) 2010 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.internal; + +import java.io.UnsupportedEncodingException; +/* TODO(depoll): clear use of this class with someone */ + +/** + * Utilities for encoding and decoding the Base64 representation of + * binary data. See RFCs 2045 and 3548. + */ +public class Base64 { + + /** + * Default values for encoder/decoder flags. + */ + public static final int DEFAULT = 0; + + /** + * Encoder flag bit to omit the padding '=' characters at the end + * of the output (if any). + */ + public static final int NO_PADDING = 1; + + /** + * Encoder flag bit to omit all line terminators (i.e., the output + * will be on one long line). + */ + public static final int NO_WRAP = 2; + + /** + * Encoder flag bit to indicate lines should be terminated with a + * CRLF pair instead of just an LF. Has no effect if {@code + * NO_WRAP} is specified as well. + */ + public static final int CRLF = 4; + + /** + * Encoder/decoder flag bit to indicate using the "URL and + * filename safe" variant of Base64 (see RFC 3548 section 4) where + * {@code -} and {@code _} are used in place of {@code +} and + * {@code /}. + */ + public static final int URL_SAFE = 8; + + /** + * Flag to pass to {@link Base64OutputStream} to indicate that it + * should not close the output stream it is wrapping when it + * itself is closed. + */ + public static final int NO_CLOSE = 16; + + // -------------------------------------------------------- + // shared code + // -------------------------------------------------------- + + /* package */ static abstract class Coder { + + public byte[] output; + public int op; + + /** + * Encode/decode another block of input data. this.output is + * provided by the caller, and must be big enough to hold all + * the coded data. On exit, this.opwill be set to the length + * of the coded data. + * + * @param finish true if this is the final call to process for this object. Will finalize the + * coder state and include any final bytes in the output. + * @return true if the input so far is good; false if some error has been detected in the input + * stream.. + */ + public abstract boolean process(byte[] input, int offset, int len, boolean finish); + + /** + * @return the maximum number of bytes a call to process() could produce for the given number of + * input bytes. This may be an overestimate. + */ + public abstract int maxOutputSize(int len); + } + + // -------------------------------------------------------- + // decoding + // -------------------------------------------------------- + + /** + * Decode the Base64-encoded data in input and return the data in + * a new byte array. + * + *

The padding '=' characters at the end are considered optional, but + * if any are present, there must be the correct number of them. + * + * @param str the input String to decode, which is converted to bytes using the default charset + * @param flags controls certain features of the decoded output. Pass {@code DEFAULT} to decode + * standard Base64. + * @throws IllegalArgumentException if the input contains incorrect padding + */ + public static byte[] decode(String str, int flags) { + return decode(str.getBytes(), flags); + } + + /** + * Decode the Base64-encoded data in input and return the data in + * a new byte array. + * + *

The padding '=' characters at the end are considered optional, but + * if any are present, there must be the correct number of them. + * + * @param input the input array to decode + * @param flags controls certain features of the decoded output. Pass {@code DEFAULT} to decode + * standard Base64. + * @throws IllegalArgumentException if the input contains incorrect padding + */ + public static byte[] decode(byte[] input, int flags) { + return decode(input, 0, input.length, flags); + } + + /** + * Decode the Base64-encoded data in input and return the data in + * a new byte array. + * + *

The padding '=' characters at the end are considered optional, but + * if any are present, there must be the correct number of them. + * + * @param input the data to decode + * @param offset the position within the input array at which to start + * @param len the number of bytes of input to decode + * @param flags controls certain features of the decoded output. Pass {@code DEFAULT} to decode + * standard Base64. + * @throws IllegalArgumentException if the input contains incorrect padding + */ + public static byte[] decode(byte[] input, int offset, int len, int flags) { + // Allocate space for the most data the input could represent. + // (It could contain less if it contains whitespace, etc.) + Decoder decoder = new Decoder(flags, new byte[len * 3 / 4]); + + if (!decoder.process(input, offset, len, true)) { + throw new IllegalArgumentException("bad base-64"); + } + + // Maybe we got lucky and allocated exactly enough output space. + if (decoder.op == decoder.output.length) { + return decoder.output; + } + + // Need to shorten the array, so allocate a new one of the + // right size and copy. + byte[] temp = new byte[decoder.op]; + System.arraycopy(decoder.output, 0, temp, 0, decoder.op); + return temp; + } + + /* package */ static class Decoder extends Coder { + + /** + * Lookup table for turning bytes into their position in the + * Base64 alphabet. + */ + private static final int DECODE[] = { + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, 62, -1, -1, -1, 63, + 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, -1, -1, -1, -2, -1, -1, + -1, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, + 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, -1, -1, -1, -1, -1, + -1, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, + 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, -1, -1, -1, -1, -1, + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, + }; + + /** + * Decode lookup table for the "web safe" variant (RFC 3548 + * sec. 4) where - and _ replace + and /. + */ + private static final int DECODE_WEBSAFE[] = { + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, 62, -1, -1, + 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, -1, -1, -1, -2, -1, -1, + -1, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, + 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, -1, -1, -1, -1, 63, + -1, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, + 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, -1, -1, -1, -1, -1, + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, + }; + + /** + * Non-data values in the DECODE arrays. + */ + private static final int SKIP = -1; + private static final int EQUALS = -2; + + /** + * States 0-3 are reading through the next input tuple. + * State 4 is having read one '=' and expecting exactly + * one more. + * State 5 is expecting no more data or padding characters + * in the input. + * State 6 is the error state; an error has been detected + * in the input and no future input can "fix" it. + */ + private int state; // state number (0 to 6) + private int value; + + final private int[] alphabet; + + public Decoder(int flags, byte[] output) { + this.output = output; + + alphabet = ((flags & URL_SAFE) == 0) ? DECODE : DECODE_WEBSAFE; + state = 0; + value = 0; + } + + /** + * @return an overestimate for the number of bytes {@code len} bytes could decode to. + */ + public int maxOutputSize(int len) { + return len * 3 / 4 + 10; + } + + /** + * Decode another block of input data. + * + * @return true if the state machine is still healthy. false if bad base-64 data has been + * detected in the input stream. + */ + public boolean process(byte[] input, int offset, int len, boolean finish) { + if (this.state == 6) { + return false; + } + + int p = offset; + len += offset; + + // Using local variables makes the decoder about 12% + // faster than if we manipulate the member variables in + // the loop. (Even alphabet makes a measurable + // difference, which is somewhat surprising to me since + // the member variable is final.) + int state = this.state; + int value = this.value; + int op = 0; + final byte[] output = this.output; + final int[] alphabet = this.alphabet; + + while (p < len) { + // Try the fast path: we're starting a new tuple and the + // next four bytes of the input stream are all data + // bytes. This corresponds to going through states + // 0-1-2-3-0. We expect to use this method for most of + // the data. + // + // If any of the next four bytes of input are non-data + // (whitespace, etc.), value will end up negative. (All + // the non-data values in decode are small negative + // numbers, so shifting any of them up and or'ing them + // together will result in a value with its top bit set.) + // + // You can remove this whole block and the output should + // be the same, just slower. + if (state == 0) { + while (p + 4 <= len && + (value = ((alphabet[input[p] & 0xff] << 18) | + (alphabet[input[p + 1] & 0xff] << 12) | + (alphabet[input[p + 2] & 0xff] << 6) | + (alphabet[input[p + 3] & 0xff]))) >= 0) { + output[op + 2] = (byte) value; + output[op + 1] = (byte) (value >> 8); + output[op] = (byte) (value >> 16); + op += 3; + p += 4; + } + if (p >= len) { + break; + } + } + + // The fast path isn't available -- either we've read a + // partial tuple, or the next four input bytes aren't all + // data, or whatever. Fall back to the slower state + // machine implementation. + + int d = alphabet[input[p++] & 0xff]; + + switch (state) { + case 0: + if (d >= 0) { + value = d; + ++state; + } else if (d != SKIP) { + this.state = 6; + return false; + } + break; + + case 1: + if (d >= 0) { + value = (value << 6) | d; + ++state; + } else if (d != SKIP) { + this.state = 6; + return false; + } + break; + + case 2: + if (d >= 0) { + value = (value << 6) | d; + ++state; + } else if (d == EQUALS) { + // Emit the last (partial) output tuple; + // expect exactly one more padding character. + output[op++] = (byte) (value >> 4); + state = 4; + } else if (d != SKIP) { + this.state = 6; + return false; + } + break; + + case 3: + if (d >= 0) { + // Emit the output triple and return to state 0. + value = (value << 6) | d; + output[op + 2] = (byte) value; + output[op + 1] = (byte) (value >> 8); + output[op] = (byte) (value >> 16); + op += 3; + state = 0; + } else if (d == EQUALS) { + // Emit the last (partial) output tuple; + // expect no further data or padding characters. + output[op + 1] = (byte) (value >> 2); + output[op] = (byte) (value >> 10); + op += 2; + state = 5; + } else if (d != SKIP) { + this.state = 6; + return false; + } + break; + + case 4: + if (d == EQUALS) { + ++state; + } else if (d != SKIP) { + this.state = 6; + return false; + } + break; + + case 5: + if (d != SKIP) { + this.state = 6; + return false; + } + break; + } + } + + if (!finish) { + // We're out of input, but a future call could provide + // more. + this.state = state; + this.value = value; + this.op = op; + return true; + } + + // Done reading input. Now figure out where we are left in + // the state machine and finish up. + + switch (state) { + case 0: + // Output length is a multiple of three. Fine. + break; + case 1: + // Read one extra input byte, which isn't enough to + // make another output byte. Illegal. + this.state = 6; + return false; + case 2: + // Read two extra input bytes, enough to emit 1 more + // output byte. Fine. + output[op++] = (byte) (value >> 4); + break; + case 3: + // Read three extra input bytes, enough to emit 2 more + // output bytes. Fine. + output[op++] = (byte) (value >> 10); + output[op++] = (byte) (value >> 2); + break; + case 4: + // Read one padding '=' when we expected 2. Illegal. + this.state = 6; + return false; + case 5: + // Read all the padding '='s we expected and no more. + // Fine. + break; + } + + this.state = state; + this.op = op; + return true; + } + } + + // -------------------------------------------------------- + // encoding + // -------------------------------------------------------- + + /** + * Base64-encode the given data and return a newly allocated + * String with the result. + * + * @param input the data to encode + * @param flags controls certain features of the encoded output. Passing {@code DEFAULT} results + * in output that adheres to RFC 2045. + */ + public static String encodeToString(byte[] input, int flags) { + try { + return new String(encode(input, flags), "US-ASCII"); + } catch (UnsupportedEncodingException e) { + // US-ASCII is guaranteed to be available. + throw new AssertionError(e); + } + } + + /** + * Base64-encode the given data and return a newly allocated + * String with the result. + * + * @param input the data to encode + * @param offset the position within the input array at which to start + * @param len the number of bytes of input to encode + * @param flags controls certain features of the encoded output. Passing {@code DEFAULT} results + * in output that adheres to RFC 2045. + */ + public static String encodeToString(byte[] input, int offset, int len, int flags) { + try { + return new String(encode(input, offset, len, flags), "US-ASCII"); + } catch (UnsupportedEncodingException e) { + // US-ASCII is guaranteed to be available. + throw new AssertionError(e); + } + } + + /** + * Base64-encode the given data and return a newly allocated + * byte[] with the result. + * + * @param input the data to encode + * @param flags controls certain features of the encoded output. Passing {@code DEFAULT} results + * in output that adheres to RFC 2045. + */ + public static byte[] encode(byte[] input, int flags) { + return encode(input, 0, input.length, flags); + } + + /** + * Base64-encode the given data and return a newly allocated + * byte[] with the result. + * + * @param input the data to encode + * @param offset the position within the input array at which to start + * @param len the number of bytes of input to encode + * @param flags controls certain features of the encoded output. Passing {@code DEFAULT} results + * in output that adheres to RFC 2045. + */ + public static byte[] encode(byte[] input, int offset, int len, int flags) { + Encoder encoder = new Encoder(flags, null); + + // Compute the exact length of the array we will produce. + int output_len = len / 3 * 4; + + // Account for the tail of the data and the padding bytes, if any. + if (encoder.do_padding) { + if (len % 3 > 0) { + output_len += 4; + } + } else { + switch (len % 3) { + case 0: + break; + case 1: + output_len += 2; + break; + case 2: + output_len += 3; + break; + } + } + + // Account for the newlines, if any. + if (encoder.do_newline && len > 0) { + output_len += (((len - 1) / (3 * Encoder.LINE_GROUPS)) + 1) * + (encoder.do_cr ? 2 : 1); + } + + encoder.output = new byte[output_len]; + encoder.process(input, offset, len, true); + + assert encoder.op == output_len; + + return encoder.output; + } + + /* package */ static class Encoder extends Coder { + + /** + * Emit a new line every this many output tuples. Corresponds to + * a 76-character line length (the maximum allowable according to + * RFC 2045). + */ + public static final int LINE_GROUPS = 19; + + /** + * Lookup table for turning Base64 alphabet positions (6 bits) + * into output bytes. + */ + private static final byte ENCODE[] = { + 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', + 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z', 'a', 'b', 'c', 'd', 'e', 'f', + 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', + 'w', 'x', 'y', 'z', '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', '+', '/', + }; + + /** + * Lookup table for turning Base64 alphabet positions (6 bits) + * into output bytes. + */ + private static final byte ENCODE_WEBSAFE[] = { + 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', + 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z', 'a', 'b', 'c', 'd', 'e', 'f', + 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', + 'w', 'x', 'y', 'z', '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', '-', '_', + }; + + final private byte[] tail; + /* package */ int tailLen; + private int count; + + final public boolean do_padding; + final public boolean do_newline; + final public boolean do_cr; + final private byte[] alphabet; + + public Encoder(int flags, byte[] output) { + this.output = output; + + do_padding = (flags & NO_PADDING) == 0; + do_newline = (flags & NO_WRAP) == 0; + do_cr = (flags & CRLF) != 0; + alphabet = ((flags & URL_SAFE) == 0) ? ENCODE : ENCODE_WEBSAFE; + + tail = new byte[2]; + tailLen = 0; + + count = do_newline ? LINE_GROUPS : -1; + } + + /** + * @return an overestimate for the number of bytes {@code len} bytes could encode to. + */ + public int maxOutputSize(int len) { + return len * 8 / 5 + 10; + } + + public boolean process(byte[] input, int offset, int len, boolean finish) { + // Using local variables makes the encoder about 9% faster. + final byte[] alphabet = this.alphabet; + final byte[] output = this.output; + int op = 0; + int count = this.count; + + int p = offset; + len += offset; + int v = -1; + + // First we need to concatenate the tail of the previous call + // with any input bytes available now and see if we can empty + // the tail. + + switch (tailLen) { + case 0: + // There was no tail. + break; + + case 1: + if (p + 2 <= len) { + // A 1-byte tail with at least 2 bytes of + // input available now. + v = ((tail[0] & 0xff) << 16) | + ((input[p++] & 0xff) << 8) | + (input[p++] & 0xff); + tailLen = 0; + } + break; + + case 2: + if (p + 1 <= len) { + // A 2-byte tail with at least 1 byte of input. + v = ((tail[0] & 0xff) << 16) | + ((tail[1] & 0xff) << 8) | + (input[p++] & 0xff); + tailLen = 0; + } + break; + } + + if (v != -1) { + output[op++] = alphabet[(v >> 18) & 0x3f]; + output[op++] = alphabet[(v >> 12) & 0x3f]; + output[op++] = alphabet[(v >> 6) & 0x3f]; + output[op++] = alphabet[v & 0x3f]; + if (--count == 0) { + if (do_cr) { + output[op++] = '\r'; + } + output[op++] = '\n'; + count = LINE_GROUPS; + } + } + + // At this point either there is no tail, or there are fewer + // than 3 bytes of input available. + + // The main loop, turning 3 input bytes into 4 output bytes on + // each iteration. + while (p + 3 <= len) { + v = ((input[p] & 0xff) << 16) | + ((input[p + 1] & 0xff) << 8) | + (input[p + 2] & 0xff); + output[op] = alphabet[(v >> 18) & 0x3f]; + output[op + 1] = alphabet[(v >> 12) & 0x3f]; + output[op + 2] = alphabet[(v >> 6) & 0x3f]; + output[op + 3] = alphabet[v & 0x3f]; + p += 3; + op += 4; + if (--count == 0) { + if (do_cr) { + output[op++] = '\r'; + } + output[op++] = '\n'; + count = LINE_GROUPS; + } + } + + if (finish) { + // Finish up the tail of the input. Note that we need to + // consume any bytes in tail before any bytes + // remaining in input; there should be at most two bytes + // total. + + if (p - tailLen == len - 1) { + int t = 0; + v = ((tailLen > 0 ? tail[t++] : input[p++]) & 0xff) << 4; + tailLen -= t; + output[op++] = alphabet[(v >> 6) & 0x3f]; + output[op++] = alphabet[v & 0x3f]; + if (do_padding) { + output[op++] = '='; + output[op++] = '='; + } + if (do_newline) { + if (do_cr) { + output[op++] = '\r'; + } + output[op++] = '\n'; + } + } else if (p - tailLen == len - 2) { + int t = 0; + v = (((tailLen > 1 ? tail[t++] : input[p++]) & 0xff) << 10) | + (((tailLen > 0 ? tail[t++] : input[p++]) & 0xff) << 2); + tailLen -= t; + output[op++] = alphabet[(v >> 12) & 0x3f]; + output[op++] = alphabet[(v >> 6) & 0x3f]; + output[op++] = alphabet[v & 0x3f]; + if (do_padding) { + output[op++] = '='; + } + if (do_newline) { + if (do_cr) { + output[op++] = '\r'; + } + output[op++] = '\n'; + } + } else if (do_newline && op > 0 && count != LINE_GROUPS) { + if (do_cr) { + output[op++] = '\r'; + } + output[op++] = '\n'; + } + + assert tailLen == 0; + assert p == len; + } else { + // Save the leftovers in tail to be consumed on the next + // call to encodeInternal. + + if (p == len - 1) { + tail[tailLen++] = input[p]; + } else if (p == len - 2) { + tail[tailLen++] = input[p]; + tail[tailLen++] = input[p + 1]; + } + } + + this.op = op; + this.count = count; + + return true; + } + } + + private Base64() { + } // don't instantiate +} diff --git a/src/main/java/com/google/firebase/internal/Base64Utils.java b/src/main/java/com/google/firebase/internal/Base64Utils.java new file mode 100644 index 000000000..9625d26a6 --- /dev/null +++ b/src/main/java/com/google/firebase/internal/Base64Utils.java @@ -0,0 +1,104 @@ +// Copyright 2011 Google Inc. All Rights Reserved. + +package com.google.firebase.internal; + +/** + * Base64 conversion utility helpers. + * + * @hide + */ +public final class Base64Utils { + + /** + * @param encodedData String to decode into byte array. If input is null, output data will be null + * as well. + * @return byte array corresponding to encoded string. + */ + public static byte[] decode(String encodedData) { + // Base64 decode explodes on null input. + if (encodedData == null) { + return null; + } + return Base64.decode(encodedData, Base64.DEFAULT); + } + + /** + * @param encodedData String to decode into byte array using the URL_SAFE option. If input is + * null, output data will be null as well. + * @return byte array corresponding to encoded string. + */ + public static byte[] decodeUrlSafe(String encodedData) { + // Base64 decode explodes on null input. + if (encodedData == null) { + return null; + } + return Base64.decode(encodedData, Base64.URL_SAFE | Base64.NO_WRAP); + } + + /** + * @param encodedData String to decode into byte array using the URL_SAFE, NO_WRAP, and NO_PADDING + * options. If input is null, output data will be null as well. + * @return byte array corresponding to encoded string. + */ + public static byte[] decodeUrlSafeNoPadding(String encodedData) { + // Base64 decode explodes on null input. + if (encodedData == null) { + return null; + } + return Base64.decode(encodedData, Base64.URL_SAFE | Base64.NO_WRAP | Base64.NO_PADDING); + } + + /** + * This is the equivalent of {@link #decodeUrlSafeNoPadding(String)} except it accepts a byte[]. + * + * @param encodedData byte[] to decode into byte array using the URL_SAFE, NO_WRAP, and NO_PADDING + * options. If input is null, output data will be null as well. + * @return byte array corresponding to encoded string. + */ + public static byte[] decodeUrlSafeNoPadding(byte[] encodedData) { + // Base64 decode explodes on null input. + if (encodedData == null) { + return null; + } + return Base64.decode(encodedData, Base64.URL_SAFE | Base64.NO_WRAP | Base64.NO_PADDING); + } + + /** + * @param data Byte array to encode with Base64. If input data is null, output will be null as + * well. + * @return String representing encoded data. + */ + public static String encode(byte[] data) { + // Base64 encoder will explode if you give it null. + if (data == null) { + return null; + } + return Base64.encodeToString(data, Base64.DEFAULT); + } + + /** + * @param data Byte array to encode with Base64 using the URL_SAFE option. If input data is null, + * output will be null as well. + * @return String representing encoded data. + */ + public static String encodeUrlSafe(byte[] data) { + // Base64 encoder will explode if you give it null. + if (data == null) { + return null; + } + return Base64.encodeToString(data, Base64.URL_SAFE | Base64.NO_WRAP); + } + + /** + * @param data Byte array to encode with Base64 using the URL_SAFE, NO_WRAP, and NO_PADDING + * options. If input data is null, output will be null as well. + * @return String representing encoded data. + */ + public static String encodeUrlSafeNoPadding(byte[] data) { + // Base64 encoder will explode if you give it null. + if (data == null) { + return null; + } + return Base64.encodeToString(data, Base64.URL_SAFE | Base64.NO_WRAP | Base64.NO_PADDING); + } +} diff --git a/src/main/java/com/google/firebase/internal/FirebaseAppStore.java b/src/main/java/com/google/firebase/internal/FirebaseAppStore.java new file mode 100644 index 000000000..6e6d647a7 --- /dev/null +++ b/src/main/java/com/google/firebase/internal/FirebaseAppStore.java @@ -0,0 +1,69 @@ +package com.google.firebase.internal; + +import com.google.common.annotations.VisibleForTesting; +import com.google.firebase.FirebaseApp; +import com.google.firebase.FirebaseOptions; +import java.util.Collections; +import java.util.Set; +import java.util.concurrent.atomic.AtomicReference; + +/** + * No-op base class of FirebaseAppStore. + */ +public class FirebaseAppStore { + + private static final AtomicReference sInstance = new AtomicReference<>(); + + @Nullable + public static FirebaseAppStore getInstance() { + return sInstance.get(); + } + + // TODO(arondeak): reenable persistence. See b/28158809. + public static FirebaseAppStore initialize() { + sInstance.compareAndSet(null /* expected */, new FirebaseAppStore()); + return sInstance.get(); + } + + FirebaseAppStore() { + } + + /** + * @hide + */ + public static void setInstanceForTest(FirebaseAppStore firebaseAppStore) { + sInstance.set(firebaseAppStore); + } + + @VisibleForTesting + public static void clearInstanceForTest() { + FirebaseAppStore instance = sInstance.get(); + if (instance != null) { + instance.resetStore(); + } + sInstance.set(null); + } + + /** + * The returned set is mutable. + */ + public Set getAllPersistedAppNames() { + return Collections.emptySet(); + } + + public void persistApp(@NonNull FirebaseApp app) { + } + + public void removeApp(@NonNull String name) { + } + + /** + * @return The restored {@link FirebaseOptions}, or null if it doesn't exist. + */ + public FirebaseOptions restoreAppOptions(@NonNull String name) { + return null; + } + + protected void resetStore() { + } +} diff --git a/src/main/java/com/google/firebase/internal/FirebaseExecutors.java b/src/main/java/com/google/firebase/internal/FirebaseExecutors.java new file mode 100644 index 000000000..665f8ca22 --- /dev/null +++ b/src/main/java/com/google/firebase/internal/FirebaseExecutors.java @@ -0,0 +1,22 @@ +package com.google.firebase.internal; + +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; + +/** + * Default executors used for internal Firebase threads. + */ +public class FirebaseExecutors { + + public static final ScheduledExecutorService DEFAULT_SCHEDULED_EXECUTOR; + + static { + if (GaeThreadFactory.isAvailable()) { + DEFAULT_SCHEDULED_EXECUTOR = GaeThreadFactory.DEFAULT_EXECUTOR; + } else { + DEFAULT_SCHEDULED_EXECUTOR = Executors.newSingleThreadScheduledExecutor( + Executors.defaultThreadFactory()); + } + } + +} diff --git a/src/main/java/com/google/firebase/internal/GaeScheduledExecutorService.java b/src/main/java/com/google/firebase/internal/GaeScheduledExecutorService.java new file mode 100644 index 000000000..f8c3201f2 --- /dev/null +++ b/src/main/java/com/google/firebase/internal/GaeScheduledExecutorService.java @@ -0,0 +1,185 @@ +package com.google.firebase.internal; + +import java.util.Collection; +import java.util.List; +import java.util.concurrent.Callable; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Future; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.SynchronousQueue; +import java.util.concurrent.ThreadPoolExecutor; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.concurrent.atomic.AtomicReference; + +/** + * A ScheduledExecutorService instance that can operate in the Google App Engine environment. + * The scheduling operations (i.e. operations specific to the ScheduledExecutorService interface) + * can only be used when background thread support is enabled. These operations will throw + * UnsupportedOperationException when invoked in an auto-scaled instance without background thread + * support. Operations inherited from the ExecutorService and Executor interfaces will work + * regardless of the background threads support. This implementation is also lazy loaded to + * prevent unnecessary RPC calls to the GAE backend. + */ +public class GaeScheduledExecutorService implements ScheduledExecutorService { + + private final AtomicReference executor = new AtomicReference<>(); + private final String threadName; + + GaeScheduledExecutorService(String threadName) { + this.threadName = Preconditions.checkNotEmpty(threadName); + } + + private ExecutorWrapper ensureExecutorWrapper() { + ExecutorWrapper wrapper = executor.get(); + if (wrapper == null) { + synchronized (executor) { + wrapper = executor.get(); + if (wrapper == null) { + wrapper = new ExecutorWrapper(threadName); + executor.compareAndSet(null, wrapper); + } + } + } + return wrapper; + } + + private ExecutorService ensureExecutorService() { + return ensureExecutorWrapper().getExecutorService(); + } + + private ScheduledExecutorService ensureScheduledExecutorService() { + ScheduledExecutorService scheduledExecutorService = ensureExecutorWrapper() + .getScheduledExecutorService(); + if (scheduledExecutorService != null) { + return scheduledExecutorService; + } else { + throw new UnsupportedOperationException("ScheduledExecutorService not available. " + + "A manually-scaled instance is required when running the Firebase Admin SDK on GAE."); + } + } + + @Override + public ScheduledFuture schedule(Runnable command, long delay, TimeUnit unit) { + return ensureScheduledExecutorService().schedule(command, delay, unit); + } + + @Override + public ScheduledFuture schedule(Callable callable, long delay, TimeUnit unit) { + return ensureScheduledExecutorService().schedule(callable, delay, unit); + } + + @Override + public ScheduledFuture scheduleAtFixedRate(Runnable command, long initialDelay, long period, + TimeUnit unit) { + return ensureScheduledExecutorService() + .scheduleAtFixedRate(command, initialDelay, period, unit); + } + + @Override + public ScheduledFuture scheduleWithFixedDelay(Runnable command, long initialDelay, long delay, + TimeUnit unit) { + return ensureScheduledExecutorService() + .scheduleWithFixedDelay(command, initialDelay, delay, unit); + } + + @Override + public Future submit(Callable task) { + return ensureExecutorService().submit(task); + } + + @Override + public Future submit(Runnable task, T result) { + return ensureExecutorService().submit(task, result); + } + + @Override + public Future submit(Runnable task) { + return ensureExecutorService().submit(task); + } + + @Override + public List> invokeAll(Collection> tasks) + throws InterruptedException { + return ensureExecutorService().invokeAll(tasks); + } + + @Override + public List> invokeAll(Collection> tasks, long timeout, + TimeUnit unit) throws InterruptedException { + return ensureExecutorService().invokeAll(tasks, timeout, unit); + } + + @Override + public T invokeAny(Collection> tasks) + throws InterruptedException, ExecutionException { + return ensureExecutorService().invokeAny(tasks); + } + + @Override + public T invokeAny(Collection> tasks, long timeout, TimeUnit unit) + throws InterruptedException, ExecutionException, TimeoutException { + return ensureExecutorService().invokeAny(tasks, timeout, unit); + } + + @Override + public void shutdown() { + ensureExecutorService().shutdown(); + } + + @Override + public List shutdownNow() { + return ensureExecutorService().shutdownNow(); + } + + @Override + public boolean isShutdown() { + return ensureExecutorService().isShutdown(); + } + + @Override + public boolean isTerminated() { + return ensureExecutorService().isTerminated(); + } + + @Override + public boolean awaitTermination(long timeout, TimeUnit unit) throws InterruptedException { + return ensureExecutorService().awaitTermination(timeout, unit); + } + + @Override + public void execute(Runnable command) { + ensureExecutorService().execute(command); + } + + + private static class ExecutorWrapper { + + private final ExecutorService executorService; + private final ScheduledExecutorService scheduledExecutorService; + + ExecutorWrapper(String threadName) { + GaeThreadFactory threadFactory = GaeThreadFactory.getInstance(); + if (threadFactory.isUsingBackgroundThreads()) { + scheduledExecutorService = new RevivingScheduledExecutor(threadFactory, + threadName, true); + executorService = scheduledExecutorService; + } else { + scheduledExecutorService = null; + executorService = new ThreadPoolExecutor(0, Integer.MAX_VALUE, + 0L, TimeUnit.SECONDS, new SynchronousQueue(), + threadFactory); + } + } + + ExecutorService getExecutorService() { + return executorService; + } + + ScheduledExecutorService getScheduledExecutorService() { + return scheduledExecutorService; + } + } +} diff --git a/src/main/java/com/google/firebase/internal/GaeThreadFactory.java b/src/main/java/com/google/firebase/internal/GaeThreadFactory.java new file mode 100644 index 000000000..056c9f9dd --- /dev/null +++ b/src/main/java/com/google/firebase/internal/GaeThreadFactory.java @@ -0,0 +1,150 @@ +package com.google.firebase.internal; + +import java.lang.reflect.InvocationTargetException; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ThreadFactory; +import java.util.concurrent.atomic.AtomicReference; + +/** + * GaeThreadFactory is a thread factory that works on App Engine. It uses background threads on + * manually-scaled GAE backends and request-scoped threads on automatically scaled instances. + * + *

This class is thread-safe. + */ +public class GaeThreadFactory implements ThreadFactory { + + private static final String TAG = "GaeThreadFactory"; + private static final String GAE_THREAD_MANAGER_CLASS = "com.google.appengine.api.ThreadManager"; + + private static final GaeThreadFactory instance = new GaeThreadFactory(); + public static final ScheduledExecutorService DEFAULT_EXECUTOR = + new GaeScheduledExecutorService("FirebaseDefault"); + + private final AtomicReference threadFactory = new AtomicReference<>(null); + + public static GaeThreadFactory getInstance() { + return instance; + } + + private GaeThreadFactory() { + } + + @Override + public Thread newThread(Runnable r) { + ThreadFactoryWrapper wrapper = threadFactory.get(); + if (wrapper != null) { + return wrapper.getThreadFactory().newThread(r); + } + return initThreadFactory(r); + } + + /** + * Checks whether background thread support is available in the current environment. + * This method forces the ThreadFactory to get fully initialized (if not already initialized), + * by running a no-op thread. + * + * @return true if background thread support is available, and false otherwise. + */ + public boolean isUsingBackgroundThreads() { + ThreadFactoryWrapper wrapper = threadFactory.get(); + if (wrapper != null) { + return wrapper.isUsingBackgroundThreads(); + } + + // Create a no-op thread to force initialize the ThreadFactory implementation. + // Start the resulting thread, since GAE code seems to expect that. + initThreadFactory(new Runnable() { + @Override + public void run() { + } + }).start(); + return threadFactory.get().isUsingBackgroundThreads(); + } + + private Thread initThreadFactory(Runnable r) { + ThreadFactory threadFactory; + boolean usesBackgroundThreads = false; + Thread thread; + // Since we can't tell manually-scaled GAE instances apart until we spawn a thread (which + // sends an RPC and thus is done after class initialization), we initialize both of GAE's + // thread factories here and discard one once we detect that we are running in an + // automatically scaled instance. + // + // Note: It's fine if multiple thread access this block at the same time. + try { + try { + threadFactory = createBackgroundFactory(); + thread = threadFactory.newThread(r); + usesBackgroundThreads = true; + } catch (IllegalStateException e) { + Log.w(TAG, "Falling back to GAE's request-scoped threads. Firebase requires " + + "manually-scaled instances for most operations."); + threadFactory = createRequestScopedFactory(); + thread = threadFactory.newThread(r); + } + } catch (ClassNotFoundException + | InvocationTargetException + | NoSuchMethodException + | IllegalAccessException e) { + threadFactory = new ThreadFactory() { + @Override + public Thread newThread(Runnable r) { + Log.w(TAG, "Failed to initialize native GAE thread factory. " + + "GaeThreadFactory cannot be used in a non-GAE environment."); + return null; + } + }; + thread = null; + } + + ThreadFactoryWrapper wrapper = new ThreadFactoryWrapper(threadFactory, usesBackgroundThreads); + this.threadFactory.compareAndSet(null, wrapper); + return thread; + } + + /** + * Returns whether GaeThreadFactory can be used on this system (true for GAE). + */ + public static boolean isAvailable() { + try { + Class.forName(GAE_THREAD_MANAGER_CLASS); + return System.getProperty("com.google.appengine.runtime.environment") != null; + } catch (ClassNotFoundException e) { + return false; + } + } + + private static ThreadFactory createBackgroundFactory() + throws ClassNotFoundException, NoSuchMethodException, InvocationTargetException, + IllegalAccessException { + Class gaeThreadManager = Class.forName(GAE_THREAD_MANAGER_CLASS); + return (ThreadFactory) gaeThreadManager.getMethod("backgroundThreadFactory").invoke(null); + } + + private static ThreadFactory createRequestScopedFactory() + throws ClassNotFoundException, NoSuchMethodException, InvocationTargetException, + IllegalAccessException { + Class gaeThreadManager = Class.forName(GAE_THREAD_MANAGER_CLASS); + return (ThreadFactory) gaeThreadManager.getMethod("currentRequestThreadFactory").invoke(null); + } + + private static class ThreadFactoryWrapper { + + private final ThreadFactory threadFactory; + private final boolean usingBackgroundThreads; + + private ThreadFactoryWrapper(ThreadFactory threadFactory, boolean usingBackgroundThreads) { + this.threadFactory = Preconditions.checkNotNull(threadFactory); + this.usingBackgroundThreads = usingBackgroundThreads; + } + + ThreadFactory getThreadFactory() { + return threadFactory; + } + + boolean isUsingBackgroundThreads() { + return usingBackgroundThreads; + } + } +} + diff --git a/src/main/java/com/google/firebase/internal/GetTokenResult.java b/src/main/java/com/google/firebase/internal/GetTokenResult.java new file mode 100644 index 000000000..32ca1547d --- /dev/null +++ b/src/main/java/com/google/firebase/internal/GetTokenResult.java @@ -0,0 +1,34 @@ +package com.google.firebase.internal; + +/** + * This class mirrors the GetAccessTokenResult in GITKit. + */ +public class GetTokenResult { + + private String mToken; + + /** + * @param token represents the {@link String} access token. + * @hide + */ + public GetTokenResult(String token) { + mToken = token; + } + + @Nullable + public String getToken() { + return mToken; + } + + @Override + public int hashCode() { + return Objects.hashCode(mToken); + } + + @Override + public boolean equals(Object obj) { + return obj != null + && obj instanceof GetTokenResult + && Objects.equal(mToken, ((GetTokenResult) obj).mToken); + } +} diff --git a/src/main/java/com/google/firebase/internal/GuardedBy.java b/src/main/java/com/google/firebase/internal/GuardedBy.java new file mode 100644 index 000000000..42849bc47 --- /dev/null +++ b/src/main/java/com/google/firebase/internal/GuardedBy.java @@ -0,0 +1,11 @@ +package com.google.firebase.internal; + +/** + * Indicates that the given field can only be accessed when holding a particular lock. + */ +// TODO(depoll): Remove this if we can find a safe alternative or take the dependency. +public @interface GuardedBy { + + String value(); +} + diff --git a/src/main/java/com/google/firebase/internal/Joiner.java b/src/main/java/com/google/firebase/internal/Joiner.java new file mode 100644 index 000000000..414aa81a4 --- /dev/null +++ b/src/main/java/com/google/firebase/internal/Joiner.java @@ -0,0 +1,48 @@ +// Copyright 2014 Google Inc. All Rights Reserved. + +package com.google.firebase.internal; + +import java.util.Iterator; + +/** + * Joins pieces of text with a separator. + */ +public class Joiner { + + public static Joiner on(String separator) { + return new Joiner(separator); + } + + private final String separator; + + private Joiner(String separator) { + this.separator = separator; + } + + /** + * Appends each of part, using the configured separator between each. + */ + public final StringBuilder appendTo(StringBuilder builder, Iterable parts) { + Iterator iterator = parts.iterator(); + if (iterator.hasNext()) { + builder.append(toString(iterator.next())); + while (iterator.hasNext()) { + builder.append(separator); + builder.append(toString(iterator.next())); + } + } + return builder; + } + + /** + * Returns a string containing the string representation of each of {@code parts}, using the + * previously configured separator between each. + */ + public final String join(Iterable parts) { + return appendTo(new StringBuilder(), parts).toString(); + } + + CharSequence toString(Object part) { + return (part instanceof CharSequence) ? (CharSequence) part : part.toString(); + } +} diff --git a/src/main/java/com/google/firebase/internal/Log.java b/src/main/java/com/google/firebase/internal/Log.java new file mode 100644 index 000000000..4d91173de --- /dev/null +++ b/src/main/java/com/google/firebase/internal/Log.java @@ -0,0 +1,50 @@ +package com.google.firebase.internal; + +import java.util.logging.Level; +import java.util.logging.Logger; + +/** + * Provides a logging interface for Firebase implementations. + */ +// TODO(depoll): Remove this or replace logging internally. +public final class Log { + + private static final String PARENT_LOGGER_NAME = "com.google.firebase"; + private static final String LOG_PREFIX = PARENT_LOGGER_NAME + "."; + private static final Logger PARENT_LOGGER = Logger.getLogger(PARENT_LOGGER_NAME); + private static final Level WTF_LEVEL = new Level("WTF", 1100) { + }; + + /** + * Logs a message. Log levels correspond as follows: + *